diff --git a/.changeset/tricky-badgers-post.md b/.changeset/tricky-badgers-post.md new file mode 100644 index 00000000000..26bdbe07428 --- /dev/null +++ b/.changeset/tricky-badgers-post.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/clerk-react': patch +--- + +Experimental: Ground work for fixing stale data between hooks and components by sharing a single cache. diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index aafce89a986..9faea1b61a7 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -71,6 +71,7 @@ "@formkit/auto-animate": "^0.8.2", "@stripe/stripe-js": "5.6.0", "@swc/helpers": "^0.5.17", + "@tanstack/query-core": "5.87.4", "@zxcvbn-ts/core": "3.0.4", "@zxcvbn-ts/language-common": "3.0.4", "alien-signals": "2.0.6", diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index 76c48d467cc..18e842bb3d5 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -117,6 +117,12 @@ const common = ({ mode, variant, disableRHC = false }) => { chunks: 'all', enforce: true, }, + queryCoreVendor: { + test: /[\\/]node_modules[\\/](@tanstack\/query-core)[\\/]/, + name: 'query-core-vendors', + chunks: 'all', + enforce: true, + }, /** * Sign up is shared between the SignUp component and the SignIn component. */ diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 1d9f44f5258..72213936304 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -94,6 +94,7 @@ import type { } from '@clerk/shared/types'; import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url'; import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils'; +import type { QueryClient } from '@tanstack/query-core'; import { debugLogger, initDebugLogger } from '@/utils/debug'; @@ -219,6 +220,7 @@ export class Clerk implements ClerkInterface { // converted to protected environment to support `updateEnvironment` type assertion protected environment?: EnvironmentResource | null; + #queryClient: QueryClient | undefined; #publishableKey = ''; #domain: DomainOrProxyUrl['domain']; #proxyUrl: DomainOrProxyUrl['proxyUrl']; @@ -237,6 +239,28 @@ export class Clerk implements ClerkInterface { #touchThrottledUntil = 0; #publicEventBus = createClerkEventBus(); + get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined { + if (!this.#queryClient) { + void import('./query-core') + .then(module => module.QueryClient) + .then(QueryClient => { + if (this.#queryClient) { + return; + } + this.#queryClient = new QueryClient(); + // @ts-expect-error - queryClientStatus is not typed + this.#publicEventBus.emit('queryClientStatus', 'ready'); + }); + } + + return this.#queryClient + ? { + __tag: 'clerk-rq-client', + client: this.#queryClient, + } + : undefined; + } + public __internal_getCachedResources: | (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>) | undefined; diff --git a/packages/clerk-js/src/core/query-core.ts b/packages/clerk-js/src/core/query-core.ts new file mode 100644 index 00000000000..71a5e77cc2d --- /dev/null +++ b/packages/clerk-js/src/core/query-core.ts @@ -0,0 +1,3 @@ +import { QueryClient } from '@tanstack/query-core'; + +export { QueryClient }; diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index e70cb43df35..70db8104a65 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -146,6 +146,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private premountApiKeysNodes = new Map(); private premountOAuthConsentNodes = new Map(); private premountTaskChooseOrganizationNodes = new Map(); + // A separate Map of `addListener` method calls to handle multiple listeners. private premountAddListenerCalls = new Map< ListenerCallback, @@ -283,6 +284,11 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return this.clerkjs?.isStandardBrowser || this.options.standardBrowser || false; } + get __internal_queryClient() { + // @ts-expect-error - __internal_queryClient is not typed + return this.clerkjs?.__internal_queryClient; + } + get isSatellite() { // This getter can run in environments where window is not available. // In those cases we should expect and use domain as a string @@ -567,6 +573,13 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { this.on('status', listener, { notify: true }); }); + // @ts-expect-error - queryClientStatus is not typed + this.#eventBus.internal.retrieveListeners('queryClientStatus')?.forEach(listener => { + // Since clerkjs exists it will call `this.clerkjs.on('queryClientStatus', listener)` + // @ts-expect-error - queryClientStatus is not typed + this.on('queryClientStatus', listener, { notify: true }); + }); + if (this.preopenSignIn !== null) { clerkjs.openSignIn(this.preopenSignIn); } diff --git a/packages/shared/package.json b/packages/shared/package.json index 421f3db0aa0..057ec80a9e5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -163,6 +163,7 @@ "devDependencies": { "@stripe/react-stripe-js": "3.1.1", "@stripe/stripe-js": "5.6.0", + "@tanstack/query-core": "5.87.4", "@types/glob-to-regexp": "0.4.4", "@types/js-cookie": "3.0.6", "cross-fetch": "^4.1.0", diff --git a/packages/shared/src/react/clerk-rq/queryOptions.ts b/packages/shared/src/react/clerk-rq/queryOptions.ts new file mode 100644 index 00000000000..ee92606d539 --- /dev/null +++ b/packages/shared/src/react/clerk-rq/queryOptions.ts @@ -0,0 +1,80 @@ +import type { + DataTag, + DefaultError, + InitialDataFunction, + NonUndefinedGuard, + OmitKeyof, + QueryFunction, + QueryKey, + SkipToken, +} from '@tanstack/query-core'; + +import type { UseQueryOptions } from './types'; + +export type UndefinedInitialDataOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = UseQueryOptions & { + initialData?: undefined | InitialDataFunction> | NonUndefinedGuard; +}; + +export type UnusedSkipTokenOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = OmitKeyof, 'queryFn'> & { + queryFn?: Exclude['queryFn'], SkipToken | undefined>; +}; + +export type DefinedInitialDataOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Omit, 'queryFn'> & { + initialData: NonUndefinedGuard | (() => NonUndefinedGuard); + queryFn?: QueryFunction; +}; + +export function queryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: DefinedInitialDataOptions, +): DefinedInitialDataOptions & { + queryKey: DataTag; +}; + +export function queryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UnusedSkipTokenOptions, +): UnusedSkipTokenOptions & { + queryKey: DataTag; +}; + +export function queryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UndefinedInitialDataOptions, +): UndefinedInitialDataOptions & { + queryKey: DataTag; +}; + +/** + * + */ +export function queryOptions(options: unknown) { + return options; +} diff --git a/packages/shared/src/react/clerk-rq/types.ts b/packages/shared/src/react/clerk-rq/types.ts new file mode 100644 index 00000000000..6f5bcbfbc8d --- /dev/null +++ b/packages/shared/src/react/clerk-rq/types.ts @@ -0,0 +1,54 @@ +import type { + DefaultError, + DefinedQueryObserverResult, + InfiniteQueryObserverOptions, + OmitKeyof, + QueryKey, + QueryObserverOptions, + QueryObserverResult, +} from '@tanstack/query-core'; + +export type AnyUseBaseQueryOptions = UseBaseQueryOptions; +export interface UseBaseQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> extends QueryObserverOptions { + /** + * Set this to `false` to unsubscribe this observer from updates to the query cache. + * Defaults to `true`. + */ + subscribed?: boolean; +} + +export type AnyUseQueryOptions = UseQueryOptions; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UseQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> extends OmitKeyof, 'suspense'> {} + +export type AnyUseInfiniteQueryOptions = UseInfiniteQueryOptions; +export interface UseInfiniteQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> extends OmitKeyof, 'suspense'> { + /** + * Set this to `false` to unsubscribe this observer from updates to the query cache. + * Defaults to `true`. + */ + subscribed?: boolean; +} + +export type UseBaseQueryResult = QueryObserverResult; + +export type UseQueryResult = UseBaseQueryResult; + +export type DefinedUseQueryResult = DefinedQueryObserverResult; diff --git a/packages/shared/src/react/clerk-rq/use-clerk-query-client.ts b/packages/shared/src/react/clerk-rq/use-clerk-query-client.ts new file mode 100644 index 00000000000..1875742240b --- /dev/null +++ b/packages/shared/src/react/clerk-rq/use-clerk-query-client.ts @@ -0,0 +1,84 @@ +import type { QueryClient } from '@tanstack/query-core'; +import { useEffect, useState } from 'react'; + +import { useClerkInstanceContext } from '../contexts'; + +export type RecursiveMock = { + (...args: unknown[]): RecursiveMock; +} & { + readonly [key in string | symbol]: RecursiveMock; +}; + +/** + * Creates a recursively self-referential Proxy that safely handles: + * - Arbitrary property access (e.g., obj.any.prop.path) + * - Function calls at any level (e.g., obj.a().b.c()) + * - Construction (e.g., new obj.a.b()) + * + * Always returns itself to allow infinite chaining without throwing. + */ +function createRecursiveProxy(label: string): RecursiveMock { + // The callable target for the proxy so that `apply` works + const callableTarget = function noop(): void {}; + + // eslint-disable-next-line prefer-const + let self: RecursiveMock; + const handler: ProxyHandler = { + get(_target, prop) { + // Avoid being treated as a Promise/thenable by test runners or frameworks + if (prop === 'then') { + return undefined; + } + if (prop === 'toString') { + return () => `[${label}]`; + } + if (prop === Symbol.toPrimitive) { + return () => 0; + } + return self; + }, + apply() { + return self; + }, + construct() { + return self as unknown as object; + }, + has() { + return false; + }, + set() { + return false; + }, + }; + + self = new Proxy(callableTarget, handler) as unknown as RecursiveMock; + return self; +} + +const mockQueryClient = createRecursiveProxy('ClerkMockQueryClient') as unknown as QueryClient; + +const useClerkQueryClient = (): [QueryClient, boolean] => { + const clerk = useClerkInstanceContext(); + + // @ts-expect-error - __internal_queryClient is not typed + const queryClient = clerk.__internal_queryClient as { __tag: 'clerk-rq-client'; client: QueryClient } | undefined; + const [, setQueryClientLoaded] = useState( + typeof queryClient === 'object' && '__tag' in queryClient && queryClient.__tag === 'clerk-rq-client', + ); + + useEffect(() => { + const _setQueryClientLoaded = () => setQueryClientLoaded(true); + // @ts-expect-error - queryClientStatus is not typed + clerk.on('queryClientStatus', _setQueryClientLoaded); + return () => { + // @ts-expect-error - queryClientStatus is not typed + clerk.off('queryClientStatus', _setQueryClientLoaded); + }; + }, [clerk, setQueryClientLoaded]); + + const isLoaded = typeof queryClient === 'object' && '__tag' in queryClient && queryClient.__tag === 'clerk-rq-client'; + + return [queryClient?.client || mockQueryClient, isLoaded]; +}; + +export { useClerkQueryClient }; diff --git a/packages/shared/src/react/clerk-rq/useBaseQuery.ts b/packages/shared/src/react/clerk-rq/useBaseQuery.ts new file mode 100644 index 00000000000..84ef1558a24 --- /dev/null +++ b/packages/shared/src/react/clerk-rq/useBaseQuery.ts @@ -0,0 +1,79 @@ +/** + * Stripped down version of useBaseQuery from @tanstack/query-core. + * This implementation allows for an observer to be created every time a query client changes. + */ + +'use client'; +import type { DefaultedQueryObserverOptions, QueryKey, QueryObserver, QueryObserverResult } from '@tanstack/query-core'; +import { noop, notifyManager } from '@tanstack/query-core'; +import * as React from 'react'; + +import type { UseBaseQueryOptions } from './types'; +import { useClerkQueryClient } from './use-clerk-query-client'; + +export type DistributivePick = T extends unknown ? Pick> : never; + +export type CommonQueryResult = 'data' | 'error' | 'isLoading' | 'isFetching' | 'status'; + +/** + * An alternative `useBaseQuery` implementation that allows for an observer to be created every time a query client changes. + * + * @internal + */ +export function useBaseQuery( + options: UseBaseQueryOptions, + Observer: typeof QueryObserver, +): DistributivePick, CommonQueryResult> { + const [client, isQueryClientLoaded] = useClerkQueryClient(); + const defaultedOptions = isQueryClientLoaded + ? client.defaultQueryOptions(options) + : (options as DefaultedQueryObserverOptions); + + // Make sure results are optimistically set in fetching state before subscribing or updating options + defaultedOptions._optimisticResults = 'optimistic'; + + const observer = React.useMemo(() => { + return new Observer(client, defaultedOptions); + }, [client]); + + // note: this must be called before useSyncExternalStore + const result = observer.getOptimisticResult(defaultedOptions); + + const shouldSubscribe = options.subscribed !== false; + React.useSyncExternalStore( + React.useCallback( + onStoreChange => { + const unsubscribe = shouldSubscribe ? observer.subscribe(notifyManager.batchCalls(onStoreChange)) : noop; + + // Update result to make sure we did not miss any query updates + // between creating the observer and subscribing to it. + observer.updateResult(); + + return unsubscribe; + }, + [observer, shouldSubscribe], + ), + () => observer.getCurrentResult(), + () => observer.getCurrentResult(), + ); + + React.useEffect(() => { + observer.setOptions(defaultedOptions); + }, [defaultedOptions, observer]); + + if (!isQueryClientLoaded) { + // In this step we attempt to return a dummy result that matches RQ's pending state while on SSR or until the query client is loaded on the client (after clerk-js loads). + // When the query client is not loaded, we return the result as if the query was not enabled. + // `isLoading` and `isFetching` need to be `false` because we can't know if the query will be enabled during SSR since most conditions rely on client-only data that are available after clerk-js loads. + return { + data: undefined, + error: null, + isLoading: false, + isFetching: false, + status: 'pending', + }; + } + + // Handle result property usage tracking + return !defaultedOptions.notifyOnChangeProps ? observer.trackResult(result) : result; +} diff --git a/packages/shared/src/react/clerk-rq/useQuery.ts b/packages/shared/src/react/clerk-rq/useQuery.ts new file mode 100644 index 00000000000..89416b2e24b --- /dev/null +++ b/packages/shared/src/react/clerk-rq/useQuery.ts @@ -0,0 +1,42 @@ +'use client'; +import type { DefaultError, NoInfer, QueryKey } from '@tanstack/query-core'; +import { QueryObserver } from '@tanstack/query-core'; + +import type { DefinedInitialDataOptions, UndefinedInitialDataOptions } from './queryOptions'; +import type { DefinedUseQueryResult, UseQueryOptions, UseQueryResult } from './types'; +import type { CommonQueryResult, DistributivePick } from './useBaseQuery'; +import { useBaseQuery } from './useBaseQuery'; + +export function useClerkQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: DefinedInitialDataOptions, +): DistributivePick, TError>, CommonQueryResult>; + +export function useClerkQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UndefinedInitialDataOptions, +): DistributivePick, TError>, CommonQueryResult>; + +export function useClerkQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UseQueryOptions, +): DistributivePick, TError>, CommonQueryResult>; + +/** + * + */ +export function useClerkQuery(options: UseQueryOptions) { + return useBaseQuery(options, QueryObserver); +} diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index 60c1898bd16..77bef33fcd7 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -13,8 +13,8 @@ import type { SignedInSessionResource, UserResource, } from '../types'; -import { SWRConfig } from './clerk-swr'; import { createContextAndHook } from './hooks/createContextAndHook'; +import { SWRConfigCompat } from './providers/SWRConfigCompat'; const [ClerkInstanceContext, useClerkInstanceContext] = createContextAndHook('ClerkInstanceContext'); const [UserContext, useUserContext] = createContextAndHook('UserContext'); @@ -66,7 +66,7 @@ const OrganizationProvider = ({ } >) => { return ( - + {children} - + ); }; diff --git a/packages/shared/src/react/hooks/useSubscription.rq.tsx b/packages/shared/src/react/hooks/useSubscription.rq.tsx new file mode 100644 index 00000000000..6b850d5b103 --- /dev/null +++ b/packages/shared/src/react/hooks/useSubscription.rq.tsx @@ -0,0 +1,70 @@ +import { useCallback, useMemo } from 'react'; + +import { eventMethodCalled } from '../../telemetry/events'; +import type { BillingSubscriptionResource, EnvironmentResource } from '../../types'; +import { useClerkQueryClient } from '../clerk-rq/use-clerk-query-client'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { + useAssertWrappedByClerkProvider, + useClerkInstanceContext, + useOrganizationContext, + useUserContext, +} from '../contexts'; +import type { SubscriptionResult, UseSubscriptionParams } from './useSubscription.types'; + +const hookName = 'useSubscription'; + +/** + * @internal + * This is the new implementation of useSubscription using React Query. + * It is exported only if the package is build with the `CLERK_USE_RQ` environment variable set to `true`. + */ +export function useSubscription(params?: UseSubscriptionParams): SubscriptionResult { + useAssertWrappedByClerkProvider(hookName); + + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + // @ts-expect-error `__unstable__environment` is not typed + const environment = clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined; + + clerk.telemetry?.record(eventMethodCalled(hookName)); + + const isOrganization = params?.for === 'organization'; + const billingEnabled = isOrganization + ? environment?.commerceSettings.billing.organization.enabled + : environment?.commerceSettings.billing.user.enabled; + + const [queryClient] = useClerkQueryClient(); + + const queryKey = useMemo(() => { + return [ + 'commerce-subscription', + { + userId: user?.id, + args: { orgId: isOrganization ? organization?.id : undefined }, + }, + ]; + }, [user?.id, isOrganization, organization?.id]); + + const query = useClerkQuery({ + queryKey, + queryFn: ({ queryKey }) => { + const obj = queryKey[1] as { args: { orgId?: string } }; + return clerk.billing.getSubscription(obj.args); + }, + staleTime: 1_000 * 60, + enabled: Boolean(user?.id && billingEnabled) && ((params as any)?.enabled ?? true), + }); + + const revalidate = useCallback(() => queryClient.invalidateQueries({ queryKey }), [queryClient, queryKey]); + + return { + data: query.data, + error: query.error, + isLoading: query.isLoading, + isFetching: query.isFetching, + revalidate, + }; +} diff --git a/packages/shared/src/react/hooks/useSubscription.swr.tsx b/packages/shared/src/react/hooks/useSubscription.swr.tsx new file mode 100644 index 00000000000..aff40c39860 --- /dev/null +++ b/packages/shared/src/react/hooks/useSubscription.swr.tsx @@ -0,0 +1,69 @@ +import { useCallback } from 'react'; + +import { eventMethodCalled } from '../../telemetry/events'; +import type { BillingSubscriptionResource, EnvironmentResource } from '../../types'; +import { useSWR } from '../clerk-swr'; +import { + useAssertWrappedByClerkProvider, + useClerkInstanceContext, + useOrganizationContext, + useUserContext, +} from '../contexts'; +import type { SubscriptionResult, UseSubscriptionParams } from './useSubscription.types'; + +const hookName = 'useSubscription'; + +/** + * @internal + * This is the existing implementation of useSubscription using SWR. + * It is kept here for backwards compatibility until our next major version. + */ +export function useSubscription(params?: UseSubscriptionParams): SubscriptionResult { + useAssertWrappedByClerkProvider(hookName); + + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + // @ts-expect-error `__unstable__environment` is not typed + const environment = clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined; + + clerk.telemetry?.record(eventMethodCalled(hookName)); + + const isOrganization = params?.for === 'organization'; + const billingEnabled = isOrganization + ? environment?.commerceSettings.billing.organization.enabled + : environment?.commerceSettings.billing.user.enabled; + + const swr = useSWR( + billingEnabled + ? { + type: 'commerce-subscription', + userId: user?.id, + args: { orgId: isOrganization ? organization?.id : undefined }, + } + : null, + ({ args, userId }) => { + if (userId) { + return clerk.billing.getSubscription(args); + } + return null; + }, + { + dedupingInterval: 1_000 * 60, + keepPreviousData: params?.keepPreviousData, + }, + ); + + const revalidate = useCallback(() => { + void swr.mutate(); + }, [swr]); + + return { + data: swr.data, + error: swr.error, + isLoading: swr.isLoading, + isFetching: swr.isValidating, + revalidate, + }; +} diff --git a/packages/shared/src/react/hooks/useSubscription.tsx b/packages/shared/src/react/hooks/useSubscription.tsx index e80024db1e2..98cd031a355 100644 --- a/packages/shared/src/react/hooks/useSubscription.tsx +++ b/packages/shared/src/react/hooks/useSubscription.tsx @@ -1,80 +1 @@ -import { useCallback } from 'react'; - -import { eventMethodCalled } from '../../telemetry/events'; -import type { EnvironmentResource, ForPayerType } from '../../types'; -import { useSWR } from '../clerk-swr'; -import { - useAssertWrappedByClerkProvider, - useClerkInstanceContext, - useOrganizationContext, - useUserContext, -} from '../contexts'; - -const hookName = 'useSubscription'; - -type UseSubscriptionParams = { - for?: ForPayerType; - /** - * If `true`, the previous data will be kept in the cache until new data is fetched. - * - * @default false - */ - keepPreviousData?: boolean; -}; - -/** - * @internal - * - * Fetches subscription data for the current user or organization. - * - * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. - */ -export const useSubscription = (params?: UseSubscriptionParams) => { - useAssertWrappedByClerkProvider(hookName); - - const clerk = useClerkInstanceContext(); - const user = useUserContext(); - const { organization } = useOrganizationContext(); - - // @ts-expect-error `__unstable__environment` is not typed - const environment = clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined; - - clerk.telemetry?.record(eventMethodCalled(hookName)); - - const isOrganization = params?.for === 'organization'; - const billingEnabled = isOrganization - ? environment?.commerceSettings.billing.organization.enabled - : environment?.commerceSettings.billing.user.enabled; - - const swr = useSWR( - billingEnabled - ? { - type: 'commerce-subscription', - userId: user?.id, - args: { orgId: isOrganization ? organization?.id : undefined }, - } - : null, - ({ args, userId }) => { - // This allows for supporting keeping previous data between revalidations - // but also hides the stale data on sign-out. - if (userId) { - return clerk.billing.getSubscription(args); - } - return null; - }, - { - dedupingInterval: 1_000 * 60, - keepPreviousData: params?.keepPreviousData, - }, - ); - - const revalidate = useCallback(() => swr.mutate(), [swr.mutate]); - - return { - data: swr.data, - error: swr.error, - isLoading: swr.isLoading, - isFetching: swr.isValidating, - revalidate, - }; -}; +export { useSubscription } from 'virtual:data-hooks/useSubscription'; diff --git a/packages/shared/src/react/hooks/useSubscription.types.ts b/packages/shared/src/react/hooks/useSubscription.types.ts new file mode 100644 index 00000000000..c268635402a --- /dev/null +++ b/packages/shared/src/react/hooks/useSubscription.types.ts @@ -0,0 +1,21 @@ +import type { ForPayerType } from '../../types'; + +export type UseSubscriptionParams = { + for?: ForPayerType; + /** + * If true, the previous data will be kept in the cache until new data is fetched. + * Defaults to false. + */ + keepPreviousData?: boolean; +}; + +export type SubscriptionResult = { + data: TData | undefined | null; + error: unknown; + isLoading: boolean; + isFetching: boolean; + /** + * Revalidate or refetch the subscription data. + */ + revalidate: () => Promise | void; +}; diff --git a/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx b/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx new file mode 100644 index 00000000000..63bb029b3ac --- /dev/null +++ b/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx @@ -0,0 +1,10 @@ +import type { PropsWithChildren } from 'react'; +import React from 'react'; +import { SWRConfig } from 'swr'; + +/** + * @internal + */ +export function SWRConfigCompat({ swrConfig, children }: PropsWithChildren<{ swrConfig?: any }>) { + return {children}; +} diff --git a/packages/shared/src/react/providers/SWRConfigCompat.swr.tsx b/packages/shared/src/react/providers/SWRConfigCompat.swr.tsx new file mode 100644 index 00000000000..555d744474b --- /dev/null +++ b/packages/shared/src/react/providers/SWRConfigCompat.swr.tsx @@ -0,0 +1,9 @@ +import React, { type PropsWithChildren } from 'react'; +import { SWRConfig } from 'swr'; + +/** + * @internal + */ +export function SWRConfigCompat({ swrConfig, children }: PropsWithChildren<{ swrConfig?: any }>) { + return {children}; +} diff --git a/packages/shared/src/react/providers/SWRConfigCompat.tsx b/packages/shared/src/react/providers/SWRConfigCompat.tsx new file mode 100644 index 00000000000..0286d80613d --- /dev/null +++ b/packages/shared/src/react/providers/SWRConfigCompat.tsx @@ -0,0 +1 @@ +export { SWRConfigCompat } from 'virtual:data-hooks/SWRConfigCompat'; diff --git a/packages/shared/src/types/virtual-data-hooks.d.ts b/packages/shared/src/types/virtual-data-hooks.d.ts new file mode 100644 index 00000000000..0f3065af451 --- /dev/null +++ b/packages/shared/src/types/virtual-data-hooks.d.ts @@ -0,0 +1,7 @@ +declare module 'virtual:data-hooks/*' { + // Generic export signatures to satisfy type resolution for virtual modules + export const SWRConfigCompat: any; + export const useSubscription: any; + const mod: any; + export default mod; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index fbb3e74d4c8..07df0392194 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -21,8 +21,12 @@ "emitDeclarationOnly": true, "declaration": true, "declarationMap": true, - "allowJs": true + "allowJs": true, + "paths": { + "virtual:data-hooks/useSubscription": ["./src/react/hooks/useSubscription.swr.tsx"], + "virtual:data-hooks/SWRConfigCompat": ["./src/react/providers/SWRConfigCompat.swr.tsx"] + } }, "exclude": ["node_modules"], - "include": ["src", "global.d.ts"] + "include": ["src", "global.d.ts", "src/types/virtual-data-hooks.d.ts"] } diff --git a/packages/shared/tsdown.config.mts b/packages/shared/tsdown.config.mts index 6a559c60060..6282ea1e89f 100644 --- a/packages/shared/tsdown.config.mts +++ b/packages/shared/tsdown.config.mts @@ -1,3 +1,6 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + import type { Options } from 'tsdown'; import { defineConfig } from 'tsdown'; @@ -45,6 +48,41 @@ export default defineConfig(({ watch }) => { ], outDir: './dist/runtime', unbundle: false, + plugins: [HookAliasPlugin()], }, ]; }); + +const HookAliasPlugin = () => { + const useRQ = process.env.CLERK_USE_RQ === 'true'; + const rqHooks = new Set((process.env.CLERK_RQ_HOOKS ?? '').split(',').filter(Boolean)); + const baseDir = process.cwd(); + + const resolveImpl = (specifier: string) => { + const name = specifier.replace('virtual:data-hooks/', ''); + const chosenRQ = rqHooks.has(name) || useRQ; + const impl = chosenRQ ? `${name}.rq.tsx` : `${name}.swr.tsx`; + + const candidates = name.toLowerCase().includes('provider') + ? [path.join(baseDir, 'src', 'react', 'providers', impl), path.join(baseDir, 'src', 'react', 'hooks', impl)] + : [path.join(baseDir, 'src', 'react', 'hooks', impl), path.join(baseDir, 'src', 'react', 'providers', impl)]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + // default to first candidate; bundler will emit a clear error if missing + return candidates[0]; + }; + + return { + name: 'hook-alias-plugin', + resolveId(id: string) { + if (!id.startsWith('virtual:data-hooks/')) { + return null; + } + return resolveImpl(id); + }, + } as any; +}; diff --git a/packages/shared/vitest.config.mts b/packages/shared/vitest.config.mts index 3d926296ff2..abfa8ee1250 100644 --- a/packages/shared/vitest.config.mts +++ b/packages/shared/vitest.config.mts @@ -1,7 +1,51 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + import { defineConfig } from 'vitest/config'; +function HookAliasPlugin() { + return { + name: 'hook-alias-plugin', + resolveId(id: string) { + if (!id.startsWith('virtual:data-hooks/')) { + return null; + } + + const name = id.replace('virtual:data-hooks/', ''); + const useRQ = process.env.CLERK_USE_RQ === 'true'; + const rqHooks = new Set((process.env.CLERK_RQ_HOOKS ?? '').split(',').filter(Boolean)); + const chosenRQ = rqHooks.has(name) || useRQ; + const impl = `${name}.${chosenRQ ? 'rq' : 'swr'}.tsx`; + + const baseDirs = [process.cwd(), path.join(process.cwd(), 'packages', 'shared')]; + + const candidates: string[] = []; + for (const base of baseDirs) { + if (name.toLowerCase().includes('provider')) { + candidates.push( + path.join(base, 'src', 'react', 'providers', impl), + path.join(base, 'src', 'react', 'hooks', impl), + ); + } else { + candidates.push( + path.join(base, 'src', 'react', 'hooks', impl), + path.join(base, 'src', 'react', 'providers', impl), + ); + } + } + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return candidates[0]; + }, + } as any; +} + export default defineConfig({ - plugins: [], + plugins: [HookAliasPlugin()], test: { watch: false, typecheck: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 087df7cfb9d..aa5c9d89bbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -460,6 +460,9 @@ importers: '@swc/helpers': specifier: ^0.5.17 version: 0.5.17 + '@tanstack/query-core': + specifier: 5.87.4 + version: 5.87.4 '@zxcvbn-ts/core': specifier: 3.0.4 version: 3.0.4 @@ -937,6 +940,9 @@ importers: '@stripe/stripe-js': specifier: 5.6.0 version: 5.6.0 + '@tanstack/query-core': + specifier: 5.87.4 + version: 5.87.4 '@types/glob-to-regexp': specifier: 0.4.4 version: 0.4.4 @@ -2592,7 +2598,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -4995,6 +5001,9 @@ packages: resolution: {integrity: sha512-GG2R9I6QSlbNR9fEuX2sQCigY6K28w51h2634TWmkaHXlzQw+rWuIWr4nAGM9doA+kWRi1LFSFMvAiG3cOqjXQ==} engines: {node: '>=12'} + '@tanstack/query-core@5.87.4': + resolution: {integrity: sha512-uNsg6zMxraEPDVO2Bn+F3/ctHi+Zsk+MMpcN8h6P7ozqD088F6mFY5TfGM7zuyIrL7HKpDyu6QHfLWiDxh3cuw==} + '@tanstack/react-router@1.132.0': resolution: {integrity: sha512-tGNmQrFc4zWQZvjqYnC8ib84H/9QokRl73hr0P2XlxCY2KAgPTk2QjdzW03LqXgQZRXg7++vKznJt4LS9/M3iA==} engines: {node: '>=12'} @@ -20346,6 +20355,8 @@ snapshots: '@tanstack/history@1.132.0': {} + '@tanstack/query-core@5.87.4': {} + '@tanstack/react-router@1.132.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/history': 1.132.0