diff --git a/.changeset/tricky-badgers-post.md b/.changeset/tricky-badgers-post.md new file mode 100644 index 00000000000..883a5ff001c --- /dev/null +++ b/.changeset/tricky-badgers-post.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/clerk-react': patch +--- + +wip diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 362c61fd573..c7d381954da 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "821KB" }, + { "path": "./dist/clerk.js", "maxSize": "823KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "81KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "123KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "63KB" }, diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index d1afe83202b..e2bda0b686d 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 78467f67a79..552574af9f6 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -114,6 +114,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 d9c3d53aef8..892d5effcd3 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -95,6 +95,7 @@ import type { WaitlistResource, Web3Provider, } from '@clerk/types'; +import type { QueryClient } from '@tanstack/query-core'; import { debugLogger, initDebugLogger } from '@/utils/debug'; @@ -222,6 +223,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']; @@ -240,6 +242,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', // make this a symbol + 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/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index f38f9f785f5..0e63be1e544 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -93,7 +93,10 @@ export function ClerkContextProvider(props: ClerkContextProvider) { - + (); 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 543ab746414..9c6f3a7a6e0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -155,6 +155,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..97d5f0bc360 --- /dev/null +++ b/packages/shared/src/react/clerk-rq/use-clerk-query-client.ts @@ -0,0 +1,82 @@ +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 => { + 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]); + + return queryClient?.client || mockQueryClient; +}; + +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..00613951acc --- /dev/null +++ b/packages/shared/src/react/clerk-rq/useBaseQuery.ts @@ -0,0 +1,55 @@ +/** + * 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 { 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 function useBaseQuery( + options: UseBaseQueryOptions, + Observer: typeof QueryObserver, +): QueryObserverResult { + const client = useClerkQueryClient(); + const defaultedOptions = client.defaultQueryOptions(options); + + 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]); + + // 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..33120a541ff --- /dev/null +++ b/packages/shared/src/react/clerk-rq/useQuery.ts @@ -0,0 +1,37 @@ +'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 { useBaseQuery } from './useBaseQuery'; + +export function useClerkQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: DefinedInitialDataOptions, +): DefinedUseQueryResult, TError>; + +export function useClerkQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>(options: UndefinedInitialDataOptions): UseQueryResult, TError>; + +export function useClerkQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>(options: UseQueryOptions): UseQueryResult, TError>; + +/** + * + */ +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 c54ea6f7a2a..aeca4229022 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -13,8 +13,8 @@ import type { import type { PropsWithChildren } from 'react'; import React from 'react'; -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..02e4ded4a1f --- /dev/null +++ b/packages/shared/src/react/hooks/useSubscription.rq.tsx @@ -0,0 +1,70 @@ +import type { BillingSubscriptionResource, EnvironmentResource } from '@clerk/types'; +import { useCallback, useMemo } from 'react'; + +import { eventMethodCalled } from '../../telemetry/events'; +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), + }); + + 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..1a30bb8c67b --- /dev/null +++ b/packages/shared/src/react/hooks/useSubscription.swr.tsx @@ -0,0 +1,69 @@ +import type { BillingSubscriptionResource, EnvironmentResource } from '@clerk/types'; +import { useCallback } from 'react'; + +import { eventMethodCalled } from '../../telemetry/events'; +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 53c48df8c08..98cd031a355 100644 --- a/packages/shared/src/react/hooks/useSubscription.tsx +++ b/packages/shared/src/react/hooks/useSubscription.tsx @@ -1,80 +1 @@ -import type { EnvironmentResource, ForPayerType } from '@clerk/types'; -import { useCallback } from 'react'; - -import { eventMethodCalled } from '../../telemetry/events'; -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..9835de9857b --- /dev/null +++ b/packages/shared/src/react/hooks/useSubscription.types.ts @@ -0,0 +1,21 @@ +import type { ForPayerType } from '@clerk/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..a21ddc663ed --- /dev/null +++ b/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx @@ -0,0 +1,9 @@ +import type { PropsWithChildren } from 'react'; +import React from 'react'; + +/** + * @internal + */ +export function SWRConfigCompat({ children }: PropsWithChildren) { + 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 05919363e30..461a9ae89de 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -13,8 +13,12 @@ "resolveJsonModule": true, "jsx": "react", "lib": ["ES2022", "DOM", "WebWorker"], - "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/tsup.config.ts b/packages/shared/tsup.config.ts index 9d5b8e121c9..ba115e8f9b1 100644 --- a/packages/shared/tsup.config.ts +++ b/packages/shared/tsup.config.ts @@ -1,9 +1,13 @@ import type { Plugin } from 'esbuild'; import * as esbuild from 'esbuild'; +import * as fs from 'fs'; import { readFile } from 'fs/promises'; +import * as path from 'path'; import { defineConfig } from 'tsup'; +// @ts-ignore - resolved by tsup build (resolveJsonModule not needed at type time) import { version as clerkJsVersion } from '../clerk-js/package.json'; +// @ts-ignore - resolved by tsup build import { name, version } from './package.json'; export default defineConfig(overrideOptions => { @@ -26,12 +30,13 @@ export default defineConfig(overrideOptions => { dts: true, target: 'es2022', external: ['react', 'react-dom'], - esbuildPlugins: [WebWorkerMinifyPlugin as any], + esbuildPlugins: [WebWorkerMinifyPlugin as any, HookAliasPlugin() as any], define: { PACKAGE_NAME: `"${name}"`, PACKAGE_VERSION: `"${version}"`, JS_PACKAGE_VERSION: `"${clerkJsVersion}"`, __DEV__: `${isWatch}`, + __USE_RQ__: JSON.stringify(process.env.CLERK_USE_RQ === 'true'), }, }; }); @@ -49,3 +54,37 @@ export const WebWorkerMinifyPlugin: Plugin = { }); }, }; + +const HookAliasPlugin = (): Plugin => { + const useRQ = process.env.CLERK_USE_RQ === 'true'; + const rqHooks = new Set((process.env.CLERK_RQ_HOOKS ?? '').split(',').filter(Boolean)); + const baseDir = __dirname; // packages/shared + + 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; esbuild will emit a clear error if missing + return candidates[0]; + }; + + return { + name: 'hook-alias-plugin', + setup(build) { + build.onResolve({ filter: /^virtual:data-hooks\// }, args => { + const resolved = resolveImpl(args.path); + return { path: resolved }; + }); + }, + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cce2c25ef42..2d3e62ae51d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -470,6 +470,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 @@ -950,6 +953,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 @@ -4883,6 +4889,9 @@ packages: resolution: {integrity: sha512-cs1WKawpXIe+vSTeiZUuSBy8JFjEuDgdMKZFRLKwQysKo8y2q6Q1HvS74Yw+m5IhOW1nTZooa6rlgdfXcgFAaw==} engines: {node: '>=12'} + '@tanstack/query-core@5.87.4': + resolution: {integrity: sha512-uNsg6zMxraEPDVO2Bn+F3/ctHi+Zsk+MMpcN8h6P7ozqD088F6mFY5TfGM7zuyIrL7HKpDyu6QHfLWiDxh3cuw==} + '@tanstack/react-router@1.131.49': resolution: {integrity: sha512-WHgWJ053W8VU8lUYh8abSHVPeQdpaCpfaUAbV+3uYXbip2G+qlmI/Gsbh/BBV3bYtIi6l3t5dqx3ffCXNTzB5Q==} engines: {node: '>=12'} @@ -19649,6 +19658,8 @@ snapshots: '@tanstack/history@1.131.2': {} + '@tanstack/query-core@5.87.4': {} + '@tanstack/react-router@1.131.49(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/history': 1.131.2