diff --git a/.changeset/remove-swr-switches.md b/.changeset/remove-swr-switches.md deleted file mode 100644 index a8b10845b82..00000000000 --- a/.changeset/remove-swr-switches.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/shared': major ---- - -Remove SWR hooks and env-based switchovers in favor of the React Query implementations; promote @tanstack/query-core to a runtime dependency. diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 7564ade279a..478c802b1ab 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -67,7 +67,7 @@ "@solana/wallet-standard": "catalog:module-manager", "@stripe/stripe-js": "5.6.0", "@swc/helpers": "catalog:repo", - "@tanstack/query-core": "5.90.16", + "@tanstack/query-core": "5.87.4", "@wallet-standard/core": "catalog:module-manager", "@zxcvbn-ts/core": "catalog:module-manager", "@zxcvbn-ts/language-common": "catalog:module-manager", diff --git a/packages/clerk-js/src/test/create-fixtures.tsx b/packages/clerk-js/src/test/create-fixtures.tsx index 787b2bc97bb..2ffa7c64034 100644 --- a/packages/clerk-js/src/test/create-fixtures.tsx +++ b/packages/clerk-js/src/test/create-fixtures.tsx @@ -2,6 +2,7 @@ // @ts-nocheck import type { ClerkOptions, ClientJSON, EnvironmentJSON, LoadedClerk } from '@clerk/shared/types'; +import { useState } from 'react'; import { vi } from 'vitest'; import { Clerk as ClerkCtor } from '@/core/clerk'; @@ -86,6 +87,7 @@ const unboundCreateFixtures = ( const MockClerkProvider = (props: any) => { const { children } = props; + const [swrConfig] = useState(() => ({ provider: () => new Map() })); const componentsWithoutContext = [ 'UsernameSection', @@ -106,7 +108,11 @@ const unboundCreateFixtures = ( ); return ( - + diff --git a/packages/shared/global.d.ts b/packages/shared/global.d.ts index 9bac5865c4b..ffc87dfc746 100644 --- a/packages/shared/global.d.ts +++ b/packages/shared/global.d.ts @@ -4,6 +4,7 @@ declare const JS_PACKAGE_VERSION: string; declare const UI_PACKAGE_VERSION: string; declare const __DEV__: boolean; declare const __BUILD_DISABLE_RHC__: boolean; +declare const __CLERK_USE_RQ__: boolean; interface ImportMetaEnv { readonly [key: string]: string; diff --git a/packages/shared/package.json b/packages/shared/package.json index 57f82cadcbb..0ad144e3b78 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -124,11 +124,11 @@ "test:coverage": "vitest --collectCoverage && open coverage/lcov-report/index.html" }, "dependencies": { - "@tanstack/query-core": "5.90.16", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", - "std-env": "^3.9.0" + "std-env": "^3.9.0", + "swr": "2.3.4" }, "devDependencies": { "@base-org/account": "catalog:module-manager", @@ -138,6 +138,7 @@ "@solana/wallet-standard": "catalog:module-manager", "@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", "@wallet-standard/core": "catalog:module-manager", diff --git a/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx b/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx new file mode 100644 index 00000000000..5c58075c6b6 --- /dev/null +++ b/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx @@ -0,0 +1,76 @@ +import { useCallback, useMemo } from 'react'; + +import type { BillingInitializedPaymentMethodResource, ForPayerType } from '../../types'; +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQueryClient } from '../clerk-rq/use-clerk-query-client'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useOrganizationContext, useUserContext } from '../contexts'; +import { useBillingHookEnabled } from '../hooks/useBillingHookEnabled'; + +type InitializePaymentMethodOptions = { + for?: ForPayerType; +}; + +export type UseInitializePaymentMethodResult = { + initializedPaymentMethod: BillingInitializedPaymentMethodResource | undefined; + initializePaymentMethod: () => Promise; +}; + +/** + * @internal + */ +function useInitializePaymentMethod(options?: InitializePaymentMethodOptions): UseInitializePaymentMethodResult { + const { for: forType } = options ?? {}; + const { organization } = useOrganizationContext(); + const user = useUserContext(); + + const resource = forType === 'organization' ? organization : user; + + const billingEnabled = useBillingHookEnabled(options); + + const queryKey = useMemo(() => { + return ['billing-payment-method-initialize', { resourceId: resource?.id }] as const; + }, [resource?.id]); + + const isEnabled = Boolean(resource?.id) && billingEnabled; + + const query = useClerkQuery({ + queryKey, + queryFn: async () => { + if (!resource) { + return undefined; + } + + return resource.initializePaymentMethod({ + gateway: 'stripe', + }); + }, + enabled: isEnabled, + staleTime: 1_000 * 60, + refetchOnWindowFocus: false, + placeholderData: defineKeepPreviousDataFn(true), + }); + + const [queryClient] = useClerkQueryClient(); + + const initializePaymentMethod = useCallback(async () => { + if (!resource) { + return undefined; + } + + const result = await resource.initializePaymentMethod({ + gateway: 'stripe', + }); + + queryClient.setQueryData(queryKey, result); + + return result; + }, [queryClient, queryKey, resource]); + + return { + initializedPaymentMethod: query.data ?? undefined, + initializePaymentMethod, + }; +} + +export { useInitializePaymentMethod as __internal_useInitializePaymentMethod }; diff --git a/packages/shared/src/react/billing/useInitializePaymentMethod.swr.tsx b/packages/shared/src/react/billing/useInitializePaymentMethod.swr.tsx new file mode 100644 index 00000000000..8a4a3df8f35 --- /dev/null +++ b/packages/shared/src/react/billing/useInitializePaymentMethod.swr.tsx @@ -0,0 +1,60 @@ +import { useEffect } from 'react'; +import useSWRMutation from 'swr/mutation'; + +import type { BillingInitializedPaymentMethodResource, ForPayerType } from '../../types'; +import { useOrganizationContext, useUserContext } from '../contexts'; + +type InitializePaymentMethodOptions = { + for?: ForPayerType; +}; + +export type UseInitializePaymentMethodResult = { + initializedPaymentMethod: BillingInitializedPaymentMethodResource | undefined; + initializePaymentMethod: () => Promise; +}; + +/** + * This is the existing implementation of the payment method initializer using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +function useInitializePaymentMethod(options?: InitializePaymentMethodOptions): UseInitializePaymentMethodResult { + const { for: forType = 'user' } = options ?? {}; + const { organization } = useOrganizationContext(); + const user = useUserContext(); + + const resource = forType === 'organization' ? organization : user; + + const { data, trigger } = useSWRMutation( + resource?.id + ? { + key: 'billing-payment-method-initialize', + resourceId: resource.id, + for: forType, + } + : null, + () => { + return resource?.initializePaymentMethod({ + gateway: 'stripe', + }); + }, + ); + + useEffect(() => { + if (!resource?.id) { + return; + } + + trigger().catch(() => { + // ignore errors + }); + }, [resource?.id, trigger]); + + return { + initializedPaymentMethod: data, + initializePaymentMethod: trigger, + }; +} + +export { useInitializePaymentMethod as __internal_useInitializePaymentMethod }; diff --git a/packages/shared/src/react/billing/useInitializePaymentMethod.tsx b/packages/shared/src/react/billing/useInitializePaymentMethod.tsx index 5c58075c6b6..1373b76c409 100644 --- a/packages/shared/src/react/billing/useInitializePaymentMethod.tsx +++ b/packages/shared/src/react/billing/useInitializePaymentMethod.tsx @@ -1,76 +1,2 @@ -import { useCallback, useMemo } from 'react'; - -import type { BillingInitializedPaymentMethodResource, ForPayerType } from '../../types'; -import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; -import { useClerkQueryClient } from '../clerk-rq/use-clerk-query-client'; -import { useClerkQuery } from '../clerk-rq/useQuery'; -import { useOrganizationContext, useUserContext } from '../contexts'; -import { useBillingHookEnabled } from '../hooks/useBillingHookEnabled'; - -type InitializePaymentMethodOptions = { - for?: ForPayerType; -}; - -export type UseInitializePaymentMethodResult = { - initializedPaymentMethod: BillingInitializedPaymentMethodResource | undefined; - initializePaymentMethod: () => Promise; -}; - -/** - * @internal - */ -function useInitializePaymentMethod(options?: InitializePaymentMethodOptions): UseInitializePaymentMethodResult { - const { for: forType } = options ?? {}; - const { organization } = useOrganizationContext(); - const user = useUserContext(); - - const resource = forType === 'organization' ? organization : user; - - const billingEnabled = useBillingHookEnabled(options); - - const queryKey = useMemo(() => { - return ['billing-payment-method-initialize', { resourceId: resource?.id }] as const; - }, [resource?.id]); - - const isEnabled = Boolean(resource?.id) && billingEnabled; - - const query = useClerkQuery({ - queryKey, - queryFn: async () => { - if (!resource) { - return undefined; - } - - return resource.initializePaymentMethod({ - gateway: 'stripe', - }); - }, - enabled: isEnabled, - staleTime: 1_000 * 60, - refetchOnWindowFocus: false, - placeholderData: defineKeepPreviousDataFn(true), - }); - - const [queryClient] = useClerkQueryClient(); - - const initializePaymentMethod = useCallback(async () => { - if (!resource) { - return undefined; - } - - const result = await resource.initializePaymentMethod({ - gateway: 'stripe', - }); - - queryClient.setQueryData(queryKey, result); - - return result; - }, [queryClient, queryKey, resource]); - - return { - initializedPaymentMethod: query.data ?? undefined, - initializePaymentMethod, - }; -} - -export { useInitializePaymentMethod as __internal_useInitializePaymentMethod }; +export type { UseInitializePaymentMethodResult } from 'virtual:data-hooks/useInitializePaymentMethod'; +export { __internal_useInitializePaymentMethod } from 'virtual:data-hooks/useInitializePaymentMethod'; diff --git a/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx b/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx new file mode 100644 index 00000000000..e2dd394b24c --- /dev/null +++ b/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx @@ -0,0 +1,37 @@ +import type { loadStripe } from '@stripe/stripe-js'; + +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useBillingHookEnabled } from '../hooks/useBillingHookEnabled'; +import { useClerk } from '../hooks/useClerk'; + +type LoadStripeFn = typeof loadStripe; + +type StripeClerkLibs = { + loadStripe: LoadStripeFn; +}; + +/** + * @internal + */ +function useStripeClerkLibs(): StripeClerkLibs | null { + const clerk = useClerk(); + + const billingEnabled = useBillingHookEnabled(); + + const query = useClerkQuery({ + queryKey: ['clerk-stripe-sdk'], + queryFn: async () => { + const loadStripe = (await clerk.__internal_loadStripeJs()) as LoadStripeFn; + return { loadStripe }; + }, + enabled: billingEnabled, + staleTime: Infinity, + refetchOnWindowFocus: false, + placeholderData: defineKeepPreviousDataFn(true), + }); + + return query.data ?? null; +} + +export { useStripeClerkLibs as __internal_useStripeClerkLibs }; diff --git a/packages/shared/src/react/billing/useStripeClerkLibs.swr.tsx b/packages/shared/src/react/billing/useStripeClerkLibs.swr.tsx new file mode 100644 index 00000000000..820144b4dff --- /dev/null +++ b/packages/shared/src/react/billing/useStripeClerkLibs.swr.tsx @@ -0,0 +1,39 @@ +import type { loadStripe } from '@stripe/stripe-js'; + +import { useSWR } from '../clerk-swr'; +import { useClerk } from '../hooks/useClerk'; + +type LoadStripeFn = typeof loadStripe; + +type StripeClerkLibs = { + loadStripe: LoadStripeFn; +}; + +export type UseStripeClerkLibsResult = StripeClerkLibs | null; + +/** + * This is the existing implementation of the Stripe libraries loader using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +function useStripeClerkLibs(): UseStripeClerkLibsResult { + const clerk = useClerk(); + + const swr = useSWR( + 'clerk-stripe-sdk', + async () => { + const loadStripe = (await clerk.__internal_loadStripeJs()) as LoadStripeFn; + return { loadStripe }; + }, + { + keepPreviousData: true, + revalidateOnFocus: false, + dedupingInterval: Infinity, + }, + ); + + return swr.data ?? null; +} + +export { useStripeClerkLibs as __internal_useStripeClerkLibs }; diff --git a/packages/shared/src/react/billing/useStripeClerkLibs.tsx b/packages/shared/src/react/billing/useStripeClerkLibs.tsx index 197df86743a..3a55aaca025 100644 --- a/packages/shared/src/react/billing/useStripeClerkLibs.tsx +++ b/packages/shared/src/react/billing/useStripeClerkLibs.tsx @@ -1,39 +1,2 @@ -import type { loadStripe } from '@stripe/stripe-js'; - -import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; -import { useClerkQuery } from '../clerk-rq/useQuery'; -import { useBillingHookEnabled } from '../hooks/useBillingHookEnabled'; -import { useClerk } from '../hooks/useClerk'; - -type LoadStripeFn = typeof loadStripe; - -type StripeClerkLibs = { - loadStripe: LoadStripeFn; -}; - -export type UseStripeClerkLibsResult = StripeClerkLibs | null; - -/** - * @internal - */ -function useStripeClerkLibs(): UseStripeClerkLibsResult { - const clerk = useClerk(); - - const billingEnabled = useBillingHookEnabled(); - - const query = useClerkQuery({ - queryKey: ['clerk-stripe-sdk'], - queryFn: async () => { - const loadStripe = (await clerk.__internal_loadStripeJs()) as LoadStripeFn; - return { loadStripe }; - }, - enabled: billingEnabled, - staleTime: Infinity, - refetchOnWindowFocus: false, - placeholderData: defineKeepPreviousDataFn(true), - }); - - return query.data ?? null; -} - -export { useStripeClerkLibs as __internal_useStripeClerkLibs }; +export type { UseStripeClerkLibsResult } from 'virtual:data-hooks/useStripeClerkLibs'; +export { __internal_useStripeClerkLibs } from 'virtual:data-hooks/useStripeClerkLibs'; diff --git a/packages/shared/src/react/billing/useStripeLoader.rq.tsx b/packages/shared/src/react/billing/useStripeLoader.rq.tsx new file mode 100644 index 00000000000..59dee615f6b --- /dev/null +++ b/packages/shared/src/react/billing/useStripeLoader.rq.tsx @@ -0,0 +1,51 @@ +import type { Stripe } from '@stripe/stripe-js'; +import { useMemo } from 'react'; + +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useBillingHookEnabled } from '../hooks/useBillingHookEnabled'; +import type { UseStripeClerkLibsResult } from './useStripeClerkLibs'; + +type StripeLoaderOptions = { + stripeClerkLibs: UseStripeClerkLibsResult; + externalGatewayId?: string; + stripePublishableKey?: string; +}; + +export type UseStripeLoaderResult = Stripe | null | undefined; + +/** + * @internal + */ +function useStripeLoader(options: StripeLoaderOptions): UseStripeLoaderResult { + const { stripeClerkLibs, externalGatewayId, stripePublishableKey } = options; + + const queryKey = useMemo(() => { + return ['stripe-sdk', { externalGatewayId, stripePublishableKey }] as const; + }, [externalGatewayId, stripePublishableKey]); + + const billingEnabled = useBillingHookEnabled({ authenticated: true }); + + const isEnabled = Boolean(stripeClerkLibs && externalGatewayId && stripePublishableKey) && billingEnabled; + + const query = useClerkQuery({ + queryKey, + queryFn: () => { + if (!stripeClerkLibs || !externalGatewayId || !stripePublishableKey) { + return null; + } + + return stripeClerkLibs.loadStripe(stripePublishableKey, { + stripeAccount: externalGatewayId, + }); + }, + enabled: isEnabled, + staleTime: 1_000 * 60, + refetchOnWindowFocus: false, + placeholderData: defineKeepPreviousDataFn(true), + }); + + return query.data; +} + +export { useStripeLoader as __internal_useStripeLoader }; diff --git a/packages/shared/src/react/billing/useStripeLoader.swr.tsx b/packages/shared/src/react/billing/useStripeLoader.swr.tsx new file mode 100644 index 00000000000..57e396dcddc --- /dev/null +++ b/packages/shared/src/react/billing/useStripeLoader.swr.tsx @@ -0,0 +1,46 @@ +import type { Stripe } from '@stripe/stripe-js'; + +import { useSWR } from '../clerk-swr'; +import type { UseStripeClerkLibsResult } from './useStripeClerkLibs'; + +type StripeLoaderOptions = { + stripeClerkLibs: UseStripeClerkLibsResult; + externalGatewayId?: string; + stripePublishableKey?: string; +}; + +export type UseStripeLoaderResult = Stripe | null | undefined; + +/** + * This is the existing implementation of the Stripe instance loader using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +function useStripeLoader(options: StripeLoaderOptions): UseStripeLoaderResult { + const { stripeClerkLibs, externalGatewayId, stripePublishableKey } = options; + + const swr = useSWR( + stripeClerkLibs && externalGatewayId && stripePublishableKey + ? { + key: 'stripe-sdk', + externalGatewayId, + stripePublishableKey, + } + : null, + ({ stripePublishableKey, externalGatewayId }) => { + return stripeClerkLibs?.loadStripe(stripePublishableKey, { + stripeAccount: externalGatewayId, + }); + }, + { + keepPreviousData: true, + revalidateOnFocus: false, + dedupingInterval: 1_000 * 60, + }, + ); + + return swr.data; +} + +export { useStripeLoader as __internal_useStripeLoader }; diff --git a/packages/shared/src/react/billing/useStripeLoader.tsx b/packages/shared/src/react/billing/useStripeLoader.tsx index 59dee615f6b..689fed791c4 100644 --- a/packages/shared/src/react/billing/useStripeLoader.tsx +++ b/packages/shared/src/react/billing/useStripeLoader.tsx @@ -1,51 +1,2 @@ -import type { Stripe } from '@stripe/stripe-js'; -import { useMemo } from 'react'; - -import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; -import { useClerkQuery } from '../clerk-rq/useQuery'; -import { useBillingHookEnabled } from '../hooks/useBillingHookEnabled'; -import type { UseStripeClerkLibsResult } from './useStripeClerkLibs'; - -type StripeLoaderOptions = { - stripeClerkLibs: UseStripeClerkLibsResult; - externalGatewayId?: string; - stripePublishableKey?: string; -}; - -export type UseStripeLoaderResult = Stripe | null | undefined; - -/** - * @internal - */ -function useStripeLoader(options: StripeLoaderOptions): UseStripeLoaderResult { - const { stripeClerkLibs, externalGatewayId, stripePublishableKey } = options; - - const queryKey = useMemo(() => { - return ['stripe-sdk', { externalGatewayId, stripePublishableKey }] as const; - }, [externalGatewayId, stripePublishableKey]); - - const billingEnabled = useBillingHookEnabled({ authenticated: true }); - - const isEnabled = Boolean(stripeClerkLibs && externalGatewayId && stripePublishableKey) && billingEnabled; - - const query = useClerkQuery({ - queryKey, - queryFn: () => { - if (!stripeClerkLibs || !externalGatewayId || !stripePublishableKey) { - return null; - } - - return stripeClerkLibs.loadStripe(stripePublishableKey, { - stripeAccount: externalGatewayId, - }); - }, - enabled: isEnabled, - staleTime: 1_000 * 60, - refetchOnWindowFocus: false, - placeholderData: defineKeepPreviousDataFn(true), - }); - - return query.data; -} - -export { useStripeLoader as __internal_useStripeLoader }; +export type { UseStripeLoaderResult } from 'virtual:data-hooks/useStripeLoader'; +export { __internal_useStripeLoader } from 'virtual:data-hooks/useStripeLoader'; diff --git a/packages/shared/src/react/clerk-swr.ts b/packages/shared/src/react/clerk-swr.ts new file mode 100644 index 00000000000..5d03ac36156 --- /dev/null +++ b/packages/shared/src/react/clerk-swr.ts @@ -0,0 +1,7 @@ +'use client'; + +// TODO: Replace these SWR re-exports with react-query equivalents. +export * from 'swr'; + +export { default as useSWR } from 'swr'; +export { default as useSWRInfinite } from 'swr/infinite'; diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index f79e88b4a8f..65abac89866 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -14,6 +14,7 @@ import type { UserResource, } from '../types'; import { createContextAndHook } from './hooks/createContextAndHook'; +import { SWRConfigCompat } from './providers/SWRConfigCompat'; const [ClerkInstanceContext, useClerkInstanceContext] = createContextAndHook('ClerkInstanceContext'); const [UserContext, useUserContext] = createContextAndHook('UserContext'); @@ -68,15 +69,26 @@ const [OrganizationContextInternal, useOrganizationContext] = createContextAndHo organization: OrganizationResource | null | undefined; }>('OrganizationContext'); -const OrganizationProvider = ({ children, organization }: PropsWithChildren) => { +const OrganizationProvider = ({ + children, + organization, + swrConfig, +}: PropsWithChildren< + OrganizationContextProps & { + // Exporting inferred types directly from SWR will result in error while building declarations + swrConfig?: any; + } +>) => { return ( - - {children} - + + + {children} + + ); }; diff --git a/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx index 1c1394a07b6..7c9032cb449 100644 --- a/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx @@ -308,7 +308,16 @@ describe('createBillingPaginatedHook', () => { expect(params).toStrictEqual({ initialPage: 1, pageSize: 5 }); }); - expect(result.current.isLoading).toBe(false); + if (__CLERK_USE_RQ__) { + expect(result.current.isLoading).toBe(false); + } else { + // Attention: We are forcing fetcher to be executed instead of setting the key to null + // because SWR will continue to display the cached data when the key is null and `keepPreviousData` is true. + // This means that SWR will update the loading state to true even if the fetcher is not called, + // because the key changes from `{..., userId: 'user_1'}` to `{..., userId: undefined}`. + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + } // Data should be cleared even with keepPreviousData: true // The key difference here vs usePagesOrInfinite test: userId in cache key changes @@ -534,7 +543,11 @@ describe('createBillingPaginatedHook', () => { await result.current.paginated.revalidate(); }); - await waitFor(() => expect(fetcherMock.mock.calls.length).toBeGreaterThanOrEqual(2)); + if (__CLERK_USE_RQ__) { + await waitFor(() => expect(fetcherMock.mock.calls.length).toBeGreaterThanOrEqual(2)); + } else { + await waitFor(() => expect(fetcherMock).toHaveBeenCalledTimes(1)); + } }); }); }); diff --git a/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx b/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx index 570a3ce1d3d..075b794a406 100644 --- a/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx @@ -89,7 +89,13 @@ describe('useApiKeys', () => { await result.current.paginated.revalidate(); }); - await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); + const isRQ = Boolean((globalThis as any).__CLERK_USE_RQ__); + + if (isRQ) { + await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); + } else { + await waitFor(() => expect(getAllSpy).toHaveBeenCalledTimes(1)); + } }); it('handles revalidation with different pageSize configurations', async () => { @@ -119,8 +125,15 @@ describe('useApiKeys', () => { await result.current.small.revalidate(); }); + const isRQ = Boolean((globalThis as any).__CLERK_USE_RQ__); + await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(1)); - await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); + + if (isRQ) { + await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); + } else { + expect(getAllSpy).toHaveBeenCalledTimes(2); + } }); it('handles revalidation with different query filters', async () => { @@ -150,8 +163,15 @@ describe('useApiKeys', () => { await result.current.defaultQuery.revalidate(); }); + const isRQ = Boolean((globalThis as any).__CLERK_USE_RQ__); + await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(1)); - await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); + + if (isRQ) { + await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); + } else { + expect(getAllSpy).toHaveBeenCalledTimes(2); + } }); it('does not cascade revalidation across different subjects', async () => { diff --git a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts index dd0d2420bf2..31e26912eaf 100644 --- a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts @@ -722,7 +722,11 @@ describe('usePagesOrInfinite - revalidate behavior', () => { await result.current.paginated.revalidate(); }); - await waitFor(() => expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(2)); + if (__CLERK_USE_RQ__) { + await waitFor(() => expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(2)); + } else { + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + } }); }); diff --git a/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx b/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx index 8640ecff86b..4ba3a45ae30 100644 --- a/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx @@ -261,9 +261,19 @@ describe('usePlans', () => { await result.current.userPlans.revalidate(); }); - await waitFor(() => expect(getPlansSpy.mock.calls.length).toBeGreaterThanOrEqual(1)); - + const isRQ = Boolean((globalThis as any).__CLERK_USE_RQ__); const calls = getPlansSpy.mock.calls.map(call => call[0]?.for); - expect(calls.every(value => value === 'user')).toBe(true); + + if (isRQ) { + await waitFor(() => expect(getPlansSpy.mock.calls.length).toBeGreaterThanOrEqual(1)); + expect(calls.every(value => value === 'user')).toBe(true); + } else { + await waitFor(() => expect(getPlansSpy.mock.calls.length).toBe(1)); + expect(getPlansSpy.mock.calls[0][0]).toEqual( + expect.objectContaining({ + for: 'user', + }), + ); + } }); }); diff --git a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx index 06bdf71cc85..1563127d543 100644 --- a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx @@ -105,7 +105,14 @@ describe('useSubscription', () => { mockUser = null; rerender(); - await waitFor(() => expect(result.current.data).toBeUndefined()); + if (__CLERK_USE_RQ__) { + await waitFor(() => expect(result.current.data).toBeUndefined()); + } else { + // Assert that SWR will flip to fetching because the fetcherFN runs, but it forces `null` when userId is falsy. + await waitFor(() => expect(result.current.isFetching).toBe(true)); + // The fetcher returns null when userId is falsy, so data should become null + await waitFor(() => expect(result.current.data).toBeNull()); + } expect(getSubscriptionSpy).toHaveBeenCalledTimes(1); expect(result.current.isFetching).toBe(false); @@ -126,7 +133,15 @@ describe('useSubscription', () => { mockUser = null; rerender({ kp: true }); - await waitFor(() => expect(result.current.data).toBeUndefined()); + if (__CLERK_USE_RQ__) { + await waitFor(() => expect(result.current.data).toBeUndefined()); + } else { + // Assert that SWR will flip to fetching because the fetcherFN runs, but it forces `null` when userId is falsy. + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // The fetcher returns null when userId is falsy, so data should become null + await waitFor(() => expect(result.current.data).toBeNull()); + } expect(getSubscriptionSpy).toHaveBeenCalledTimes(1); expect(result.current.isFetching).toBe(false); @@ -154,7 +169,12 @@ describe('useSubscription', () => { await waitFor(() => expect(result.current.isFetching).toBe(true)); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + // Slight difference in behavior between SWR and React Query, but acceptable for the migration. + if (__CLERK_USE_RQ__) { + await waitFor(() => expect(result.current.isLoading).toBe(false)); + } else { + await waitFor(() => expect(result.current.isLoading).toBe(true)); + } expect(result.current.data).toEqual({ id: 'sub_org_org_1' }); deferred.resolve({ id: 'sub_org_org_2' }); diff --git a/packages/shared/src/react/hooks/__tests__/wrapper.tsx b/packages/shared/src/react/hooks/__tests__/wrapper.tsx index 8969ffc22bb..90fad9e4a39 100644 --- a/packages/shared/src/react/hooks/__tests__/wrapper.tsx +++ b/packages/shared/src/react/hooks/__tests__/wrapper.tsx @@ -1,3 +1,13 @@ import React from 'react'; -export const wrapper = ({ children }: { children: React.ReactNode }) => <>{children}; +import { SWRConfigCompat } from '../../providers/SWRConfigCompat'; + +export const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map(), + }} + > + {children} + +); diff --git a/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx b/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx index 9d658db2969..425b05e97b8 100644 --- a/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx +++ b/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx @@ -141,7 +141,7 @@ export function createBillingPaginatedHook; + +/** + * @internal + */ +export type UseAPIKeysReturn = PaginatedResources< + APIKeyResource, + T extends { infinite: true } ? true : false +>; + +/** + * @internal + * + * The `useAPIKeys()` hook provides access to paginated API keys for the current user or organization. + * + * @example + * ### Basic usage with default pagination + * + * ```tsx + * const { data, isLoading, page, pageCount, fetchNext, fetchPrevious } = useAPIKeys({ + * subject: 'user_123', + * pageSize: 10, + * initialPage: 1, + * }); + * ``` + * + * @example + * ### With search query + * + * ```tsx + * const [searchValue, setSearchValue] = useState(''); + * const debouncedSearch = useDebounce(searchValue, 500); + * + * const { data, isLoading } = useAPIKeys({ + * subject: 'user_123', + * query: debouncedSearch.trim(), + * pageSize: 10, + * }); + * ``` + * + * @example + * ### Infinite scroll + * + * ```tsx + * const { data, isLoading, fetchNext, hasNextPage } = useAPIKeys({ + * subject: 'user_123', + * infinite: true, + * }); + * ``` + */ +export function useAPIKeys(params?: T): UseAPIKeysReturn { + useAssertWrappedByClerkProvider('useAPIKeys'); + + const safeValues = useWithSafeValues(params, { + initialPage: 1, + pageSize: 10, + keepPreviousData: false, + infinite: false, + subject: '', + query: '', + enabled: true, + } as UseAPIKeysParams); + + const clerk = useClerkInstanceContext(); + + clerk.telemetry?.record(eventMethodCalled('useAPIKeys')); + + const hookParams: GetAPIKeysParams = { + initialPage: safeValues.initialPage, + pageSize: safeValues.pageSize, + ...(safeValues.subject ? { subject: safeValues.subject } : {}), + ...(safeValues.query ? { query: safeValues.query } : {}), + }; + + const isEnabled = (safeValues.enabled ?? true) && clerk.loaded; + + const result = usePagesOrInfinite({ + fetcher: clerk.apiKeys?.getAll + ? (params: GetAPIKeysParams) => clerk.apiKeys.getAll({ ...params, subject: safeValues.subject }) + : undefined, + config: { + keepPreviousData: safeValues.keepPreviousData, + infinite: safeValues.infinite, + enabled: isEnabled, + isSignedIn: Boolean(clerk.user), + initialPage: safeValues.initialPage, + pageSize: safeValues.pageSize, + }, + keys: createCacheKeys({ + stablePrefix: STABLE_KEYS.API_KEYS_KEY, + authenticated: Boolean(clerk.user), + tracked: { + subject: safeValues.subject, + }, + untracked: { + args: hookParams, + }, + }), + }) as UseAPIKeysReturn; + + const { mutate } = useSWRConfig(); + + // Invalidate all cache entries for this user or organization + const invalidateAll = useCallback(() => { + return mutate(key => { + if (!key || typeof key !== 'object') { + return false; + } + // Match all apiKeys cache entries for this user or organization, regardless of page, pageSize, or query + return 'type' in key && key.type === 'apiKeys' && 'subject' in key && key.subject === safeValues.subject; + }); + }, [mutate, safeValues.subject]); + + return { + ...result, + revalidate: invalidateAll as any, + }; +} diff --git a/packages/shared/src/react/hooks/useAPIKeys.ts b/packages/shared/src/react/hooks/useAPIKeys.ts new file mode 100644 index 00000000000..cd899c1e737 --- /dev/null +++ b/packages/shared/src/react/hooks/useAPIKeys.ts @@ -0,0 +1,2 @@ +export { useAPIKeys } from 'virtual:data-hooks/useAPIKeys'; +export type { UseAPIKeysParams, UseAPIKeysReturn } from './useAPIKeys.rq'; diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx new file mode 100644 index 00000000000..f4d01862445 --- /dev/null +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx @@ -0,0 +1,257 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; + +import type { ClerkPaginatedResponse } from '../../types'; +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQueryClient } from '../clerk-rq/use-clerk-query-client'; +import { useClerkInfiniteQuery } from '../clerk-rq/useInfiniteQuery'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import type { CacheSetter, ValueOrSetter } from '../types'; +import { useClearQueriesOnSignOut, withInfiniteKey } from './useClearQueriesOnSignOut'; +import type { UsePagesOrInfiniteSignature } from './usePageOrInfinite.types'; +import { useWithSafeValues } from './usePagesOrInfinite.shared'; + +export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { + const { fetcher, config, keys } = params; + + const [paginatedPage, setPaginatedPage] = useState(config.initialPage ?? 1); + + // Cache initialPage and initialPageSize until unmount + const initialPageRef = useRef(config.initialPage ?? 1); + const pageSizeRef = useRef(config.pageSize ?? 10); + + const enabled = config.enabled ?? true; + const isSignedIn = config.isSignedIn; + const triggerInfinite = config.infinite ?? false; + const cacheMode = config.__experimental_mode === 'cache'; + const keepPreviousData = config.keepPreviousData ?? false; + + const [queryClient] = useClerkQueryClient(); + + // Compute the actual enabled state for queries (considering all conditions) + const queriesEnabled = enabled && Boolean(fetcher) && !cacheMode && isSignedIn !== false; + + // Force re-render counter for cache-only updates + const [forceUpdateCounter, setForceUpdateCounter] = useState(0); + const forceUpdate = useCallback((updater: (n: number) => number) => { + setForceUpdateCounter(updater); + }, []); + + // Non-infinite mode: single page query + const pagesQueryKey = useMemo(() => { + const [stablePrefix, authenticated, tracked, untracked] = keys.queryKey; + + return [ + stablePrefix, + authenticated, + tracked, + { + ...untracked, + args: { + ...untracked.args, + initialPage: paginatedPage, + pageSize: pageSizeRef.current, + }, + }, + ] as const; + }, [keys.queryKey, paginatedPage]); + + const singlePageQuery = useClerkQuery({ + queryKey: pagesQueryKey, + queryFn: ({ queryKey }) => { + const { args } = queryKey[3]; + + if (!fetcher) { + return undefined as any; + } + + return fetcher(args); + }, + staleTime: 60_000, + enabled: queriesEnabled && !triggerInfinite, + // Use placeholderData to keep previous data while fetching new page + placeholderData: defineKeepPreviousDataFn(keepPreviousData), + }); + + // Infinite mode: accumulate pages + const infiniteQueryKey = useMemo(() => { + const [stablePrefix, authenticated, tracked, untracked] = keys.queryKey; + + return [stablePrefix + '-inf', authenticated, tracked, untracked] as const; + }, [keys.queryKey]); + + const infiniteQuery = useClerkInfiniteQuery, any, any, typeof infiniteQueryKey, any>({ + queryKey: infiniteQueryKey, + initialPageParam: config.initialPage ?? 1, + getNextPageParam: (lastPage, allPages, lastPageParam) => { + const total = lastPage?.total_count ?? 0; + const consumed = (allPages.length + (config.initialPage ? config.initialPage - 1 : 0)) * (config.pageSize ?? 10); + return consumed < total ? (lastPageParam as number) + 1 : undefined; + }, + queryFn: ({ pageParam, queryKey }) => { + const { args } = queryKey[3]; + if (!fetcher) { + return undefined as any; + } + return fetcher({ ...args, initialPage: pageParam, pageSize: pageSizeRef.current }); + }, + staleTime: 60_000, + enabled: queriesEnabled && triggerInfinite, + }); + + useClearQueriesOnSignOut({ + isSignedOut: isSignedIn === false, + authenticated: keys.authenticated, + stableKeys: withInfiniteKey(keys.stableKey), + onCleanup: () => { + // Reset paginated page to initial + setPaginatedPage(initialPageRef.current); + + // Force re-render to reflect cache changes + void Promise.resolve().then(() => forceUpdate(n => n + 1)); + }, + }); + + // Compute data, count and page from the same data source to ensure consistency + const computedValues = useMemo(() => { + if (triggerInfinite) { + // Read from query data first, fallback to cache + const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); + const pages = queriesEnabled ? (infiniteQuery.data?.pages ?? cachedData?.pages ?? []) : (cachedData?.pages ?? []); + + // Ensure pages is always an array and filter out null/undefined pages + const validPages = Array.isArray(pages) ? pages.filter(Boolean) : []; + + return { + data: + validPages + .map((a: ClerkPaginatedResponse) => a?.data) + .flat() + .filter(Boolean) ?? [], + count: validPages[validPages.length - 1]?.total_count ?? 0, + page: validPages.length > 0 ? validPages.length : initialPageRef.current, + }; + } + + // When query is disabled (via enabled flag), the hook's data is stale, so only read from cache + // This ensures that after cache clearing, we return consistent empty state + const pageData = queriesEnabled + ? (singlePageQuery.data ?? queryClient.getQueryData>(pagesQueryKey)) + : queryClient.getQueryData>(pagesQueryKey); + + return { + data: Array.isArray(pageData?.data) ? pageData.data : [], + count: typeof pageData?.total_count === 'number' ? pageData.total_count : 0, + page: paginatedPage, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- forceUpdateCounter is used to trigger re-renders for cache updates + }, [ + queriesEnabled, + forceUpdateCounter, + triggerInfinite, + infiniteQuery.data?.pages, + singlePageQuery.data, + queryClient, + infiniteQueryKey, + pagesQueryKey, + paginatedPage, + ]); + + const { data, count, page } = computedValues; + + const fetchPage: ValueOrSetter = useCallback( + numberOrgFn => { + if (triggerInfinite) { + const next = typeof numberOrgFn === 'function' ? (numberOrgFn as (n: number) => number)(page) : numberOrgFn; + const targetCount = Math.max(0, next); + const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); + const pages = infiniteQuery.data?.pages ?? cachedData?.pages ?? []; + const currentCount = pages.length; + const toFetch = targetCount - currentCount; + if (toFetch > 0) { + void infiniteQuery.fetchNextPage({ cancelRefetch: false }); + } + return; + } + return setPaginatedPage(numberOrgFn); + }, + [infiniteQuery, page, triggerInfinite, queryClient, infiniteQueryKey], + ); + + const isLoading = triggerInfinite ? infiniteQuery.isLoading : singlePageQuery.isLoading; + const isFetching = triggerInfinite ? infiniteQuery.isFetching : singlePageQuery.isFetching; + const error = (triggerInfinite ? infiniteQuery.error : singlePageQuery.error) ?? null; + const isError = !!error; + + const fetchNext = useCallback(() => { + if (triggerInfinite) { + void infiniteQuery.fetchNextPage({ cancelRefetch: false }); + return; + } + setPaginatedPage(n => Math.max(0, n + 1)); + }, [infiniteQuery, triggerInfinite]); + + const fetchPrevious = useCallback(() => { + if (triggerInfinite) { + // not natively supported by forward-only pagination; noop + return; + } + setPaginatedPage(n => Math.max(0, n - 1)); + }, [triggerInfinite]); + + const offsetCount = (initialPageRef.current - 1) * pageSizeRef.current; + const pageCount = Math.ceil((count - offsetCount) / pageSizeRef.current); + const hasNextPage = triggerInfinite + ? Boolean(infiniteQuery.hasNextPage) + : count - offsetCount * pageSizeRef.current > page * pageSizeRef.current; + const hasPreviousPage = triggerInfinite + ? Boolean(infiniteQuery.hasPreviousPage) + : (page - 1) * pageSizeRef.current > offsetCount * pageSizeRef.current; + + const setData: CacheSetter = value => { + if (triggerInfinite) { + queryClient.setQueryData(infiniteQueryKey, (prevValue: any = {}) => { + const prevPages = Array.isArray(prevValue?.pages) ? prevValue.pages : []; + const nextPages = (typeof value === 'function' ? value(prevPages) : value) as Array< + ClerkPaginatedResponse + >; + return { ...prevValue, pages: nextPages }; + }); + // Force immediate re-render to reflect cache changes + forceUpdate(n => n + 1); + return Promise.resolve(); + } + queryClient.setQueryData(pagesQueryKey, (prevValue: any = { data: [], total_count: 0 }) => { + const nextValue = (typeof value === 'function' ? value(prevValue) : value) as ClerkPaginatedResponse; + return nextValue; + }); + // Force re-render to reflect cache changes + forceUpdate(n => n + 1); + return Promise.resolve(); + }; + + const revalidate = async () => { + await queryClient.invalidateQueries({ queryKey: keys.invalidationKey }); + const [stablePrefix, ...rest] = keys.invalidationKey; + return queryClient.invalidateQueries({ queryKey: [stablePrefix + '-inf', ...rest] }); + }; + + return { + data, + count, + error, + isLoading, + isFetching, + isError, + page, + pageCount, + fetchPage, + fetchNext, + fetchPrevious, + hasNextPage, + hasPreviousPage, + revalidate: revalidate as any, + setData: setData as any, + }; +}; + +export { useWithSafeValues }; diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx new file mode 100644 index 00000000000..d81d5bbbf76 --- /dev/null +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { useCallback, useMemo, useRef, useState } from 'react'; + +import { useSWR, useSWRInfinite } from '../clerk-swr'; +import type { CacheSetter, ValueOrSetter } from '../types'; +import { toSWRQuery } from './createCacheKeys'; +import type { UsePagesOrInfiniteSignature } from './usePageOrInfinite.types'; +import { getDifferentKeys, useWithSafeValues } from './usePagesOrInfinite.shared'; +import { usePreviousValue } from './usePreviousValue'; + +const cachingSWROptions = { + dedupingInterval: 1000 * 60, + focusThrottleInterval: 1000 * 60 * 2, +} satisfies Parameters[2]; + +const cachingSWRInfiniteOptions = { + ...cachingSWROptions, + revalidateFirstPage: false, +} satisfies Parameters[2]; + +/** + * A flexible pagination hook that supports both traditional pagination and infinite loading. + * It provides a unified API for handling paginated data fetching, with built-in caching through SWR. + * The hook can operate in two modes: + * - Traditional pagination: Fetches one page at a time with page navigation + * - Infinite loading: Accumulates data as more pages are loaded. + * + * Features: + * - Cache management with SWR + * - Loading and error states + * - Page navigation helpers + * - Data revalidation and updates + * - Support for keeping previous data while loading. + * + * @internal + */ +export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { + const { fetcher, config, keys } = params; + const [paginatedPage, setPaginatedPage] = useState(config.initialPage ?? 1); + + // Cache initialPage and initialPageSize until unmount + const initialPageRef = useRef(config.initialPage ?? 1); + const pageSizeRef = useRef(config.pageSize ?? 10); + + const enabled = config.enabled ?? true; + const cacheMode = config.__experimental_mode === 'cache'; + const triggerInfinite = config.infinite ?? false; + const keepPreviousData = config.keepPreviousData ?? false; + const isSignedIn = config.isSignedIn; + + const pagesCacheKey = { + ...toSWRQuery(keys), + initialPage: paginatedPage, + pageSize: pageSizeRef.current, + }; + + const previousIsSignedIn = usePreviousValue(isSignedIn); + + // cacheMode being `true` indicates that the cache key is defined, but the fetcher is not. + // This allows to ready the cache instead of firing a request. + const shouldFetch = !triggerInfinite && enabled && (!cacheMode ? !!fetcher : true); + + // Attention: + // + // This complex logic is necessary to ensure that the cached data is not used when the user is signed out. + // `useSWR` with `key` set to `null` and `keepPreviousData` set to `true` will return the previous cached data until the hook unmounts. + // So for hooks that render authenticated data, we need to ensure that the cached data is not used when the user is signed out. + // + // 1. Fetcher should not fire if user is signed out on mount. (fetcher does not run, loading states are not triggered) + // 2. If user was signed in and then signed out, cached data should become null. (fetcher runs and returns null, loading states are triggered) + // + // We achieve (2) by setting the key to the cache key when the user transitions to signed out and forcing the fetcher to return null. + const swrKey = + typeof isSignedIn === 'boolean' + ? previousIsSignedIn === true && isSignedIn === false + ? pagesCacheKey + : isSignedIn + ? shouldFetch + ? pagesCacheKey + : null + : null + : shouldFetch + ? pagesCacheKey + : null; + + const swrFetcher = + !cacheMode && !!fetcher + ? (cacheKeyParams: Record) => { + if (isSignedIn === false || shouldFetch === false) { + return null; + } + const requestParams = getDifferentKeys(cacheKeyParams, { type: keys.queryKey[0], ...keys.queryKey[2] }); + // @ts-ignore - fetcher expects Params subset; narrowing at call-site + return fetcher(requestParams); + } + : null; + + const { + data: swrData, + isValidating: swrIsValidating, + isLoading: swrIsLoading, + error: swrError, + mutate: swrMutate, + } = useSWR(swrKey, swrFetcher, { keepPreviousData, ...cachingSWROptions }); + + // Attention: + // + // Cache behavior for infinite loading when signing out: + // + // Unlike `useSWR` above (which requires complex transition handling), `useSWRInfinite` has simpler sign-out semantics: + // 1. When user is signed out on mount, the key getter returns `null`, preventing any fetches. + // 2. When user transitions from signed in to signed out, the key getter returns `null` for all page indices. + // 3. When `useSWRInfinite`'s key getter returns `null`, SWR will not fetch data and considers that page invalid. + // 4. Unlike paginated mode, `useSWRInfinite` does not support `keepPreviousData`, so there's no previous data retention. + // + // This simpler behavior works because: + // - `useSWRInfinite` manages multiple pages internally, each with its own cache key + // - When the key getter returns `null`, all page fetches are prevented and pages become invalid + // - Without `keepPreviousData`, the hook will naturally reflect the empty/invalid state + // + // Result: No special transition logic needed - just return `null` from key getter when `isSignedIn === false`. + const { + data: swrInfiniteData, + isLoading: swrInfiniteIsLoading, + isValidating: swrInfiniteIsValidating, + error: swrInfiniteError, + size, + setSize, + mutate: swrInfiniteMutate, + } = useSWRInfinite( + pageIndex => { + if (!triggerInfinite || !enabled || isSignedIn === false) { + return null; + } + + return { + ...toSWRQuery(keys), + initialPage: initialPageRef.current + pageIndex, + pageSize: pageSizeRef.current, + }; + }, + cacheKeyParams => { + // @ts-ignore - fetcher expects Params subset; narrowing at call-site + const requestParams = getDifferentKeys(cacheKeyParams, { type: keys.queryKey[0], ...keys.queryKey[2] }); + // @ts-ignore - fetcher expects Params subset; narrowing at call-site + return fetcher?.(requestParams); + }, + cachingSWRInfiniteOptions, + ); + + const page = useMemo(() => { + if (triggerInfinite) { + return size; + } + return paginatedPage; + }, [triggerInfinite, size, paginatedPage]); + + const fetchPage: ValueOrSetter = useCallback( + numberOrgFn => { + if (triggerInfinite) { + void setSize(numberOrgFn); + return; + } + return setPaginatedPage(numberOrgFn); + }, + [setSize, triggerInfinite], + ); + + const data = useMemo(() => { + if (triggerInfinite) { + return swrInfiniteData?.map(a => a?.data).flat() ?? []; + } + return swrData?.data ?? []; + }, [triggerInfinite, swrData, swrInfiniteData]); + + const count = useMemo(() => { + if (triggerInfinite) { + return swrInfiniteData?.[swrInfiniteData?.length - 1]?.total_count || 0; + } + return swrData?.total_count ?? 0; + }, [triggerInfinite, swrData, swrInfiniteData]); + + const isLoading = triggerInfinite ? swrInfiniteIsLoading : swrIsLoading; + const isFetching = triggerInfinite ? swrInfiniteIsValidating : swrIsValidating; + const error = (triggerInfinite ? swrInfiniteError : swrError) ?? null; + const isError = !!error; + + const fetchNext = useCallback(() => { + fetchPage(n => Math.max(0, n + 1)); + }, [fetchPage]); + + const fetchPrevious = useCallback(() => { + fetchPage(n => Math.max(0, n - 1)); + }, [fetchPage]); + + const offsetCount = (initialPageRef.current - 1) * pageSizeRef.current; + + const pageCount = Math.ceil((count - offsetCount) / pageSizeRef.current); + const hasNextPage = count - offsetCount * pageSizeRef.current > page * pageSizeRef.current; + const hasPreviousPage = (page - 1) * pageSizeRef.current > offsetCount * pageSizeRef.current; + + const setData: CacheSetter = triggerInfinite + ? value => + swrInfiniteMutate(value, { + revalidate: false, + }) + : value => + swrMutate(value, { + revalidate: false, + }); + + const revalidate = triggerInfinite ? () => swrInfiniteMutate() : () => swrMutate(); + + return { + data, + count, + error, + isLoading, + isFetching, + isError, + page, + pageCount, + fetchPage, + fetchNext, + fetchPrevious, + hasNextPage, + hasPreviousPage, + // Let the hook return type define this type + revalidate: revalidate as any, + // Let the hook return type define this type + setData: setData as any, + }; +}; + +export { useWithSafeValues }; diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.tsx index f4d01862445..3bb9fe522ff 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.tsx +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.tsx @@ -1,257 +1,2 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; - -import type { ClerkPaginatedResponse } from '../../types'; -import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; -import { useClerkQueryClient } from '../clerk-rq/use-clerk-query-client'; -import { useClerkInfiniteQuery } from '../clerk-rq/useInfiniteQuery'; -import { useClerkQuery } from '../clerk-rq/useQuery'; -import type { CacheSetter, ValueOrSetter } from '../types'; -import { useClearQueriesOnSignOut, withInfiniteKey } from './useClearQueriesOnSignOut'; -import type { UsePagesOrInfiniteSignature } from './usePageOrInfinite.types'; -import { useWithSafeValues } from './usePagesOrInfinite.shared'; - -export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { - const { fetcher, config, keys } = params; - - const [paginatedPage, setPaginatedPage] = useState(config.initialPage ?? 1); - - // Cache initialPage and initialPageSize until unmount - const initialPageRef = useRef(config.initialPage ?? 1); - const pageSizeRef = useRef(config.pageSize ?? 10); - - const enabled = config.enabled ?? true; - const isSignedIn = config.isSignedIn; - const triggerInfinite = config.infinite ?? false; - const cacheMode = config.__experimental_mode === 'cache'; - const keepPreviousData = config.keepPreviousData ?? false; - - const [queryClient] = useClerkQueryClient(); - - // Compute the actual enabled state for queries (considering all conditions) - const queriesEnabled = enabled && Boolean(fetcher) && !cacheMode && isSignedIn !== false; - - // Force re-render counter for cache-only updates - const [forceUpdateCounter, setForceUpdateCounter] = useState(0); - const forceUpdate = useCallback((updater: (n: number) => number) => { - setForceUpdateCounter(updater); - }, []); - - // Non-infinite mode: single page query - const pagesQueryKey = useMemo(() => { - const [stablePrefix, authenticated, tracked, untracked] = keys.queryKey; - - return [ - stablePrefix, - authenticated, - tracked, - { - ...untracked, - args: { - ...untracked.args, - initialPage: paginatedPage, - pageSize: pageSizeRef.current, - }, - }, - ] as const; - }, [keys.queryKey, paginatedPage]); - - const singlePageQuery = useClerkQuery({ - queryKey: pagesQueryKey, - queryFn: ({ queryKey }) => { - const { args } = queryKey[3]; - - if (!fetcher) { - return undefined as any; - } - - return fetcher(args); - }, - staleTime: 60_000, - enabled: queriesEnabled && !triggerInfinite, - // Use placeholderData to keep previous data while fetching new page - placeholderData: defineKeepPreviousDataFn(keepPreviousData), - }); - - // Infinite mode: accumulate pages - const infiniteQueryKey = useMemo(() => { - const [stablePrefix, authenticated, tracked, untracked] = keys.queryKey; - - return [stablePrefix + '-inf', authenticated, tracked, untracked] as const; - }, [keys.queryKey]); - - const infiniteQuery = useClerkInfiniteQuery, any, any, typeof infiniteQueryKey, any>({ - queryKey: infiniteQueryKey, - initialPageParam: config.initialPage ?? 1, - getNextPageParam: (lastPage, allPages, lastPageParam) => { - const total = lastPage?.total_count ?? 0; - const consumed = (allPages.length + (config.initialPage ? config.initialPage - 1 : 0)) * (config.pageSize ?? 10); - return consumed < total ? (lastPageParam as number) + 1 : undefined; - }, - queryFn: ({ pageParam, queryKey }) => { - const { args } = queryKey[3]; - if (!fetcher) { - return undefined as any; - } - return fetcher({ ...args, initialPage: pageParam, pageSize: pageSizeRef.current }); - }, - staleTime: 60_000, - enabled: queriesEnabled && triggerInfinite, - }); - - useClearQueriesOnSignOut({ - isSignedOut: isSignedIn === false, - authenticated: keys.authenticated, - stableKeys: withInfiniteKey(keys.stableKey), - onCleanup: () => { - // Reset paginated page to initial - setPaginatedPage(initialPageRef.current); - - // Force re-render to reflect cache changes - void Promise.resolve().then(() => forceUpdate(n => n + 1)); - }, - }); - - // Compute data, count and page from the same data source to ensure consistency - const computedValues = useMemo(() => { - if (triggerInfinite) { - // Read from query data first, fallback to cache - const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); - const pages = queriesEnabled ? (infiniteQuery.data?.pages ?? cachedData?.pages ?? []) : (cachedData?.pages ?? []); - - // Ensure pages is always an array and filter out null/undefined pages - const validPages = Array.isArray(pages) ? pages.filter(Boolean) : []; - - return { - data: - validPages - .map((a: ClerkPaginatedResponse) => a?.data) - .flat() - .filter(Boolean) ?? [], - count: validPages[validPages.length - 1]?.total_count ?? 0, - page: validPages.length > 0 ? validPages.length : initialPageRef.current, - }; - } - - // When query is disabled (via enabled flag), the hook's data is stale, so only read from cache - // This ensures that after cache clearing, we return consistent empty state - const pageData = queriesEnabled - ? (singlePageQuery.data ?? queryClient.getQueryData>(pagesQueryKey)) - : queryClient.getQueryData>(pagesQueryKey); - - return { - data: Array.isArray(pageData?.data) ? pageData.data : [], - count: typeof pageData?.total_count === 'number' ? pageData.total_count : 0, - page: paginatedPage, - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- forceUpdateCounter is used to trigger re-renders for cache updates - }, [ - queriesEnabled, - forceUpdateCounter, - triggerInfinite, - infiniteQuery.data?.pages, - singlePageQuery.data, - queryClient, - infiniteQueryKey, - pagesQueryKey, - paginatedPage, - ]); - - const { data, count, page } = computedValues; - - const fetchPage: ValueOrSetter = useCallback( - numberOrgFn => { - if (triggerInfinite) { - const next = typeof numberOrgFn === 'function' ? (numberOrgFn as (n: number) => number)(page) : numberOrgFn; - const targetCount = Math.max(0, next); - const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); - const pages = infiniteQuery.data?.pages ?? cachedData?.pages ?? []; - const currentCount = pages.length; - const toFetch = targetCount - currentCount; - if (toFetch > 0) { - void infiniteQuery.fetchNextPage({ cancelRefetch: false }); - } - return; - } - return setPaginatedPage(numberOrgFn); - }, - [infiniteQuery, page, triggerInfinite, queryClient, infiniteQueryKey], - ); - - const isLoading = triggerInfinite ? infiniteQuery.isLoading : singlePageQuery.isLoading; - const isFetching = triggerInfinite ? infiniteQuery.isFetching : singlePageQuery.isFetching; - const error = (triggerInfinite ? infiniteQuery.error : singlePageQuery.error) ?? null; - const isError = !!error; - - const fetchNext = useCallback(() => { - if (triggerInfinite) { - void infiniteQuery.fetchNextPage({ cancelRefetch: false }); - return; - } - setPaginatedPage(n => Math.max(0, n + 1)); - }, [infiniteQuery, triggerInfinite]); - - const fetchPrevious = useCallback(() => { - if (triggerInfinite) { - // not natively supported by forward-only pagination; noop - return; - } - setPaginatedPage(n => Math.max(0, n - 1)); - }, [triggerInfinite]); - - const offsetCount = (initialPageRef.current - 1) * pageSizeRef.current; - const pageCount = Math.ceil((count - offsetCount) / pageSizeRef.current); - const hasNextPage = triggerInfinite - ? Boolean(infiniteQuery.hasNextPage) - : count - offsetCount * pageSizeRef.current > page * pageSizeRef.current; - const hasPreviousPage = triggerInfinite - ? Boolean(infiniteQuery.hasPreviousPage) - : (page - 1) * pageSizeRef.current > offsetCount * pageSizeRef.current; - - const setData: CacheSetter = value => { - if (triggerInfinite) { - queryClient.setQueryData(infiniteQueryKey, (prevValue: any = {}) => { - const prevPages = Array.isArray(prevValue?.pages) ? prevValue.pages : []; - const nextPages = (typeof value === 'function' ? value(prevPages) : value) as Array< - ClerkPaginatedResponse - >; - return { ...prevValue, pages: nextPages }; - }); - // Force immediate re-render to reflect cache changes - forceUpdate(n => n + 1); - return Promise.resolve(); - } - queryClient.setQueryData(pagesQueryKey, (prevValue: any = { data: [], total_count: 0 }) => { - const nextValue = (typeof value === 'function' ? value(prevValue) : value) as ClerkPaginatedResponse; - return nextValue; - }); - // Force re-render to reflect cache changes - forceUpdate(n => n + 1); - return Promise.resolve(); - }; - - const revalidate = async () => { - await queryClient.invalidateQueries({ queryKey: keys.invalidationKey }); - const [stablePrefix, ...rest] = keys.invalidationKey; - return queryClient.invalidateQueries({ queryKey: [stablePrefix + '-inf', ...rest] }); - }; - - return { - data, - count, - error, - isLoading, - isFetching, - isError, - page, - pageCount, - fetchPage, - fetchNext, - fetchPrevious, - hasNextPage, - hasPreviousPage, - revalidate: revalidate as any, - setData: setData as any, - }; -}; - -export { useWithSafeValues }; +export { usePagesOrInfinite } from 'virtual:data-hooks/usePagesOrInfinite'; +export { useWithSafeValues } from './usePagesOrInfinite.shared'; diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx new file mode 100644 index 00000000000..7a59fc5b282 --- /dev/null +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx @@ -0,0 +1,57 @@ +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +import { usePaymentAttemptQueryCacheKeys } from './usePaymentAttemptQuery.shared'; +import type { PaymentAttemptQueryResult, UsePaymentAttemptQueryParams } from './usePaymentAttemptQuery.types'; + +/** + * @internal + */ +function usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAttemptQueryResult { + const { paymentAttemptId, keepPreviousData = false, for: forType = 'user' } = params; + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; + const userId = user?.id ?? null; + + const { queryKey, stableKey, authenticated } = usePaymentAttemptQueryCacheKeys({ + paymentAttemptId, + userId, + orgId: organizationId, + for: forType, + }); + + const billingEnabled = useBillingHookEnabled(params); + + const queryEnabled = Boolean(paymentAttemptId) && billingEnabled; + + useClearQueriesOnSignOut({ + isSignedOut: user === null, // works with the transitive state + authenticated, + stableKeys: stableKey, + }); + + const query = useClerkQuery({ + queryKey, + queryFn: ({ queryKey }) => { + const args = queryKey[3].args; + return clerk.billing.getPaymentAttempt(args); + }, + enabled: queryEnabled, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), + staleTime: 1_000 * 60, + }); + + return { + data: query.data, + error: (query.error ?? null) as PaymentAttemptQueryResult['error'], + isLoading: query.isLoading, + isFetching: query.isFetching, + }; +} + +export { usePaymentAttemptQuery as __internal_usePaymentAttemptQuery }; diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx new file mode 100644 index 00000000000..d47d5d52246 --- /dev/null +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx @@ -0,0 +1,48 @@ +import { useSWR } from '../clerk-swr'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; +import { usePaymentAttemptQueryCacheKeys } from './usePaymentAttemptQuery.shared'; +import type { PaymentAttemptQueryResult, UsePaymentAttemptQueryParams } from './usePaymentAttemptQuery.types'; + +/** + * This is the existing implementation of usePaymentAttemptQuery using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +export function __internal_usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAttemptQueryResult { + const { paymentAttemptId, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; + const userId = user?.id ?? null; + + const { queryKey } = usePaymentAttemptQueryCacheKeys({ + paymentAttemptId, + userId, + orgId: organizationId, + for: forType, + }); + + const queryEnabled = Boolean(paymentAttemptId) && enabled && (forType !== 'organization' || Boolean(organizationId)); + + const swr = useSWR( + queryEnabled ? { queryKey } : null, + ({ queryKey }) => { + const args = queryKey[3].args; + return clerk.billing.getPaymentAttempt(args); + }, + { + dedupingInterval: 1_000 * 60, + keepPreviousData, + }, + ); + + return { + data: swr.data, + error: (swr.error ?? null) as PaymentAttemptQueryResult['error'], + isLoading: swr.isLoading, + isFetching: swr.isValidating, + }; +} diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.tsx index 7a59fc5b282..ffa7ea1dc6e 100644 --- a/packages/shared/src/react/hooks/usePaymentAttemptQuery.tsx +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.tsx @@ -1,57 +1 @@ -import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; -import { useClerkQuery } from '../clerk-rq/useQuery'; -import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; -import { useBillingHookEnabled } from './useBillingHookEnabled'; -import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; -import { usePaymentAttemptQueryCacheKeys } from './usePaymentAttemptQuery.shared'; -import type { PaymentAttemptQueryResult, UsePaymentAttemptQueryParams } from './usePaymentAttemptQuery.types'; - -/** - * @internal - */ -function usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAttemptQueryResult { - const { paymentAttemptId, keepPreviousData = false, for: forType = 'user' } = params; - const clerk = useClerkInstanceContext(); - const user = useUserContext(); - const { organization } = useOrganizationContext(); - - const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; - const userId = user?.id ?? null; - - const { queryKey, stableKey, authenticated } = usePaymentAttemptQueryCacheKeys({ - paymentAttemptId, - userId, - orgId: organizationId, - for: forType, - }); - - const billingEnabled = useBillingHookEnabled(params); - - const queryEnabled = Boolean(paymentAttemptId) && billingEnabled; - - useClearQueriesOnSignOut({ - isSignedOut: user === null, // works with the transitive state - authenticated, - stableKeys: stableKey, - }); - - const query = useClerkQuery({ - queryKey, - queryFn: ({ queryKey }) => { - const args = queryKey[3].args; - return clerk.billing.getPaymentAttempt(args); - }, - enabled: queryEnabled, - placeholderData: defineKeepPreviousDataFn(keepPreviousData), - staleTime: 1_000 * 60, - }); - - return { - data: query.data, - error: (query.error ?? null) as PaymentAttemptQueryResult['error'], - isLoading: query.isLoading, - isFetching: query.isFetching, - }; -} - -export { usePaymentAttemptQuery as __internal_usePaymentAttemptQuery }; +export { __internal_usePaymentAttemptQuery } from 'virtual:data-hooks/usePaymentAttemptQuery'; diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx new file mode 100644 index 00000000000..c2a7ec96cbd --- /dev/null +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx @@ -0,0 +1,45 @@ +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useClerkInstanceContext } from '../contexts'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; +import { usePlanDetailsQueryCacheKeys } from './usePlanDetailsQuery.shared'; +import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePlanDetailsQuery.types'; + +/** + * @internal + */ +export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams = {}): PlanDetailsQueryResult { + const { planId, initialPlan = null, keepPreviousData = true } = params; + const clerk = useClerkInstanceContext(); + + const targetPlanId = planId ?? initialPlan?.id ?? null; + + const { queryKey } = usePlanDetailsQueryCacheKeys({ planId: targetPlanId }); + + const billingEnabled = useBillingHookEnabled({ + authenticated: false, + }); + + const queryEnabled = Boolean(targetPlanId) && billingEnabled; + + const query = useClerkQuery({ + queryKey, + queryFn: () => { + if (!targetPlanId) { + throw new Error('planId is required to fetch plan details'); + } + return clerk.billing.getPlan({ id: targetPlanId }); + }, + enabled: queryEnabled, + initialData: initialPlan ?? undefined, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), + initialDataUpdatedAt: 0, + }); + + return { + data: query.data, + error: (query.error ?? null) as PlanDetailsQueryResult['error'], + isLoading: query.isLoading, + isFetching: query.isFetching, + }; +} diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx new file mode 100644 index 00000000000..ce544fce5b4 --- /dev/null +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx @@ -0,0 +1,45 @@ +import { useSWR } from '../clerk-swr'; +import { useClerkInstanceContext } from '../contexts'; +import { usePlanDetailsQueryCacheKeys } from './usePlanDetailsQuery.shared'; +import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePlanDetailsQuery.types'; + +/** + * This is the existing implementation of usePlanDetailsQuery using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +function usePlanDetailsQuery(params: UsePlanDetailsQueryParams = {}): PlanDetailsQueryResult { + const { planId, initialPlan = null, enabled = true, keepPreviousData = true } = params; + const clerk = useClerkInstanceContext(); + + const targetPlanId = planId ?? initialPlan?.id ?? null; + + const { queryKey } = usePlanDetailsQueryCacheKeys({ planId: targetPlanId }); + + const queryEnabled = Boolean(targetPlanId) && enabled; + + const swr = useSWR( + queryEnabled ? queryKey : null, + () => { + if (!targetPlanId) { + throw new Error('planId is required to fetch plan details'); + } + return clerk.billing.getPlan({ id: targetPlanId }); + }, + { + dedupingInterval: 1_000 * 60, + keepPreviousData, + fallbackData: initialPlan ?? undefined, + }, + ); + + return { + data: swr.data, + error: (swr.error ?? null) as PlanDetailsQueryResult['error'], + isLoading: swr.isLoading, + isFetching: swr.isValidating, + }; +} + +export { usePlanDetailsQuery as __internal_usePlanDetailsQuery }; diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.tsx index c2a7ec96cbd..7fb85951400 100644 --- a/packages/shared/src/react/hooks/usePlanDetailsQuery.tsx +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.tsx @@ -1,45 +1 @@ -import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; -import { useClerkQuery } from '../clerk-rq/useQuery'; -import { useClerkInstanceContext } from '../contexts'; -import { useBillingHookEnabled } from './useBillingHookEnabled'; -import { usePlanDetailsQueryCacheKeys } from './usePlanDetailsQuery.shared'; -import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePlanDetailsQuery.types'; - -/** - * @internal - */ -export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams = {}): PlanDetailsQueryResult { - const { planId, initialPlan = null, keepPreviousData = true } = params; - const clerk = useClerkInstanceContext(); - - const targetPlanId = planId ?? initialPlan?.id ?? null; - - const { queryKey } = usePlanDetailsQueryCacheKeys({ planId: targetPlanId }); - - const billingEnabled = useBillingHookEnabled({ - authenticated: false, - }); - - const queryEnabled = Boolean(targetPlanId) && billingEnabled; - - const query = useClerkQuery({ - queryKey, - queryFn: () => { - if (!targetPlanId) { - throw new Error('planId is required to fetch plan details'); - } - return clerk.billing.getPlan({ id: targetPlanId }); - }, - enabled: queryEnabled, - initialData: initialPlan ?? undefined, - placeholderData: defineKeepPreviousDataFn(keepPreviousData), - initialDataUpdatedAt: 0, - }); - - return { - data: query.data, - error: (query.error ?? null) as PlanDetailsQueryResult['error'], - isLoading: query.isLoading, - isFetching: query.isFetching, - }; -} +export { __internal_usePlanDetailsQuery } from 'virtual:data-hooks/usePlanDetailsQuery'; diff --git a/packages/shared/src/react/hooks/useStatementQuery.rq.tsx b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx new file mode 100644 index 00000000000..25f8b4a3908 --- /dev/null +++ b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx @@ -0,0 +1,59 @@ +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +import { useStatementQueryCacheKeys } from './useStatementQuery.shared'; +import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; + +/** + * @internal + */ +function useStatementQuery(params: UseStatementQueryParams = {}): StatementQueryResult { + const { statementId = null, keepPreviousData = false, for: forType = 'user' } = params; + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; + const userId = user?.id ?? null; + + const { queryKey, stableKey, authenticated } = useStatementQueryCacheKeys({ + statementId, + userId, + orgId: organizationId, + for: forType, + }); + + const billingEnabled = useBillingHookEnabled(params); + + const queryEnabled = Boolean(statementId) && billingEnabled; + + useClearQueriesOnSignOut({ + isSignedOut: user === null, + authenticated, + stableKeys: stableKey, + }); + + const query = useClerkQuery({ + queryKey, + queryFn: () => { + if (!statementId) { + throw new Error('statementId is required to fetch a statement'); + } + return clerk.billing.getStatement({ id: statementId, orgId: organizationId ?? undefined }); + }, + enabled: queryEnabled, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), + staleTime: 1_000 * 60, + }); + + return { + data: query.data, + error: (query.error ?? null) as StatementQueryResult['error'], + isLoading: query.isLoading, + isFetching: query.isFetching, + }; +} + +export { useStatementQuery as __internal_useStatementQuery }; diff --git a/packages/shared/src/react/hooks/useStatementQuery.swr.tsx b/packages/shared/src/react/hooks/useStatementQuery.swr.tsx new file mode 100644 index 00000000000..8d209d75f66 --- /dev/null +++ b/packages/shared/src/react/hooks/useStatementQuery.swr.tsx @@ -0,0 +1,50 @@ +import { useSWR } from '../clerk-swr'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; +import { useStatementQueryCacheKeys } from './useStatementQuery.shared'; +import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; + +/** + * This is the existing implementation of useStatementQuery using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +export function __internal_useStatementQuery(params: UseStatementQueryParams = {}): StatementQueryResult { + const { statementId = null, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; + const userId = user?.id ?? null; + + const { queryKey } = useStatementQueryCacheKeys({ + statementId, + userId, + orgId: organizationId, + for: forType, + }); + + const queryEnabled = Boolean(statementId) && enabled && (forType !== 'organization' || Boolean(organizationId)); + + const swr = useSWR( + queryEnabled ? queryKey : null, + () => { + if (!statementId) { + throw new Error('statementId is required to fetch a statement'); + } + return clerk.billing.getStatement({ id: statementId, orgId: organizationId ?? undefined }); + }, + { + dedupingInterval: 1_000 * 60, + keepPreviousData, + }, + ); + + return { + data: swr.data, + error: (swr.error ?? null) as StatementQueryResult['error'], + isLoading: swr.isLoading, + isFetching: swr.isValidating, + }; +} diff --git a/packages/shared/src/react/hooks/useStatementQuery.tsx b/packages/shared/src/react/hooks/useStatementQuery.tsx index 25f8b4a3908..0664eedaefa 100644 --- a/packages/shared/src/react/hooks/useStatementQuery.tsx +++ b/packages/shared/src/react/hooks/useStatementQuery.tsx @@ -1,59 +1 @@ -import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; -import { useClerkQuery } from '../clerk-rq/useQuery'; -import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; -import { useBillingHookEnabled } from './useBillingHookEnabled'; -import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; -import { useStatementQueryCacheKeys } from './useStatementQuery.shared'; -import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; - -/** - * @internal - */ -function useStatementQuery(params: UseStatementQueryParams = {}): StatementQueryResult { - const { statementId = null, keepPreviousData = false, for: forType = 'user' } = params; - const clerk = useClerkInstanceContext(); - const user = useUserContext(); - const { organization } = useOrganizationContext(); - - const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; - const userId = user?.id ?? null; - - const { queryKey, stableKey, authenticated } = useStatementQueryCacheKeys({ - statementId, - userId, - orgId: organizationId, - for: forType, - }); - - const billingEnabled = useBillingHookEnabled(params); - - const queryEnabled = Boolean(statementId) && billingEnabled; - - useClearQueriesOnSignOut({ - isSignedOut: user === null, - authenticated, - stableKeys: stableKey, - }); - - const query = useClerkQuery({ - queryKey, - queryFn: () => { - if (!statementId) { - throw new Error('statementId is required to fetch a statement'); - } - return clerk.billing.getStatement({ id: statementId, orgId: organizationId ?? undefined }); - }, - enabled: queryEnabled, - placeholderData: defineKeepPreviousDataFn(keepPreviousData), - staleTime: 1_000 * 60, - }); - - return { - data: query.data, - error: (query.error ?? null) as StatementQueryResult['error'], - isLoading: query.isLoading, - isFetching: query.isFetching, - }; -} - -export { useStatementQuery as __internal_useStatementQuery }; +export { __internal_useStatementQuery } from 'virtual:data-hooks/useStatementQuery'; 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..4ae24e593e1 --- /dev/null +++ b/packages/shared/src/react/hooks/useSubscription.rq.tsx @@ -0,0 +1,79 @@ +import { useCallback } from 'react'; + +import { eventMethodCalled } from '../../telemetry/events'; +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQueryClient } from '../clerk-rq/use-clerk-query-client'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { + useAssertWrappedByClerkProvider, + useClerkInstanceContext, + useOrganizationContext, + useUserContext, +} from '../contexts'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +import { useSubscriptionCacheKeys } from './useSubscription.shared'; +import type { SubscriptionResult, UseSubscriptionParams } from './useSubscription.types'; + +const HOOK_NAME = 'useSubscription'; + +/** + * 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`. + * + * @internal + */ +export function useSubscription(params?: UseSubscriptionParams): SubscriptionResult { + useAssertWrappedByClerkProvider(HOOK_NAME); + + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + const billingEnabled = useBillingHookEnabled(params); + + clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); + + const keepPreviousData = params?.keepPreviousData ?? false; + + const [queryClient] = useClerkQueryClient(); + + const { queryKey, invalidationKey, stableKey, authenticated } = useSubscriptionCacheKeys({ + userId: user?.id, + orgId: organization?.id, + for: params?.for, + }); + + const queriesEnabled = Boolean(user?.id && billingEnabled); + useClearQueriesOnSignOut({ + isSignedOut: user === null, + authenticated, + stableKeys: stableKey, + }); + + const query = useClerkQuery({ + queryKey, + queryFn: ({ queryKey }) => { + const obj = queryKey[3]; + return clerk.billing.getSubscription(obj.args); + }, + staleTime: 1_000 * 60, + enabled: queriesEnabled, + placeholderData: defineKeepPreviousDataFn(keepPreviousData && queriesEnabled), + }); + + const revalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: invalidationKey }), + [queryClient, invalidationKey], + ); + + return { + data: query.data, + // Our existing types for SWR return undefined when there is no error, but React Query returns null. + // So we need to convert the error to undefined, for backwards compatibility. + error: query.error ?? undefined, + 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..d418cbe6d77 --- /dev/null +++ b/packages/shared/src/react/hooks/useSubscription.swr.tsx @@ -0,0 +1,74 @@ +import { useCallback } from 'react'; + +import { eventMethodCalled } from '../../telemetry/events'; +import type { EnvironmentResource } from '../../types'; +import { useSWR } from '../clerk-swr'; +import { + useAssertWrappedByClerkProvider, + useClerkInstanceContext, + useOrganizationContext, + useUserContext, +} from '../contexts'; +import { useSubscriptionCacheKeys } from './useSubscription.shared'; +import type { SubscriptionResult, UseSubscriptionParams } from './useSubscription.types'; + +const hookName = 'useSubscription'; + +/** + * This is the existing implementation of useSubscription using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +export function useSubscription(params?: UseSubscriptionParams): SubscriptionResult { + useAssertWrappedByClerkProvider(hookName); + + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + // @ts-expect-error `__internal_environment` is not typed + const environment = clerk.__internal_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 isEnabled = (params?.enabled ?? true) && billingEnabled; + + const { queryKey } = useSubscriptionCacheKeys({ + userId: user?.id, + orgId: organization?.id, + for: params?.for, + }); + + const swr = useSWR( + isEnabled ? { queryKey } : null, + ({ queryKey }) => { + const args = queryKey[3].args; + + if (queryKey[2].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 39b655dbd1e..98cd031a355 100644 --- a/packages/shared/src/react/hooks/useSubscription.tsx +++ b/packages/shared/src/react/hooks/useSubscription.tsx @@ -1,82 +1 @@ -import { useCallback, useEffect, useRef } from 'react'; - -import { eventMethodCalled } from '../../telemetry/events'; -import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; -import { useClerkQueryClient } from '../clerk-rq/use-clerk-query-client'; -import { useClerkQuery } from '../clerk-rq/useQuery'; -import { - useAssertWrappedByClerkProvider, - useClerkInstanceContext, - useOrganizationContext, - useUserContext, -} from '../contexts'; -import { useBillingHookEnabled } from './useBillingHookEnabled'; -import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; -import { useSubscriptionCacheKeys } from './useSubscription.shared'; -import type { SubscriptionResult, UseSubscriptionParams } from './useSubscription.types'; - -const HOOK_NAME = 'useSubscription'; - -/** - * @internal - */ -export function useSubscription(params?: UseSubscriptionParams): SubscriptionResult { - useAssertWrappedByClerkProvider(HOOK_NAME); - - const clerk = useClerkInstanceContext(); - const user = useUserContext(); - const { organization } = useOrganizationContext(); - - const billingEnabled = useBillingHookEnabled(params); - - const recordedRef = useRef(false); - useEffect(() => { - if (!recordedRef.current && clerk?.telemetry) { - clerk.telemetry.record(eventMethodCalled(HOOK_NAME)); - recordedRef.current = true; - } - }, [clerk]); - - const keepPreviousData = params?.keepPreviousData ?? false; - - const [queryClient] = useClerkQueryClient(); - - const { queryKey, invalidationKey, stableKey, authenticated } = useSubscriptionCacheKeys({ - userId: user?.id, - orgId: organization?.id, - for: params?.for, - }); - - const queriesEnabled = Boolean(user?.id && billingEnabled); - useClearQueriesOnSignOut({ - isSignedOut: user === null, - authenticated, - stableKeys: stableKey, - }); - - const query = useClerkQuery({ - queryKey, - queryFn: ({ queryKey }) => { - const obj = queryKey[3]; - return clerk.billing.getSubscription(obj.args); - }, - staleTime: 1_000 * 60, - enabled: queriesEnabled, - placeholderData: defineKeepPreviousDataFn(keepPreviousData && queriesEnabled), - }); - - const revalidate = useCallback( - () => queryClient.invalidateQueries({ queryKey: invalidationKey }), - [queryClient, invalidationKey], - ); - - return { - data: query.data, - // Our existing types for SWR return undefined when there is no error, but React Query returns null. - // So we need to convert the error to undefined, for backwards compatibility. - error: query.error ?? undefined, - isLoading: query.isLoading, - isFetching: query.isFetching, - revalidate, - }; -} +export { useSubscription } from 'virtual:data-hooks/useSubscription'; 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..40810747d89 --- /dev/null +++ b/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx @@ -0,0 +1,8 @@ +import type { PropsWithChildren } from 'react'; +import React from 'react'; +/** + * @internal + */ +export function SWRConfigCompat({ 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..97d341456d1 --- /dev/null +++ b/packages/shared/src/react/providers/SWRConfigCompat.swr.tsx @@ -0,0 +1,10 @@ +import React, { type PropsWithChildren } from 'react'; +import { SWRConfig } from 'swr'; + +/** + * @internal + */ +export function SWRConfigCompat({ swrConfig, children }: PropsWithChildren<{ swrConfig?: any }>) { + // TODO: Replace SWRConfig with the react-query equivalent. + 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..680d0d56269 --- /dev/null +++ b/packages/shared/src/types/virtual-data-hooks.d.ts @@ -0,0 +1,12 @@ +declare module 'virtual:data-hooks/*' { + // Generic export signatures to satisfy type resolution for virtual modules + export const SWRConfigCompat: any; + export const useSubscription: any; + export const usePagesOrInfinite: any; + export const useAPIKeys: any; + export const __internal_useStatementQuery: any; + export const __internal_usePlanDetailsQuery: any; + export const __internal_usePaymentAttemptQuery: any; + const mod: any; + export default mod; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 05d89438884..a3540904ce1 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -23,9 +23,19 @@ "declarationMap": true, "allowJs": true, "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "virtual:data-hooks/useAPIKeys": ["./src/react/hooks/useAPIKeys.swr.tsx"], + "virtual:data-hooks/useSubscription": ["./src/react/hooks/useSubscription.swr.tsx"], + "virtual:data-hooks/SWRConfigCompat": ["./src/react/providers/SWRConfigCompat.swr.tsx"], + "virtual:data-hooks/usePagesOrInfinite": ["./src/react/hooks/usePagesOrInfinite.swr.tsx"], + "virtual:data-hooks/useStatementQuery": ["./src/react/hooks/useStatementQuery.swr.tsx"], + "virtual:data-hooks/usePaymentAttemptQuery": ["./src/react/hooks/usePaymentAttemptQuery.swr.tsx"], + "virtual:data-hooks/usePlanDetailsQuery": ["./src/react/hooks/usePlanDetailsQuery.swr.tsx"], + "virtual:data-hooks/useInitializePaymentMethod": ["./src/react/billing/useInitializePaymentMethod.swr.tsx"], + "virtual:data-hooks/useStripeClerkLibs": ["./src/react/billing/useStripeClerkLibs.swr.tsx"], + "virtual:data-hooks/useStripeLoader": ["./src/react/billing/useStripeLoader.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 178c08e96d8..93c58027e0e 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'; @@ -22,6 +25,7 @@ export default defineConfig(({ watch }) => { UI_PACKAGE_VERSION: `"${clerkUiPackage.version}"`, __DEV__: `${watch}`, __BUILD_DISABLE_RHC__: JSON.stringify(false), + __CLERK_USE_RQ__: `${process.env.CLERK_USE_RQ === 'true'}`, }, } satisfies Options; @@ -51,6 +55,43 @@ 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 = [ + path.join(baseDir, 'src', 'react', 'hooks', impl), + path.join(baseDir, 'src', 'react', 'billing', 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 cf88a06605f..56046534aaa 100644 --- a/packages/shared/vitest.config.mts +++ b/packages/shared/vitest.config.mts @@ -1,8 +1,45 @@ +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) { + candidates.push( + path.join(base, 'src', 'react', 'hooks', impl), + path.join(base, 'src', 'react', 'billing', 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: [HookAliasPlugin()], resolve: { alias: { '@': path.resolve(__dirname, './src'), diff --git a/packages/shared/vitest.setup.mts b/packages/shared/vitest.setup.mts index 90be31f2b5e..e418b9f0ddf 100644 --- a/packages/shared/vitest.setup.mts +++ b/packages/shared/vitest.setup.mts @@ -8,6 +8,7 @@ globalThis.PACKAGE_NAME = '@clerk/react'; globalThis.PACKAGE_VERSION = '0.0.0-test'; globalThis.JS_PACKAGE_VERSION = '5.0.0'; globalThis.UI_PACKAGE_VERSION = '1.0.0'; +globalThis.__CLERK_USE_RQ__ = process.env.CLERK_USE_RQ === 'true'; // Setup Web Crypto API for tests (Node.js 18+ compatibility) if (!globalThis.crypto) { diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index a7a6235382b..6aa64ab7a8a 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/ui.legacy.browser.js", "maxSize": "54KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "129KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "128KB" }, { "path": "./dist/signin*.js", "maxSize": "16KB" }, { "path": "./dist/signup*.js", "maxSize": "11KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 33f4ab2185b..d65256cf3e8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -86,7 +86,8 @@ "input-otp": "1.4.2", "qrcode.react": "4.2.0", "react": "catalog:peer-react", - "react-dom": "catalog:peer-react" + "react-dom": "catalog:peer-react", + "swr": "2.3.4" }, "devDependencies": { "@floating-ui/react-dom": "^2.1.6", diff --git a/packages/ui/src/contexts/CoreClerkContextWrapper.tsx b/packages/ui/src/contexts/CoreClerkContextWrapper.tsx index b83ea37f58a..85e451a784d 100644 --- a/packages/ui/src/contexts/CoreClerkContextWrapper.tsx +++ b/packages/ui/src/contexts/CoreClerkContextWrapper.tsx @@ -12,8 +12,9 @@ import React from 'react'; import { assertClerkSingletonExists } from './utils'; type CoreClerkContextWrapperProps = { - children: React.ReactNode; clerk: Clerk; + children: React.ReactNode; + swrConfig?: any; }; type CoreClerkContextProviderState = Resources; @@ -50,7 +51,10 @@ export function CoreClerkContextWrapper(props: CoreClerkContextWrapperProps): JS - + =12'} - '@tanstack/query-core@5.90.16': - resolution: {integrity: sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==} + '@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==} @@ -14902,12 +14908,10 @@ packages: whatwg-encoding@2.0.0: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -20412,7 +20416,7 @@ snapshots: '@tanstack/history@1.132.0': {} - '@tanstack/query-core@5.90.16': {} + '@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: