From 2f87ceb1df7d51f414cd079d8f71821156725cc2 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 18 Nov 2025 16:49:22 +0200 Subject: [PATCH 01/18] compat with internal hooks Add todos --- .../src/ui/components/APIKeys/APIKeys.tsx | 1 + .../PaymentAttempts/PaymentAttemptPage.tsx | 25 ++----- .../src/ui/components/Plans/PlanDetails.tsx | 24 +++---- .../components/Statements/StatementPage.tsx | 33 ++++----- .../src/ui/hooks/usePaymentAttemptQuery.ts | 0 .../src/ui/hooks/usePlanDetailsQuery.ts | 0 .../src/ui/hooks/useStatementQuery.ts | 0 packages/shared/src/react/clerk-swr.ts | 1 + packages/shared/src/react/commerce.tsx | 3 + .../src/react/hooks/__tests__/wrapper.tsx | 1 + .../shared/src/react/hooks/createCacheKeys.ts | 14 +++- packages/shared/src/react/hooks/index.ts | 3 + .../react/hooks/usePageOrInfinite.types.ts | 4 +- .../react/hooks/usePagesOrInfinite.swr.tsx | 9 +-- .../react/hooks/usePaymentAttemptQuery.rq.tsx | 68 ++++++++++++++++++ .../hooks/usePaymentAttemptQuery.shared.ts | 33 +++++++++ .../hooks/usePaymentAttemptQuery.swr.tsx | 60 ++++++++++++++++ .../react/hooks/usePaymentAttemptQuery.tsx | 1 + .../hooks/usePaymentAttemptQuery.types.ts | 52 ++++++++++++++ .../react/hooks/usePlanDetailsQuery.rq.tsx | 60 ++++++++++++++++ .../react/hooks/usePlanDetailsQuery.shared.ts | 22 ++++++ .../react/hooks/usePlanDetailsQuery.swr.tsx | 50 +++++++++++++ .../src/react/hooks/usePlanDetailsQuery.tsx | 1 + .../react/hooks/usePlanDetailsQuery.types.ts | 50 +++++++++++++ .../src/react/hooks/useStatementQuery.rq.tsx | 70 +++++++++++++++++++ .../react/hooks/useStatementQuery.shared.ts | 33 +++++++++ .../src/react/hooks/useStatementQuery.swr.tsx | 62 ++++++++++++++++ .../src/react/hooks/useStatementQuery.tsx | 1 + .../react/hooks/useStatementQuery.types.ts | 51 ++++++++++++++ .../src/react/hooks/useSubscription.rq.tsx | 32 +++------ .../src/react/hooks/useSubscription.shared.ts | 30 ++++++++ .../src/react/hooks/useSubscription.swr.tsx | 22 +++--- packages/shared/src/react/index.ts | 2 + .../react/providers/SWRConfigCompat.rq.tsx | 1 + .../react/providers/SWRConfigCompat.swr.tsx | 1 + packages/shared/src/react/stable-keys.ts | 16 +++++ .../shared/src/types/virtual-data-hooks.d.ts | 3 + packages/shared/tsconfig.json | 5 +- 38 files changed, 745 insertions(+), 99 deletions(-) create mode 100644 packages/clerk-js/src/ui/hooks/usePaymentAttemptQuery.ts create mode 100644 packages/clerk-js/src/ui/hooks/usePlanDetailsQuery.ts create mode 100644 packages/clerk-js/src/ui/hooks/useStatementQuery.ts create mode 100644 packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx create mode 100644 packages/shared/src/react/hooks/usePaymentAttemptQuery.shared.ts create mode 100644 packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx create mode 100644 packages/shared/src/react/hooks/usePaymentAttemptQuery.tsx create mode 100644 packages/shared/src/react/hooks/usePaymentAttemptQuery.types.ts create mode 100644 packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx create mode 100644 packages/shared/src/react/hooks/usePlanDetailsQuery.shared.ts create mode 100644 packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx create mode 100644 packages/shared/src/react/hooks/usePlanDetailsQuery.tsx create mode 100644 packages/shared/src/react/hooks/usePlanDetailsQuery.types.ts create mode 100644 packages/shared/src/react/hooks/useStatementQuery.rq.tsx create mode 100644 packages/shared/src/react/hooks/useStatementQuery.shared.ts create mode 100644 packages/shared/src/react/hooks/useStatementQuery.swr.tsx create mode 100644 packages/shared/src/react/hooks/useStatementQuery.tsx create mode 100644 packages/shared/src/react/hooks/useStatementQuery.types.ts create mode 100644 packages/shared/src/react/hooks/useSubscription.shared.ts diff --git a/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx b/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx index 0ab90e2cc05..9a867294182 100644 --- a/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx +++ b/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx @@ -94,6 +94,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr }; const card = useCardState(); const clerk = useClerk(); + // TODO: Replace useSWRMutation with the react-query equivalent. const { data: createdAPIKey, trigger: createAPIKey, diff --git a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx index 8574c783daf..456ce1d14fc 100644 --- a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -1,6 +1,5 @@ -import { useClerk, useOrganizationContext } from '@clerk/shared/react'; +import { __internal_usePaymentAttemptQuery } from '@clerk/shared/react/index'; import type { BillingSubscriptionItemResource } from '@clerk/shared/types'; -import useSWR from 'swr'; import { Alert } from '@/ui/elements/Alert'; import { Header } from '@/ui/elements/Header'; @@ -31,28 +30,16 @@ export const PaymentAttemptPage = () => { const subscriberType = useSubscriberTypeContext(); const localizationRoot = useSubscriberTypeLocalizationRoot(); const { t, translateError } = useLocalizations(); - const clerk = useClerk(); - // Do not use `useOrganization` to avoid triggering the in-app enable organizations prompt in development instance - const organizationCtx = useOrganizationContext(); + const requesterType = subscriberType === 'organization' ? 'organization' : 'user'; const { data: paymentAttempt, isLoading, error, - } = useSWR( - params.paymentAttemptId - ? { - type: 'payment-attempt', - id: params.paymentAttemptId, - orgId: subscriberType === 'organization' ? organizationCtx?.organization?.id : undefined, - } - : null, - () => - clerk.billing.getPaymentAttempt({ - id: params.paymentAttemptId, - orgId: subscriberType === 'organization' ? organizationCtx?.organization?.id : undefined, - }), - ); + } = __internal_usePaymentAttemptQuery({ + paymentAttemptId: params.paymentAttemptId ?? null, + for: requesterType, + }); const subscriptionItem = paymentAttempt?.subscriptionItem; diff --git a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx index f9bf3b931c3..f9075c3de92 100644 --- a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx @@ -1,4 +1,4 @@ -import { useClerk } from '@clerk/shared/react'; +import { __internal_usePlanDetailsQuery } from '@clerk/shared/react/index'; import type { __internal_PlanDetailsProps, BillingPlanResource, @@ -7,7 +7,6 @@ import type { } from '@clerk/shared/types'; import * as React from 'react'; import { useMemo, useState } from 'react'; -import useSWR from 'swr'; import { Alert } from '@/ui/elements/Alert'; import { Avatar } from '@/ui/elements/Avatar'; @@ -29,6 +28,8 @@ import { useLocalizations, } from '../../customizables'; +type PlanFeature = BillingPlanResource['features'][number]; + export const PlanDetails = (props: __internal_PlanDetailsProps) => { return ( @@ -79,24 +80,17 @@ const PlanDetailsInternal = ({ plan: initialPlan, initialPlanPeriod = 'month', }: __internal_PlanDetailsProps) => { - const clerk = useClerk(); const [planPeriod, setPlanPeriod] = useState(initialPlanPeriod); const { data: plan, isLoading, error, - } = useSWR( - planId || initialPlan ? { type: 'plan', id: planId || initialPlan?.id } : null, - // @ts-expect-error we are handling it above - () => clerk.billing.getPlan({ id: planId || initialPlan?.id }), - { - fallbackData: initialPlan, - revalidateOnFocus: false, - shouldRetryOnError: false, - keepPreviousData: true, - }, - ); + } = __internal_usePlanDetailsQuery({ + planId, + initialPlan, + enabled: Boolean(planId || initialPlan?.id), + }); if (isLoading && !initialPlan) { return ( @@ -161,7 +155,7 @@ const PlanDetailsInternal = ({ margin: 0, })} > - {features.map(feature => ( + {features.map((feature: PlanFeature) => ( { const { params, navigate } = useRouter(); const subscriberType = useSubscriberTypeContext(); const localizationRoot = useSubscriberTypeLocalizationRoot(); const { t, translateError } = useLocalizations(); - const clerk = useClerk(); - // Do not use `useOrganization` to avoid triggering the in-app enable organizations prompt in development instance - const organizationCtx = useOrganizationContext(); + const requesterType = subscriberType === 'organization' ? 'organization' : 'user'; const { data: statement, isLoading, error, - } = useSWR( - params.statementId - ? { - type: 'statement', - id: params.statementId, - orgId: subscriberType === 'organization' ? organizationCtx?.organization?.id : undefined, - } - : null, - () => - clerk.billing.getStatement({ - id: params.statementId, - orgId: subscriberType === 'organization' ? organizationCtx?.organization?.id : undefined, - }), - ); + } = __internal_useStatementQuery({ + statementId: params.statementId ?? null, + for: requesterType, + }); if (isLoading) { return ( @@ -99,11 +90,11 @@ export const StatementPage = () => { status={statement.status} /> - {statement.groups.map(group => ( + {statement.groups.map((group: StatementGroup) => ( - {group.items.map(item => ( + {group.items.map((item: StatementItem) => ( { const clerk = useClerk(); + // TODO: Replace useSWR with the react-query equivalent. const { data: stripeClerkLibs } = useSWR( 'clerk-stripe-sdk', async () => { @@ -85,6 +86,7 @@ const usePaymentSourceUtils = (forResource: ForPayerType = 'user') => { const resource = forResource === 'organization' ? organization : user; const stripeClerkLibs = useStripeLibsContext(); + // TODO: Replace useSWRMutation with the react-query equivalent. const { data: initializedPaymentMethod, trigger: initializePaymentMethod } = useSWRMutation( { key: 'billing-payment-method-initialize', @@ -113,6 +115,7 @@ const usePaymentSourceUtils = (forResource: ForPayerType = 'user') => { const paymentMethodOrder = initializedPaymentMethod?.paymentMethodOrder; const stripePublishableKey = environment?.commerceSettings.billing.stripePublishableKey; + // TODO: Replace useSWR with the react-query equivalent. const { data: stripe } = useSWR( stripeClerkLibs && externalGatewayId && stripePublishableKey ? { key: 'stripe-sdk', externalGatewayId, stripePublishableKey } diff --git a/packages/shared/src/react/hooks/__tests__/wrapper.tsx b/packages/shared/src/react/hooks/__tests__/wrapper.tsx index 8ee95636f06..4ad4b326fc8 100644 --- a/packages/shared/src/react/hooks/__tests__/wrapper.tsx +++ b/packages/shared/src/react/hooks/__tests__/wrapper.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { SWRConfig } from 'swr'; export const wrapper = ({ children }: { children: React.ReactNode }) => ( + // TODO: Replace SWRConfig with the react-query equivalent. new Map(), diff --git a/packages/shared/src/react/hooks/createCacheKeys.ts b/packages/shared/src/react/hooks/createCacheKeys.ts index 73a485c3e25..f65bef7a98f 100644 --- a/packages/shared/src/react/hooks/createCacheKeys.ts +++ b/packages/shared/src/react/hooks/createCacheKeys.ts @@ -1,4 +1,5 @@ -import type { ResourceCacheStableKey } from '../stable-keys'; +import type { __internal_ResourceCacheStableKey, ResourceCacheStableKey } from '../stable-keys'; +import type { QueryKeyWithArgs } from './usePageOrInfinite.types'; /** * @internal @@ -8,7 +9,7 @@ export function createCacheKeys< T extends Record = Record, U extends Record | undefined = undefined, >(params: { - stablePrefix: ResourceCacheStableKey; + stablePrefix: ResourceCacheStableKey | __internal_ResourceCacheStableKey; authenticated: boolean; tracked: T; untracked: U extends { args: Params } ? U : never; @@ -20,3 +21,12 @@ export function createCacheKeys< authenticated: params.authenticated, }; } + +export function toSWRQuery }>(keys: T) { + const { queryKey } = keys; + return { + type: queryKey[0], + ...queryKey[2], + ...(queryKey[3] as { args: Record }).args, + }; +} diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index a3774cddeb3..956ef1db3c2 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -15,3 +15,6 @@ export { usePaymentMethods as __experimental_usePaymentMethods } from './usePaym export { usePlans as __experimental_usePlans } from './usePlans'; export { useSubscription as __experimental_useSubscription } from './useSubscription'; export { useCheckout as __experimental_useCheckout } from './useCheckout'; +export { __internal_useStatementQuery } from './useStatementQuery'; +export { __internal_usePlanDetailsQuery } from './usePlanDetailsQuery'; +export { __internal_usePaymentAttemptQuery } from './usePaymentAttemptQuery'; diff --git a/packages/shared/src/react/hooks/usePageOrInfinite.types.ts b/packages/shared/src/react/hooks/usePageOrInfinite.types.ts index 5fe1d831456..626284bdde7 100644 --- a/packages/shared/src/react/hooks/usePageOrInfinite.types.ts +++ b/packages/shared/src/react/hooks/usePageOrInfinite.types.ts @@ -10,7 +10,7 @@ type QueryArgs = Readonly<{ args: Params; }>; -type QueryKeyWithArgs = readonly [ +export type QueryKeyWithArgs = readonly [ string, boolean, Record, @@ -27,6 +27,8 @@ export type UsePagesOrInfiniteSignature = < queryKey: QueryKeyWithArgs; invalidationKey: InvalidationQueryKey; stableKey: string; + authenticated: boolean; + // toSWRQuery: () => Record; }, TConfig extends Config = Config, >(params: { diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx index 1bee4557d16..d81d5bbbf76 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.swr.tsx @@ -4,6 +4,7 @@ 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'; @@ -49,9 +50,7 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { const isSignedIn = config.isSignedIn; const pagesCacheKey = { - type: keys.queryKey[0], - ...keys.queryKey[2], - ...keys.queryKey[3].args, + ...toSWRQuery(keys), initialPage: paginatedPage, pageSize: pageSizeRef.current, }; @@ -136,9 +135,7 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { } return { - type: keys.queryKey[0], - ...keys.queryKey[2], - ...keys.queryKey[3].args, + ...toSWRQuery(keys), initialPage: initialPageRef.current + pageIndex, pageSize: pageSizeRef.current, }; 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..8c4f5c22654 --- /dev/null +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx @@ -0,0 +1,68 @@ +import { eventMethodCalled } from '../../telemetry/events'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { + useAssertWrappedByClerkProvider, + useClerkInstanceContext, + useOrganizationContext, + useUserContext, +} from '../contexts'; +import { usePaymentAttemptQueryCacheKeys } from './usePaymentAttemptQuery.shared'; +import type { PaymentAttemptQueryResult, UsePaymentAttemptQueryParams } from './usePaymentAttemptQuery.types'; + +const HOOK_NAME = 'usePaymentAttemptQuery'; + +/** + * @internal + */ +function KeepPreviousDataFn(previousData: Data): Data { + return previousData; +} + +/** + * This is the new implementation of usePaymentAttemptQuery using React Query. + * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. + * + * @internal + */ +export function __internal_usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAttemptQueryResult { + useAssertWrappedByClerkProvider(HOOK_NAME); + + const { paymentAttemptId, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); + + 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 query = useClerkQuery({ + queryKey, + queryFn: ({ queryKey }) => { + const args = queryKey[3].args; + return clerk.billing.getPaymentAttempt(args); + }, + enabled: queryEnabled, + placeholderData: keepPreviousData ? KeepPreviousDataFn : undefined, + staleTime: 1_000 * 60, + }); + + 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, + }; +} diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.shared.ts b/packages/shared/src/react/hooks/usePaymentAttemptQuery.shared.ts new file mode 100644 index 00000000000..160a08e006b --- /dev/null +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.shared.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; + +import type { ForPayerType } from '@/types/billing'; + +import { INTERNAL_STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +export function usePaymentAttemptQueryCacheKeys(params: { + paymentAttemptId: string; + userId: string | null; + orgId: string | null; + for?: ForPayerType; +}) { + const { paymentAttemptId, userId, orgId, for: forType } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.PAYMENT_ATTEMPT_KEY, + authenticated: true, + tracked: { + paymentAttemptId, + forType, + userId, + orgId, + }, + untracked: { + args: { + id: paymentAttemptId ?? undefined, + orgId: orgId ?? undefined, + }, + }, + }); + }, [paymentAttemptId, forType, userId, orgId]); +} 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..c9149a1b932 --- /dev/null +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx @@ -0,0 +1,60 @@ +import { eventMethodCalled } from '../../telemetry/events'; +import { useSWR } from '../clerk-swr'; +import { + useAssertWrappedByClerkProvider, + useClerkInstanceContext, + useOrganizationContext, + useUserContext, +} from '../contexts'; +import { usePaymentAttemptQueryCacheKeys } from './usePaymentAttemptQuery.shared'; +import type { PaymentAttemptQueryResult, UsePaymentAttemptQueryParams } from './usePaymentAttemptQuery.types'; + +const HOOK_NAME = 'usePaymentAttemptQuery'; + +/** + * 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 { + useAssertWrappedByClerkProvider(HOOK_NAME); + + const { paymentAttemptId, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); + + 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, + isLoading: swr.isLoading, + isFetching: swr.isValidating, + }; +} diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.tsx new file mode 100644 index 00000000000..ffa7ea1dc6e --- /dev/null +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.tsx @@ -0,0 +1 @@ +export { __internal_usePaymentAttemptQuery } from 'virtual:data-hooks/usePaymentAttemptQuery'; diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.types.ts b/packages/shared/src/react/hooks/usePaymentAttemptQuery.types.ts new file mode 100644 index 00000000000..5e8fb16ad92 --- /dev/null +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.types.ts @@ -0,0 +1,52 @@ +import type { ClerkAPIResponseError } from '../../error'; +import type { BillingPaymentResource, ForPayerType } from '../../types'; + +/** + * @interface + */ +export type UsePaymentAttemptQueryParams = { + /** + * The payment attempt ID to fetch. + */ + paymentAttemptId: string; + /** + * Specifies whether to fetch the payment attempt for an organization or a user. + * + * @default 'user' + */ + for?: ForPayerType; + /** + * If true, the previous data will be kept in the cache until new data is fetched. + * + * @default false + */ + keepPreviousData?: boolean; + /** + * If `true`, a request will be triggered when the hook is mounted. + * + * @default true + */ + enabled?: boolean; +}; + +/** + * @interface + */ +export type PaymentAttemptQueryResult = { + /** + * The payment attempt object, `undefined` before the first fetch, or `null` if no payment attempt exists. + */ + data: BillingPaymentResource | undefined | null; + /** + * Any error that occurred during the data fetch, or `undefined` if no error occurred. + */ + error: ClerkAPIResponseError | null; + /** + * A boolean that indicates whether the initial data is still being fetched. + */ + isLoading: boolean; + /** + * A boolean that indicates whether any request is still in flight, including background updates. + */ + isFetching: boolean; +}; 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..7f55f1ca713 --- /dev/null +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx @@ -0,0 +1,60 @@ +import { eventMethodCalled } from '../../telemetry/events'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; +import { usePlanDetailsQueryCacheKeys } from './usePlanDetailsQuery.shared'; +import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePlanDetailsQuery.types'; + +const HOOK_NAME = 'usePlanDetailsQuery'; + +/** + * @internal + */ +function KeepPreviousDataFn(previousData: Data): Data { + return previousData; +} + +/** + * This is the new implementation of usePlanDetailsQuery using React Query. + * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. + * + * @internal + */ +export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams = {}): PlanDetailsQueryResult { + useAssertWrappedByClerkProvider(HOOK_NAME); + + const { planId, initialPlan = null, enabled = true, keepPreviousData = true } = params; + const clerk = useClerkInstanceContext(); + + clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); + + const targetPlanId = planId ?? initialPlan?.id ?? null; + + const { queryKey } = usePlanDetailsQueryCacheKeys({ planId: targetPlanId }); + + const queryEnabled = Boolean(targetPlanId) && enabled; + + 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: keepPreviousData ? KeepPreviousDataFn : undefined, + refetchOnWindowFocus: false, + retry: false, + staleTime: 1_000 * 60, + }); + + 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, + }; +} diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.shared.ts b/packages/shared/src/react/hooks/usePlanDetailsQuery.shared.ts new file mode 100644 index 00000000000..9a83f3bba5d --- /dev/null +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.shared.ts @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; + +import { INTERNAL_STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +export function usePlanDetailsQueryCacheKeys(params: { planId: string | null }) { + const { planId } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.BILLING_PLANS_KEY, + authenticated: false, + tracked: { + planId: planId ?? null, + }, + untracked: { + args: { + id: planId ?? undefined, + }, + }, + }); + }, [planId]); +} 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..6187dda7b03 --- /dev/null +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx @@ -0,0 +1,50 @@ +import { eventMethodCalled } from '../../telemetry/events'; +import { useSWR } from '../clerk-swr'; +import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; +import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePlanDetailsQuery.types'; +import { usePlanDetailsQueryCacheKeys } from './usePlanDetailsQuery.shared'; + +const HOOK_NAME = 'usePlanDetailsQuery'; + +/** + * This is the existing implementation of usePlanDetailsQuery using SWR. + * It is kept here for backwards compatibility until our next major version. + * + * @internal + */ +export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams = {}): PlanDetailsQueryResult { + useAssertWrappedByClerkProvider(HOOK_NAME); + + const { planId, initialPlan = null, enabled = true, keepPreviousData = true } = params; + const clerk = useClerkInstanceContext(); + + clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); + + 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, + isLoading: swr.isLoading, + isFetching: swr.isValidating, + }; +} diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.tsx new file mode 100644 index 00000000000..7fb85951400 --- /dev/null +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.tsx @@ -0,0 +1 @@ +export { __internal_usePlanDetailsQuery } from 'virtual:data-hooks/usePlanDetailsQuery'; diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.types.ts b/packages/shared/src/react/hooks/usePlanDetailsQuery.types.ts new file mode 100644 index 00000000000..9dfae918964 --- /dev/null +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.types.ts @@ -0,0 +1,50 @@ +import type { ClerkAPIResponseError } from '../../errors/clerkApiResponseError'; +import type { BillingPlanResource } from '../../types'; + +/** + * @interface + */ +export type UsePlanDetailsQueryParams = { + /** + * The plan ID to fetch. + */ + planId?: string | null; + /** + * Initial plan data to use before fetching. + */ + initialPlan?: BillingPlanResource | null; + /** + * If true, the previous data will be kept in the cache until new data is fetched. + * + * @default true + */ + keepPreviousData?: boolean; + /** + * If `true`, a request will be triggered when the hook is mounted. + * + * @default true + */ + enabled?: boolean; +}; + +/** + * @interface + */ +export type PlanDetailsQueryResult = { + /** + * The plan object, `undefined` before the first fetch, or `null` if no plan exists. + */ + data: BillingPlanResource | undefined | null; + /** + * Any error that occurred during the data fetch, or `undefined` if no error occurred. + */ + error: ClerkAPIResponseError | null; + /** + * A boolean that indicates whether the initial data is still being fetched. + */ + isLoading: boolean; + /** + * A boolean that indicates whether any request is still in flight, including background updates. + */ + isFetching: boolean; +}; 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..dafb1835e05 --- /dev/null +++ b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx @@ -0,0 +1,70 @@ +import { eventMethodCalled } from '../../telemetry/events'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { + useAssertWrappedByClerkProvider, + useClerkInstanceContext, + useOrganizationContext, + useUserContext, +} from '../contexts'; +import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; +import { useStatementQueryCacheKeys } from './useStatementQuery.shared'; + +const HOOK_NAME = 'useStatementQuery'; + +/** + * @internal + */ +function KeepPreviousDataFn(previousData: Data): Data { + return previousData; +} + +/** + * This is the new implementation of useStatementQuery using React Query. + * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. + * + * @internal + */ +export function __internal_useStatementQuery(params: UseStatementQueryParams = {}): StatementQueryResult { + useAssertWrappedByClerkProvider(HOOK_NAME); + + const { statementId = null, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); + + 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 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: keepPreviousData ? KeepPreviousDataFn : undefined, + staleTime: 1_000 * 60, + }); + + 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, + }; +} diff --git a/packages/shared/src/react/hooks/useStatementQuery.shared.ts b/packages/shared/src/react/hooks/useStatementQuery.shared.ts new file mode 100644 index 00000000000..b0cb8dc0b02 --- /dev/null +++ b/packages/shared/src/react/hooks/useStatementQuery.shared.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; + +import type { ForPayerType } from '@/types/billing'; + +import { INTERNAL_STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +export function useStatementQueryCacheKeys(params: { + statementId: string | null; + userId: string | null; + orgId: string | null; + for?: ForPayerType; +}) { + const { statementId, userId, orgId, for: forType } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.BILLING_STATEMENTS_KEY, + authenticated: true, + tracked: { + statementId, + forType, + userId, + orgId, + }, + untracked: { + args: { + id: statementId ?? undefined, + orgId: orgId ?? undefined, + }, + }, + }); + }, [statementId, forType, userId, orgId]); +} 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..f90e5aeb5e8 --- /dev/null +++ b/packages/shared/src/react/hooks/useStatementQuery.swr.tsx @@ -0,0 +1,62 @@ +import { eventMethodCalled } from '../../telemetry/events'; +import { useSWR } from '../clerk-swr'; +import { + useAssertWrappedByClerkProvider, + useClerkInstanceContext, + useOrganizationContext, + useUserContext, +} from '../contexts'; +import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; +import { useStatementQueryCacheKeys } from './useStatementQuery.shared'; + +const HOOK_NAME = 'useStatementQuery'; + +/** + * 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 { + useAssertWrappedByClerkProvider(HOOK_NAME); + + const { statementId = null, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); + + 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, + isLoading: swr.isLoading, + isFetching: swr.isValidating, + }; +} diff --git a/packages/shared/src/react/hooks/useStatementQuery.tsx b/packages/shared/src/react/hooks/useStatementQuery.tsx new file mode 100644 index 00000000000..0664eedaefa --- /dev/null +++ b/packages/shared/src/react/hooks/useStatementQuery.tsx @@ -0,0 +1 @@ +export { __internal_useStatementQuery } from 'virtual:data-hooks/useStatementQuery'; diff --git a/packages/shared/src/react/hooks/useStatementQuery.types.ts b/packages/shared/src/react/hooks/useStatementQuery.types.ts new file mode 100644 index 00000000000..fe7f6174aec --- /dev/null +++ b/packages/shared/src/react/hooks/useStatementQuery.types.ts @@ -0,0 +1,51 @@ +import type { BillingStatementResource, ClerkAPIResponseError, ForPayerType } from '../../types'; + +/** + * @interface + */ +export type UseStatementQueryParams = { + /** + * The statement ID to fetch. + */ + statementId?: string | null; + /** + * Specifies whether to fetch the statement for an organization or a user. + * + * @default 'user' + */ + for?: ForPayerType; + /** + * If true, the previous data will be kept in the cache until new data is fetched. + * + * @default false + */ + keepPreviousData?: boolean; + /** + * If `true`, a request will be triggered when the hook is mounted. + * + * @default true + */ + enabled?: boolean; +}; + +/** + * @interface + */ +export type StatementQueryResult = { + /** + * The statement object, `undefined` before the first fetch, or `null` if no statement exists. + */ + data: BillingStatementResource | undefined | null; + /** + * Any error that occurred during the data fetch, or `undefined` if no error occurred. + */ + error: ClerkAPIResponseError | null; + /** + * A boolean that indicates whether the initial data is still being fetched. + */ + isLoading: boolean; + /** + * A boolean that indicates whether any request is still in flight, including background updates. + */ + isFetching: boolean; +}; diff --git a/packages/shared/src/react/hooks/useSubscription.rq.tsx b/packages/shared/src/react/hooks/useSubscription.rq.tsx index 9efb114e394..63702ab9476 100644 --- a/packages/shared/src/react/hooks/useSubscription.rq.tsx +++ b/packages/shared/src/react/hooks/useSubscription.rq.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { eventMethodCalled } from '../../telemetry/events'; import type { EnvironmentResource } from '../../types'; @@ -10,7 +10,7 @@ import { useOrganizationContext, useUserContext, } from '../contexts'; -import { STABLE_KEYS } from '../stable-keys'; +import { useSubscriptionCacheKeys } from './useSubscription.shared'; import type { SubscriptionResult, UseSubscriptionParams } from './useSubscription.types'; const HOOK_NAME = 'useSubscription'; @@ -22,19 +22,6 @@ function KeepPreviousDataFn(previousData: Data): Data { return previousData; } -export const subscriptionQuery = , U extends Record>(params: { - trackedKeys: T; - untrackedKeys?: U; -}) => { - const stableKey = STABLE_KEYS.SUBSCRIPTION_KEY; - const { trackedKeys, untrackedKeys } = params; - return { - queryKey: [stableKey, trackedKeys, untrackedKeys] as const, - invalidationKey: [stableKey, trackedKeys] as const, - stableKey, - }; -}; - /** * 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`. @@ -61,21 +48,18 @@ export function useSubscription(params?: UseSubscriptionParams): SubscriptionRes const [queryClient] = useClerkQueryClient(); - const { queryKey, invalidationKey } = useMemo(() => { - return subscriptionQuery({ - trackedKeys: { - userId: user?.id, - args: { orgId: isOrganization ? organization?.id : undefined }, - }, - }); - }, [user?.id, isOrganization, organization?.id]); + const { queryKey, invalidationKey } = useSubscriptionCacheKeys({ + userId: user?.id, + orgId: organization?.id, + for: params?.for, + }); const queriesEnabled = Boolean(user?.id && billingEnabled) && (params?.enabled ?? true); const query = useClerkQuery({ queryKey, queryFn: ({ queryKey }) => { - const obj = queryKey[1] as { args: { orgId?: string } }; + const obj = queryKey[3]; return clerk.billing.getSubscription(obj.args); }, staleTime: 1_000 * 60, diff --git a/packages/shared/src/react/hooks/useSubscription.shared.ts b/packages/shared/src/react/hooks/useSubscription.shared.ts new file mode 100644 index 00000000000..b4d1b29d59f --- /dev/null +++ b/packages/shared/src/react/hooks/useSubscription.shared.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; + +import type { ForPayerType } from '@/types/billing'; + +import { STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +export function useSubscriptionCacheKeys(params: { + userId: string | undefined; + orgId: string | undefined; + for?: ForPayerType; +}) { + const { userId, orgId, for: forType } = params; + return useMemo(() => { + const isOrganization = forType === 'organization'; + + const safeOrgId = isOrganization ? orgId : undefined; + return createCacheKeys({ + stablePrefix: STABLE_KEYS.SUBSCRIPTION_KEY, + authenticated: true, + tracked: { + userId, + orgId: safeOrgId, + }, + untracked: { + args: { orgId: safeOrgId }, + }, + }); + }, [userId, orgId, forType]); +} diff --git a/packages/shared/src/react/hooks/useSubscription.swr.tsx b/packages/shared/src/react/hooks/useSubscription.swr.tsx index 5a08ddcfc37..c142800b5c0 100644 --- a/packages/shared/src/react/hooks/useSubscription.swr.tsx +++ b/packages/shared/src/react/hooks/useSubscription.swr.tsx @@ -9,7 +9,7 @@ import { useOrganizationContext, useUserContext, } from '../contexts'; -import { STABLE_KEYS } from '../stable-keys'; +import { useSubscriptionCacheKeys } from './useSubscription.shared'; import type { SubscriptionResult, UseSubscriptionParams } from './useSubscription.types'; const hookName = 'useSubscription'; @@ -38,16 +38,18 @@ export function useSubscription(params?: UseSubscriptionParams): SubscriptionRes : 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 - ? { - type: STABLE_KEYS.SUBSCRIPTION_KEY, - userId: user?.id, - args: { orgId: isOrganization ? organization?.id : undefined }, - } - : null, - ({ args, userId }) => { - if (userId) { + isEnabled ? { queryKey } : null, + ({ queryKey }) => { + const args = queryKey[3].args; + + if (queryKey[2].userId) { return clerk.billing.getSubscription(args); } return null; diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index c1f8f761236..894f1abb2f7 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -1,5 +1,7 @@ export * from './hooks'; +export type { UseSubscriptionParams } from './hooks/useSubscription.types'; + export { ClerkInstanceContext, ClientContext, diff --git a/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx b/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx index 63bb029b3ac..4c581300747 100644 --- a/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx +++ b/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx @@ -6,5 +6,6 @@ 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.swr.tsx b/packages/shared/src/react/providers/SWRConfigCompat.swr.tsx index 555d744474b..97d341456d1 100644 --- a/packages/shared/src/react/providers/SWRConfigCompat.swr.tsx +++ b/packages/shared/src/react/providers/SWRConfigCompat.swr.tsx @@ -5,5 +5,6 @@ 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/stable-keys.ts b/packages/shared/src/react/stable-keys.ts index bf1a5e73b63..ef597662235 100644 --- a/packages/shared/src/react/stable-keys.ts +++ b/packages/shared/src/react/stable-keys.ts @@ -51,3 +51,19 @@ export const STABLE_KEYS = { } as const; export type ResourceCacheStableKey = (typeof STABLE_KEYS)[keyof typeof STABLE_KEYS]; + +/** + * Internal stable keys for queries only used by our UI components. + * These keys are not used by the hooks themselves. + */ + +const PAYMENT_ATTEMPT_KEY = 'billing-payment-attempt'; +const BILLING_PLANS_KEY = 'billing-plan'; +const BILLING_STATEMENTS_KEY = 'billing-statement'; +export const INTERNAL_STABLE_KEYS = { + PAYMENT_ATTEMPT_KEY, + BILLING_PLANS_KEY, + BILLING_STATEMENTS_KEY, +} as const; + +export type __internal_ResourceCacheStableKey = (typeof INTERNAL_STABLE_KEYS)[keyof typeof INTERNAL_STABLE_KEYS]; diff --git a/packages/shared/src/types/virtual-data-hooks.d.ts b/packages/shared/src/types/virtual-data-hooks.d.ts index 141a75a01ce..f46f2cfb657 100644 --- a/packages/shared/src/types/virtual-data-hooks.d.ts +++ b/packages/shared/src/types/virtual-data-hooks.d.ts @@ -3,6 +3,9 @@ declare module 'virtual:data-hooks/*' { export const SWRConfigCompat: any; export const useSubscription: any; export const usePagesOrInfinite: 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 6a0663ee028..0af8624f0b0 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -25,7 +25,10 @@ "paths": { "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/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"] } }, "exclude": ["node_modules"], From 5855d817d920d3150eef9be8b44772b4bd6a0ff5 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 20 Nov 2025 19:45:31 +0200 Subject: [PATCH 02/18] fix unit tests --- packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx | 4 +--- .../shared/src/react/hooks/usePaymentAttemptQuery.shared.ts | 3 +-- .../shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx | 2 +- packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx | 4 +--- packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx | 2 +- packages/shared/src/react/hooks/useStatementQuery.rq.tsx | 4 +--- packages/shared/src/react/hooks/useStatementQuery.shared.ts | 3 +-- packages/shared/src/react/hooks/useStatementQuery.swr.tsx | 2 +- packages/shared/src/react/hooks/useSubscription.shared.ts | 3 +-- 9 files changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx index 8c4f5c22654..445927fe290 100644 --- a/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx @@ -59,9 +59,7 @@ export function __internal_usePaymentAttemptQuery(params: UsePaymentAttemptQuery 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, + error: (query.error ?? null) as PaymentAttemptQueryResult['error'], isLoading: query.isLoading, isFetching: query.isFetching, }; diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.shared.ts b/packages/shared/src/react/hooks/usePaymentAttemptQuery.shared.ts index 160a08e006b..088b28855fc 100644 --- a/packages/shared/src/react/hooks/usePaymentAttemptQuery.shared.ts +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.shared.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react'; -import type { ForPayerType } from '@/types/billing'; - +import type { ForPayerType } from '../../types'; import { INTERNAL_STABLE_KEYS } from '../stable-keys'; import { createCacheKeys } from './createCacheKeys'; diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx index c9149a1b932..1fcc2fa2881 100644 --- a/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx @@ -53,7 +53,7 @@ export function __internal_usePaymentAttemptQuery(params: UsePaymentAttemptQuery return { data: swr.data, - error: swr.error, + error: (swr.error ?? null) as PaymentAttemptQueryResult['error'], isLoading: swr.isLoading, isFetching: swr.isValidating, }; diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx index 7f55f1ca713..aaa18c30239 100644 --- a/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx @@ -51,9 +51,7 @@ export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams 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, + 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 index 6187dda7b03..aef5d0ba078 100644 --- a/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx @@ -43,7 +43,7 @@ export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams return { data: swr.data, - error: swr.error, + error: (swr.error ?? null) as PlanDetailsQueryResult['error'], isLoading: swr.isLoading, isFetching: swr.isValidating, }; diff --git a/packages/shared/src/react/hooks/useStatementQuery.rq.tsx b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx index dafb1835e05..5d6fb0ef301 100644 --- a/packages/shared/src/react/hooks/useStatementQuery.rq.tsx +++ b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx @@ -61,9 +61,7 @@ export function __internal_useStatementQuery(params: UseStatementQueryParams = { 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, + error: (query.error ?? null) as StatementQueryResult['error'], isLoading: query.isLoading, isFetching: query.isFetching, }; diff --git a/packages/shared/src/react/hooks/useStatementQuery.shared.ts b/packages/shared/src/react/hooks/useStatementQuery.shared.ts index b0cb8dc0b02..1aa6d052261 100644 --- a/packages/shared/src/react/hooks/useStatementQuery.shared.ts +++ b/packages/shared/src/react/hooks/useStatementQuery.shared.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react'; -import type { ForPayerType } from '@/types/billing'; - +import type { ForPayerType } from '../../types'; import { INTERNAL_STABLE_KEYS } from '../stable-keys'; import { createCacheKeys } from './createCacheKeys'; diff --git a/packages/shared/src/react/hooks/useStatementQuery.swr.tsx b/packages/shared/src/react/hooks/useStatementQuery.swr.tsx index f90e5aeb5e8..ad979b8fce7 100644 --- a/packages/shared/src/react/hooks/useStatementQuery.swr.tsx +++ b/packages/shared/src/react/hooks/useStatementQuery.swr.tsx @@ -55,7 +55,7 @@ export function __internal_useStatementQuery(params: UseStatementQueryParams = { return { data: swr.data, - error: swr.error, + error: (swr.error ?? null) as StatementQueryResult['error'], isLoading: swr.isLoading, isFetching: swr.isValidating, }; diff --git a/packages/shared/src/react/hooks/useSubscription.shared.ts b/packages/shared/src/react/hooks/useSubscription.shared.ts index b4d1b29d59f..c5655ebdcfd 100644 --- a/packages/shared/src/react/hooks/useSubscription.shared.ts +++ b/packages/shared/src/react/hooks/useSubscription.shared.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react'; -import type { ForPayerType } from '@/types/billing'; - +import type { ForPayerType } from '../../types'; import { STABLE_KEYS } from '../stable-keys'; import { createCacheKeys } from './createCacheKeys'; From c10e94da33ebb9fbc9b4ba6a092e3db857e73fa2 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 20 Nov 2025 20:34:39 +0200 Subject: [PATCH 03/18] clean up --- .../PaymentAttempts/PaymentAttemptPage.tsx | 3 ++- .../src/ui/components/Plans/PlanDetails.tsx | 4 +--- .../ui/components/Statements/StatementPage.tsx | 11 ++++------- .../src/ui/hooks/usePaymentAttemptQuery.ts | 0 .../clerk-js/src/ui/hooks/usePlanDetailsQuery.ts | 0 .../clerk-js/src/ui/hooks/useStatementQuery.ts | 0 .../src/react/clerk-rq/keep-previous-data.ts | 11 +++++++++++ .../src/react/hooks/usePageOrInfinite.types.ts | 1 - .../src/react/hooks/usePagesOrInfinite.rq.tsx | 10 ++-------- .../react/hooks/usePaymentAttemptQuery.rq.tsx | 14 +++++--------- .../src/react/hooks/usePlanDetailsQuery.rq.tsx | 10 ++-------- .../src/react/hooks/usePlanDetailsQuery.swr.tsx | 6 ++++-- .../src/react/hooks/useStatementQuery.rq.tsx | 16 ++++++---------- .../src/react/hooks/useSubscription.rq.tsx | 10 ++-------- 14 files changed, 39 insertions(+), 57 deletions(-) delete mode 100644 packages/clerk-js/src/ui/hooks/usePaymentAttemptQuery.ts delete mode 100644 packages/clerk-js/src/ui/hooks/usePlanDetailsQuery.ts delete mode 100644 packages/clerk-js/src/ui/hooks/useStatementQuery.ts create mode 100644 packages/shared/src/react/clerk-rq/keep-previous-data.ts diff --git a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx index 456ce1d14fc..ca4671fd3ac 100644 --- a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -37,8 +37,9 @@ export const PaymentAttemptPage = () => { isLoading, error, } = __internal_usePaymentAttemptQuery({ - paymentAttemptId: params.paymentAttemptId ?? null, + paymentAttemptId: params.paymentAttemptId, for: requesterType, + enabled: Boolean(params.paymentAttemptId), }); const subscriptionItem = paymentAttempt?.subscriptionItem; diff --git a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx index f9075c3de92..ba5bde2d969 100644 --- a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx @@ -28,8 +28,6 @@ import { useLocalizations, } from '../../customizables'; -type PlanFeature = BillingPlanResource['features'][number]; - export const PlanDetails = (props: __internal_PlanDetailsProps) => { return ( @@ -155,7 +153,7 @@ const PlanDetailsInternal = ({ margin: 0, })} > - {features.map((feature: PlanFeature) => ( + {features.map(feature => ( { const { params, navigate } = useRouter(); const subscriberType = useSubscriberTypeContext(); @@ -35,8 +31,9 @@ export const StatementPage = () => { isLoading, error, } = __internal_useStatementQuery({ - statementId: params.statementId ?? null, + statementId: params.statementId, for: requesterType, + enabled: Boolean(params.statementId), }); if (isLoading) { @@ -90,11 +87,11 @@ export const StatementPage = () => { status={statement.status} /> - {statement.groups.map((group: StatementGroup) => ( + {statement.groups.map(group => ( - {group.items.map((item: StatementItem) => ( + {group.items.map(item => ( (previousData: Data): Data { + return previousData; + }; + } + return undefined; +} diff --git a/packages/shared/src/react/hooks/usePageOrInfinite.types.ts b/packages/shared/src/react/hooks/usePageOrInfinite.types.ts index 626284bdde7..2f4270262f4 100644 --- a/packages/shared/src/react/hooks/usePageOrInfinite.types.ts +++ b/packages/shared/src/react/hooks/usePageOrInfinite.types.ts @@ -28,7 +28,6 @@ export type UsePagesOrInfiniteSignature = < invalidationKey: InvalidationQueryKey; stableKey: string; authenticated: boolean; - // toSWRQuery: () => Record; }, TConfig extends Config = Config, >(params: { diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx index ee24a146c09..bf9fba8e0f9 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, 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'; @@ -11,13 +12,6 @@ import type { UsePagesOrInfiniteSignature } from './usePageOrInfinite.types'; import { useWithSafeValues } from './usePagesOrInfinite.shared'; import { usePreviousValue } from './usePreviousValue'; -/** - * @internal - */ -function KeepPreviousDataFn(previousData: Data): Data { - return previousData; -} - export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { const { fetcher, config, keys } = params; @@ -77,7 +71,7 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { staleTime: 60_000, enabled: queriesEnabled && !triggerInfinite, // Use placeholderData to keep previous data while fetching new page - placeholderData: keepPreviousData ? KeepPreviousDataFn : undefined, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), }); // Infinite mode: accumulate pages diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx index 445927fe290..6f8bc7e2c17 100644 --- a/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx @@ -1,4 +1,5 @@ import { eventMethodCalled } from '../../telemetry/events'; +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; import { useClerkQuery } from '../clerk-rq/useQuery'; import { useAssertWrappedByClerkProvider, @@ -11,20 +12,13 @@ import type { PaymentAttemptQueryResult, UsePaymentAttemptQueryParams } from './ const HOOK_NAME = 'usePaymentAttemptQuery'; -/** - * @internal - */ -function KeepPreviousDataFn(previousData: Data): Data { - return previousData; -} - /** * This is the new implementation of usePaymentAttemptQuery using React Query. * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. * * @internal */ -export function __internal_usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAttemptQueryResult { +function usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAttemptQueryResult { useAssertWrappedByClerkProvider(HOOK_NAME); const { paymentAttemptId, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; @@ -53,7 +47,7 @@ export function __internal_usePaymentAttemptQuery(params: UsePaymentAttemptQuery return clerk.billing.getPaymentAttempt(args); }, enabled: queryEnabled, - placeholderData: keepPreviousData ? KeepPreviousDataFn : undefined, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), staleTime: 1_000 * 60, }); @@ -64,3 +58,5 @@ export function __internal_usePaymentAttemptQuery(params: UsePaymentAttemptQuery isFetching: query.isFetching, }; } + +export { usePaymentAttemptQuery as __internal_usePaymentAttemptQuery }; diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx index aaa18c30239..86ae38632a5 100644 --- a/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx @@ -1,4 +1,5 @@ import { eventMethodCalled } from '../../telemetry/events'; +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; import { useClerkQuery } from '../clerk-rq/useQuery'; import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; import { usePlanDetailsQueryCacheKeys } from './usePlanDetailsQuery.shared'; @@ -6,13 +7,6 @@ import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePla const HOOK_NAME = 'usePlanDetailsQuery'; -/** - * @internal - */ -function KeepPreviousDataFn(previousData: Data): Data { - return previousData; -} - /** * This is the new implementation of usePlanDetailsQuery using React Query. * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. @@ -43,7 +37,7 @@ export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams }, enabled: queryEnabled, initialData: initialPlan ?? undefined, - placeholderData: keepPreviousData ? KeepPreviousDataFn : undefined, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), refetchOnWindowFocus: false, retry: false, staleTime: 1_000 * 60, diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx index aef5d0ba078..37c5337b658 100644 --- a/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx @@ -1,8 +1,8 @@ import { eventMethodCalled } from '../../telemetry/events'; import { useSWR } from '../clerk-swr'; import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; -import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePlanDetailsQuery.types'; import { usePlanDetailsQueryCacheKeys } from './usePlanDetailsQuery.shared'; +import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePlanDetailsQuery.types'; const HOOK_NAME = 'usePlanDetailsQuery'; @@ -12,7 +12,7 @@ const HOOK_NAME = 'usePlanDetailsQuery'; * * @internal */ -export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams = {}): PlanDetailsQueryResult { +function usePlanDetailsQuery(params: UsePlanDetailsQueryParams = {}): PlanDetailsQueryResult { useAssertWrappedByClerkProvider(HOOK_NAME); const { planId, initialPlan = null, enabled = true, keepPreviousData = true } = params; @@ -48,3 +48,5 @@ export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams isFetching: swr.isValidating, }; } + +export { usePlanDetailsQuery as __internal_usePlanDetailsQuery }; diff --git a/packages/shared/src/react/hooks/useStatementQuery.rq.tsx b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx index 5d6fb0ef301..6b9bbf4e037 100644 --- a/packages/shared/src/react/hooks/useStatementQuery.rq.tsx +++ b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx @@ -1,4 +1,5 @@ import { eventMethodCalled } from '../../telemetry/events'; +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; import { useClerkQuery } from '../clerk-rq/useQuery'; import { useAssertWrappedByClerkProvider, @@ -6,25 +7,18 @@ import { useOrganizationContext, useUserContext, } from '../contexts'; -import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; import { useStatementQueryCacheKeys } from './useStatementQuery.shared'; +import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; const HOOK_NAME = 'useStatementQuery'; -/** - * @internal - */ -function KeepPreviousDataFn(previousData: Data): Data { - return previousData; -} - /** * This is the new implementation of useStatementQuery using React Query. * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. * * @internal */ -export function __internal_useStatementQuery(params: UseStatementQueryParams = {}): StatementQueryResult { +function useStatementQuery(params: UseStatementQueryParams = {}): StatementQueryResult { useAssertWrappedByClerkProvider(HOOK_NAME); const { statementId = null, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; @@ -55,7 +49,7 @@ export function __internal_useStatementQuery(params: UseStatementQueryParams = { return clerk.billing.getStatement({ id: statementId, orgId: organizationId ?? undefined }); }, enabled: queryEnabled, - placeholderData: keepPreviousData ? KeepPreviousDataFn : undefined, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), staleTime: 1_000 * 60, }); @@ -66,3 +60,5 @@ export function __internal_useStatementQuery(params: UseStatementQueryParams = { isFetching: query.isFetching, }; } + +export { useStatementQuery as __internal_useStatementQuery }; diff --git a/packages/shared/src/react/hooks/useSubscription.rq.tsx b/packages/shared/src/react/hooks/useSubscription.rq.tsx index 63702ab9476..2f5f2accad7 100644 --- a/packages/shared/src/react/hooks/useSubscription.rq.tsx +++ b/packages/shared/src/react/hooks/useSubscription.rq.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { eventMethodCalled } from '../../telemetry/events'; import type { EnvironmentResource } 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 { @@ -15,13 +16,6 @@ import type { SubscriptionResult, UseSubscriptionParams } from './useSubscriptio const HOOK_NAME = 'useSubscription'; -/** - * @internal - */ -function KeepPreviousDataFn(previousData: Data): Data { - return previousData; -} - /** * 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`. @@ -64,7 +58,7 @@ export function useSubscription(params?: UseSubscriptionParams): SubscriptionRes }, staleTime: 1_000 * 60, enabled: queriesEnabled, - placeholderData: keepPreviousData && queriesEnabled ? KeepPreviousDataFn : undefined, + placeholderData: defineKeepPreviousDataFn(keepPreviousData && queriesEnabled), }); const revalidate = useCallback( From 5c4823d8597ce5fbbb56e790e003b306c78fbec2 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 20 Nov 2025 20:46:47 +0200 Subject: [PATCH 04/18] handle initData --- packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx index 86ae38632a5..7a72f4f9761 100644 --- a/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx @@ -38,9 +38,7 @@ export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams enabled: queryEnabled, initialData: initialPlan ?? undefined, placeholderData: defineKeepPreviousDataFn(keepPreviousData), - refetchOnWindowFocus: false, - retry: false, - staleTime: 1_000 * 60, + initialDataUpdatedAt: 0, }); return { From c258f7aead0def37fbe1252d25b16c716581f26f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 11:24:35 +0200 Subject: [PATCH 05/18] remove useSWRMutation from APIKeys.tsx --- .../src/ui/components/APIKeys/APIKeys.tsx | 27 +++++++++---------- .../components/APIKeys/CreateAPIKeyForm.tsx | 7 ++--- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx b/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx index 9a867294182..1c95a6288de 100644 --- a/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx +++ b/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx @@ -1,8 +1,7 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import { __experimental_useAPIKeys as useAPIKeys, useClerk, useOrganization, useUser } from '@clerk/shared/react'; -import type { CreateAPIKeyParams } from '@clerk/shared/types'; +import type { APIKeyResource } from '@clerk/shared/types'; import { lazy, useState } from 'react'; -import useSWRMutation from 'swr/mutation'; import { useProtect } from '@/ui/common'; import { useAPIKeysContext, withCoreUserGuard } from '@/ui/contexts'; @@ -94,12 +93,9 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr }; const card = useCardState(); const clerk = useClerk(); - // TODO: Replace useSWRMutation with the react-query equivalent. - const { - data: createdAPIKey, - trigger: createAPIKey, - isMutating, - } = useSWRMutation('api-keys-create', (_key, { arg }: { arg: CreateAPIKeyParams }) => clerk.apiKeys.create(arg)); + + const [apiKey, setAPIKey] = useState(null); + const { t } = useLocalizations(); const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false); const [selectedAPIKeyID, setSelectedAPIKeyID] = useState(''); @@ -108,19 +104,23 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr const handleCreateAPIKey = async (params: OnCreateParams) => { try { - await createAPIKey({ + card.setLoading(); + const apiKey = await clerk.apiKeys.create({ ...params, subject, }); invalidateAll(); card.setError(undefined); setIsCopyModalOpen(true); + setAPIKey(apiKey); } catch (err: any) { if (isClerkAPIResponseError(err)) { if (err.status === 409) { card.setError('API Key name already exists'); } } + } finally { + card.setIdle(); } }; @@ -182,10 +182,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr ({ paddingTop: t.space.$6, paddingBottom: t.space.$6 })}> - + @@ -194,8 +191,8 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr isOpen={isCopyModalOpen} onOpen={() => setIsCopyModalOpen(true)} onClose={() => setIsCopyModalOpen(false)} - apiKeyName={createdAPIKey?.name ?? ''} - apiKeySecret={createdAPIKey?.secret ?? ''} + apiKeyName={apiKey?.name || ''} + apiKeySecret={apiKey?.secret || ''} modalRoot={revokeModalRoot} /> diff --git a/packages/clerk-js/src/ui/components/APIKeys/CreateAPIKeyForm.tsx b/packages/clerk-js/src/ui/components/APIKeys/CreateAPIKeyForm.tsx index da603098143..4b2caa80c1c 100644 --- a/packages/clerk-js/src/ui/components/APIKeys/CreateAPIKeyForm.tsx +++ b/packages/clerk-js/src/ui/components/APIKeys/CreateAPIKeyForm.tsx @@ -3,6 +3,7 @@ import React, { useMemo, useRef, useState } from 'react'; import { useAPIKeysContext } from '@/ui/contexts'; import { Box, Col, descriptors, FormLabel, localizationKeys, Text, useLocalizations } from '@/ui/customizables'; import { useActionContext } from '@/ui/elements/Action/ActionRoot'; +import { useCardState } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { FormButtons } from '@/ui/elements/FormButtons'; import { FormContainer } from '@/ui/elements/FormContainer'; @@ -28,7 +29,6 @@ export type OnCreateParams = { interface CreateAPIKeyFormProps { onCreate: (params: OnCreateParams) => void; - isSubmitting: boolean; } const EXPIRATION_DURATIONS: Record, (date: Date) => void> = { @@ -117,10 +117,11 @@ const ExpirationSelector: React.FC = ({ selectedExpirat ); }; -export const CreateAPIKeyForm: React.FC = ({ onCreate, isSubmitting }) => { +export const CreateAPIKeyForm: React.FC = ({ onCreate }) => { const [selectedExpiration, setSelectedExpiration] = useState(null); const { close: closeCardFn } = useActionContext(); const { showDescription = false } = useAPIKeysContext(); + const card = useCardState(); const { t } = useLocalizations(); const nameField = useFormControl('name', '', { @@ -251,7 +252,7 @@ export const CreateAPIKeyForm: React.FC = ({ onCreate, is submitLabel={localizationKeys('apiKeys.formButtonPrimary__add')} isDisabled={!canSubmit} onReset={closeCardFn} - isLoading={isSubmitting} + isLoading={card.isLoading} elementDescriptor={descriptors.apiKeysCreateFormSubmitButton} /> From a9359ad5be586e1e75d4332decd0a43d06c62c29 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 12:09:04 +0200 Subject: [PATCH 06/18] rq compatibility for PaymentElement --- ...erce.test.tsx => payment-element.test.tsx} | 113 +++++++++++----- .../payment-element.tsx} | 122 ++++-------------- .../billing/useInitializePaymentMethod.rq.tsx | 76 +++++++++++ .../useInitializePaymentMethod.swr.tsx | 60 +++++++++ .../billing/useInitializePaymentMethod.tsx | 2 + .../react/billing/useStripeClerkLibs.rq.tsx | 38 ++++++ .../react/billing/useStripeClerkLibs.swr.tsx | 39 ++++++ .../src/react/billing/useStripeClerkLibs.tsx | 2 + .../src/react/billing/useStripeLoader.rq.tsx | 51 ++++++++ .../src/react/billing/useStripeLoader.swr.tsx | 46 +++++++ .../src/react/billing/useStripeLoader.tsx | 2 + packages/shared/src/react/hooks/index.ts | 7 + .../shared/src/react/hooks/useCheckout.ts | 5 +- packages/shared/src/react/index.ts | 2 +- packages/shared/tsconfig.json | 5 +- packages/shared/tsdown.config.mts | 8 +- packages/shared/vitest.config.mts | 16 +-- 17 files changed, 448 insertions(+), 146 deletions(-) rename packages/shared/src/react/__tests__/{commerce.test.tsx => payment-element.test.tsx} (65%) rename packages/shared/src/react/{commerce.tsx => billing/payment-element.tsx} (77%) create mode 100644 packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx create mode 100644 packages/shared/src/react/billing/useInitializePaymentMethod.swr.tsx create mode 100644 packages/shared/src/react/billing/useInitializePaymentMethod.tsx create mode 100644 packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx create mode 100644 packages/shared/src/react/billing/useStripeClerkLibs.swr.tsx create mode 100644 packages/shared/src/react/billing/useStripeClerkLibs.tsx create mode 100644 packages/shared/src/react/billing/useStripeLoader.rq.tsx create mode 100644 packages/shared/src/react/billing/useStripeLoader.swr.tsx create mode 100644 packages/shared/src/react/billing/useStripeLoader.tsx diff --git a/packages/shared/src/react/__tests__/commerce.test.tsx b/packages/shared/src/react/__tests__/payment-element.test.tsx similarity index 65% rename from packages/shared/src/react/__tests__/commerce.test.tsx rename to packages/shared/src/react/__tests__/payment-element.test.tsx index 53d7540af7e..67b2e7b8674 100644 --- a/packages/shared/src/react/__tests__/commerce.test.tsx +++ b/packages/shared/src/react/__tests__/payment-element.test.tsx @@ -2,8 +2,8 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { __experimental_PaymentElement, __experimental_PaymentElementProvider } from '../commerce'; -import { OptionsContext } from '../contexts'; +import { __experimental_PaymentElement, __experimental_PaymentElementProvider } from '../billing/payment-element'; +import { ClerkInstanceContext, OptionsContext, OrganizationProvider, UserContext } from '../contexts'; // Mock the Stripe components vi.mock('../stripe-react', () => ({ @@ -44,7 +44,7 @@ vi.mock('../hooks/useUser', () => ({ useUser: () => ({ user: { id: 'user_123', - initializePaymentSource: vi.fn().mockResolvedValue({ + initializePaymentMethod: vi.fn().mockResolvedValue({ externalGatewayId: 'acct_123', externalClientSecret: 'seti_123', paymentMethodOrder: ['card'], @@ -59,25 +59,35 @@ vi.mock('../hooks/useOrganization', () => ({ }), })); -vi.mock('swr', () => ({ - __esModule: true, - default: () => ({ data: { loadStripe: vi.fn().mockResolvedValue({}) } }), -})); +const mockInitializePaymentMethod = vi.fn().mockResolvedValue({ + externalGatewayId: 'acct_123', + externalClientSecret: 'seti_123', + paymentMethodOrder: ['card'], +}); -vi.mock('swr/mutation', () => ({ - __esModule: true, - default: () => ({ - data: { +vi.mock('../billing/useInitializePaymentMethod', () => ({ + __internal_useInitializePaymentMethod: vi.fn(() => ({ + initializedPaymentMethod: { externalGatewayId: 'acct_123', externalClientSecret: 'seti_123', paymentMethodOrder: ['card'], }, - trigger: vi.fn().mockResolvedValue({ - externalGatewayId: 'acct_123', - externalClientSecret: 'seti_123', - paymentMethodOrder: ['card'], - }), - }), + initializePaymentMethod: mockInitializePaymentMethod, + })), +})); + +const mockStripeLibs = { + loadStripe: vi.fn().mockResolvedValue({}), +}; + +vi.mock('../billing/useStripeClerkLibs', () => ({ + __internal_useStripeClerkLibs: vi.fn(() => mockStripeLibs), +})); + +const mockStripeInstance = {} as any; + +vi.mock('../billing/useStripeLoader', () => ({ + __internal_useStripeLoader: vi.fn(() => mockStripeInstance), })); describe('PaymentElement Localization', () => { @@ -158,6 +168,27 @@ describe('PaymentElement Localization', () => { }, }; + const mockClerk = { + __internal_loadStripeJs: vi.fn().mockResolvedValue(() => Promise.resolve({})), + __internal_getOption: mockGetOption, + __unstable__environment: { + commerceSettings: { + billing: { + stripePublishableKey: 'pk_test_123', + }, + }, + displayConfig: { + userProfileUrl: 'https://example.com/profile', + organizationProfileUrl: 'https://example.com/org-profile', + }, + }, + }; + + const mockUser = { + id: 'user_123', + initializePaymentMethod: mockInitializePaymentMethod, + }; + const renderWithLocale = (locale: string) => { // Mock the __internal_getOption to return the expected localization mockGetOption.mockImplementation(key => { @@ -172,11 +203,17 @@ describe('PaymentElement Localization', () => { }; return render( - - <__experimental_PaymentElementProvider checkout={mockCheckout}> - <__experimental_PaymentElement fallback={
Loading...
} /> - -
, + + + + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
+
+
+
, ); }; @@ -199,11 +236,17 @@ describe('PaymentElement Localization', () => { const options = {}; render( - - <__experimental_PaymentElementProvider checkout={mockCheckout}> - <__experimental_PaymentElement fallback={
Loading...
} /> - -
, + + + + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
+
+
+
, ); const elements = screen.getByTestId('stripe-elements'); @@ -235,11 +278,17 @@ describe('PaymentElement Localization', () => { }; const { unmount } = render( - - <__experimental_PaymentElementProvider checkout={mockCheckout}> - <__experimental_PaymentElement fallback={
Loading...
} /> - -
, + + + + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
+
+
+
, ); const elements = screen.getByTestId('stripe-elements'); diff --git a/packages/shared/src/react/commerce.tsx b/packages/shared/src/react/billing/payment-element.tsx similarity index 77% rename from packages/shared/src/react/commerce.tsx rename to packages/shared/src/react/billing/payment-element.tsx index 9725bb89907..dbfbac4dd89 100644 --- a/packages/shared/src/react/commerce.tsx +++ b/packages/shared/src/react/billing/payment-element.tsx @@ -1,18 +1,17 @@ -/* eslint-disable @typescript-eslint/consistent-type-imports */ import type { Stripe, StripeElements, StripeElementsOptions } from '@stripe/stripe-js'; -import React, { type PropsWithChildren, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; -import useSWR from 'swr'; -import useSWRMutation from 'swr/mutation'; - -import type { BillingCheckoutResource, EnvironmentResource, ForPayerType } from '../types'; -import { createContextAndHook } from './hooks/createContextAndHook'; -import type { useCheckout } from './hooks/useCheckout'; -import { useClerk } from './hooks/useClerk'; -import { useOrganization } from './hooks/useOrganization'; -import { useUser } from './hooks/useUser'; -import { Elements, PaymentElement as StripePaymentElement, useElements, useStripe } from './stripe-react'; - -type LoadStripeFn = typeof import('@stripe/stripe-js').loadStripe; +import React, { type PropsWithChildren, type ReactNode, useCallback, useMemo, useState } from 'react'; + +import type { BillingCheckoutResource, EnvironmentResource, ForPayerType } from '../../types'; +import { createContextAndHook } from '../hooks/createContextAndHook'; +import type { useCheckout } from '../hooks/useCheckout'; +import { useClerk } from '../hooks/useClerk'; +import { Elements, PaymentElement as StripePaymentElement, useElements, useStripe } from '../stripe-react'; +import { + __internal_useInitializePaymentMethod as useInitializePaymentMethod, + type UseInitializePaymentMethodResult, +} from './useInitializePaymentMethod'; +import { __internal_useStripeClerkLibs as useStripeClerkLibs } from './useStripeClerkLibs'; +import { __internal_useStripeLoader as useStripeLoader, type UseStripeLoaderResult } from './useStripeLoader'; type PaymentElementError = { gateway: 'stripe'; @@ -26,37 +25,6 @@ type PaymentElementError = { }; }; -const [StripeLibsContext, useStripeLibsContext] = createContextAndHook<{ - loadStripe: LoadStripeFn; -} | null>('StripeLibsContext'); - -const StripeLibsProvider = ({ children }: PropsWithChildren) => { - const clerk = useClerk(); - // TODO: Replace useSWR with the react-query equivalent. - const { data: stripeClerkLibs } = useSWR( - 'clerk-stripe-sdk', - async () => { - const loadStripe = (await clerk.__internal_loadStripeJs()) as LoadStripeFn; - return { loadStripe }; - }, - { - keepPreviousData: true, - revalidateOnFocus: false, - dedupingInterval: Infinity, - }, - ); - - return ( - - {children} - - ); -}; - const useInternalEnvironment = () => { const clerk = useClerk(); // @ts-expect-error `__unstable__environment` is not typed @@ -81,56 +49,22 @@ const useLocalization = () => { }; const usePaymentSourceUtils = (forResource: ForPayerType = 'user') => { - const { organization } = useOrganization(); - const { user } = useUser(); - const resource = forResource === 'organization' ? organization : user; - const stripeClerkLibs = useStripeLibsContext(); - - // TODO: Replace useSWRMutation with the react-query equivalent. - const { data: initializedPaymentMethod, trigger: initializePaymentMethod } = useSWRMutation( - { - key: 'billing-payment-method-initialize', - resourceId: resource?.id, - }, - () => { - return resource?.initializePaymentMethod({ - gateway: 'stripe', - }); - }, - ); - + const stripeClerkLibs = useStripeClerkLibs(); const environment = useInternalEnvironment(); - useEffect(() => { - if (!resource?.id) { - return; - } - initializePaymentMethod().catch(() => { - // ignore errors - }); - }, [resource?.id]); + const { initializedPaymentMethod, initializePaymentMethod }: UseInitializePaymentMethodResult = + useInitializePaymentMethod({ for: forResource }); + + const stripePublishableKey = environment?.commerceSettings.billing.stripePublishableKey ?? undefined; + + const stripe: UseStripeLoaderResult = useStripeLoader({ + stripeClerkLibs, + externalGatewayId: initializedPaymentMethod?.externalGatewayId, + stripePublishableKey, + }); - const externalGatewayId = initializedPaymentMethod?.externalGatewayId; const externalClientSecret = initializedPaymentMethod?.externalClientSecret; const paymentMethodOrder = initializedPaymentMethod?.paymentMethodOrder; - const stripePublishableKey = environment?.commerceSettings.billing.stripePublishableKey; - - // TODO: Replace useSWR with the react-query equivalent. - const { data: stripe } = 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, // 1 minute - }, - ); return { stripe, @@ -228,11 +162,9 @@ const PropsProvider = ({ children, ...props }: PropsWithChildren) => { return ( - - - {children} - - + + {children} + ); }; 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..9761c367222 --- /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'; + +type InitializePaymentMethodOptions = { + for?: ForPayerType; +}; + +export type UseInitializePaymentMethodResult = { + initializedPaymentMethod: BillingInitializedPaymentMethodResource | undefined; + initializePaymentMethod: () => Promise; +}; + +/** + * This is the new implementation of the payment method initializer using React Query. + * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. + * + * @internal + */ +function useInitializePaymentMethod(options?: InitializePaymentMethodOptions): UseInitializePaymentMethodResult { + const { for: forType = 'user' } = options ?? {}; + const { organization } = useOrganizationContext(); + const user = useUserContext(); + + const resource = forType === 'organization' ? organization : user; + + const queryKey = useMemo(() => { + return ['billing-payment-method-initialize', { resourceId: resource?.id, for: forType }] as const; + }, [resource?.id, forType]); + + const isEnabled = Boolean(resource?.id); + + 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 new file mode 100644 index 00000000000..1373b76c409 --- /dev/null +++ b/packages/shared/src/react/billing/useInitializePaymentMethod.tsx @@ -0,0 +1,2 @@ +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..8a1d45f80a7 --- /dev/null +++ b/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx @@ -0,0 +1,38 @@ +import type { loadStripe } from '@stripe/stripe-js'; + +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useClerk } from '../hooks/useClerk'; + +type LoadStripeFn = typeof loadStripe; + +type StripeClerkLibs = { + loadStripe: LoadStripeFn; +}; + +export type UseStripeClerkLibsResult = StripeClerkLibs | null; + +/** + * This is the new implementation of the Stripe libraries loader using React Query. + * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. + * + * @internal + */ +function useStripeClerkLibs(): UseStripeClerkLibsResult { + const clerk = useClerk(); + + const query = useClerkQuery({ + queryKey: ['clerk-stripe-sdk'], + queryFn: async () => { + const loadStripe = (await clerk.__internal_loadStripeJs()) as LoadStripeFn; + return { loadStripe }; + }, + 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 new file mode 100644 index 00000000000..3a55aaca025 --- /dev/null +++ b/packages/shared/src/react/billing/useStripeClerkLibs.tsx @@ -0,0 +1,2 @@ +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..cb437c0b2ec --- /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 type { UseStripeClerkLibsResult } from './useStripeClerkLibs'; + +type StripeLoaderOptions = { + stripeClerkLibs: UseStripeClerkLibsResult; + externalGatewayId?: string; + stripePublishableKey?: string; +}; + +export type UseStripeLoaderResult = Stripe | null | undefined; + +/** + * This is the new implementation of the Stripe instance loader using React Query. + * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. + * + * @internal + */ +function useStripeLoader(options: StripeLoaderOptions): UseStripeLoaderResult { + const { stripeClerkLibs, externalGatewayId, stripePublishableKey } = options; + + const queryKey = useMemo(() => { + return ['stripe-sdk', { externalGatewayId, stripePublishableKey }] as const; + }, [externalGatewayId, stripePublishableKey]); + + const isEnabled = Boolean(stripeClerkLibs && externalGatewayId && stripePublishableKey); + + 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 new file mode 100644 index 00000000000..689fed791c4 --- /dev/null +++ b/packages/shared/src/react/billing/useStripeLoader.tsx @@ -0,0 +1,2 @@ +export type { UseStripeLoaderResult } from 'virtual:data-hooks/useStripeLoader'; +export { __internal_useStripeLoader } from 'virtual:data-hooks/useStripeLoader'; diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index 956ef1db3c2..8260af844ec 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -15,6 +15,13 @@ export { usePaymentMethods as __experimental_usePaymentMethods } from './usePaym export { usePlans as __experimental_usePlans } from './usePlans'; export { useSubscription as __experimental_useSubscription } from './useSubscription'; export { useCheckout as __experimental_useCheckout } from './useCheckout'; + +/** + * Internal hooks to be consumed only by `@clerk/clerk-js`. + * These are not considered part of the public API and their query keys can change without notice. + * + * These exist here in order to keep RQ and SWR implementations in a centralized place. + */ export { __internal_useStatementQuery } from './useStatementQuery'; export { __internal_usePlanDetailsQuery } from './usePlanDetailsQuery'; export { __internal_usePaymentAttemptQuery } from './usePaymentAttemptQuery'; diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 79b97cc63fa..146727ebc1d 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -8,9 +8,8 @@ import type { SetActiveNavigate, } from '../../types'; import type { __experimental_CheckoutProvider } from '../contexts'; -import { useCheckoutContext } from '../contexts'; +import { useCheckoutContext, useOrganizationContext } from '../contexts'; import { useClerk } from './useClerk'; -import { useOrganization } from './useOrganization'; import { useUser } from './useUser'; /** @@ -123,7 +122,7 @@ export const useCheckout = (options?: UseCheckoutParams): __experimental_UseChec const { for: forOrganization, planId, planPeriod } = options || contextOptions; const clerk = useClerk(); - const { organization } = useOrganization(); + const { organization } = useOrganizationContext(); const { isLoaded, user } = useUser(); if (!isLoaded) { diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index 894f1abb2f7..b05c94db6bd 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -19,4 +19,4 @@ export { __experimental_CheckoutProvider, } from './contexts'; -export * from './commerce'; +export * from './billing/payment-element'; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 0af8624f0b0..6d67b1b0d82 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -28,7 +28,10 @@ "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/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"], diff --git a/packages/shared/tsdown.config.mts b/packages/shared/tsdown.config.mts index 499714fd139..1d0104ac052 100644 --- a/packages/shared/tsdown.config.mts +++ b/packages/shared/tsdown.config.mts @@ -64,9 +64,11 @@ const HookAliasPlugin = () => { const chosenRQ = rqHooks.has(name) || useRQ; const impl = chosenRQ ? `${name}.rq.tsx` : `${name}.swr.tsx`; - const candidates = name.toLowerCase().includes('provider') - ? [path.join(baseDir, 'src', 'react', 'providers', impl), path.join(baseDir, 'src', 'react', 'hooks', impl)] - : [path.join(baseDir, 'src', 'react', 'hooks', impl), path.join(baseDir, 'src', 'react', 'providers', impl)]; + 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)) { diff --git a/packages/shared/vitest.config.mts b/packages/shared/vitest.config.mts index abfa8ee1250..0e79115c153 100644 --- a/packages/shared/vitest.config.mts +++ b/packages/shared/vitest.config.mts @@ -21,17 +21,11 @@ function HookAliasPlugin() { const candidates: string[] = []; for (const base of baseDirs) { - if (name.toLowerCase().includes('provider')) { - candidates.push( - path.join(base, 'src', 'react', 'providers', impl), - path.join(base, 'src', 'react', 'hooks', impl), - ); - } else { - candidates.push( - path.join(base, 'src', 'react', 'hooks', impl), - path.join(base, 'src', 'react', 'providers', impl), - ); - } + 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) { From 7f118ff6bd16118c17e16735db2781c1ec9d199e Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 12:31:09 +0200 Subject: [PATCH 07/18] run rq e2es for machine --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 557276e735e..608ed268986 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -310,7 +310,6 @@ jobs: 'vue', 'nuxt', 'react-router', - 'machine', 'custom', ] test-project: ["chrome"] @@ -321,6 +320,12 @@ jobs: - test-name: 'billing' test-project: 'chrome' clerk-use-rq: 'true' + - test-name: 'machine' + test-project: 'chrome' + clerk-use-rq: 'false' + - test-name: 'machine' + test-project: 'chrome' + clerk-use-rq: 'true' - test-name: 'nextjs' test-project: 'chrome' next-version: '14' From 86373fcd92c0c8ffa252f4e13985e8beebdffd85 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 13:14:13 +0200 Subject: [PATCH 08/18] compatibility layer for revalidation of useAPIKeys --- packages/clerk-js/package.json | 3 +- .../src/ui/components/APIKeys/APIKeys.tsx | 4 +- .../src/ui/components/APIKeys/utils.ts | 30 +--- .../shared/src/react/hooks/useAPIKeys.rq.tsx | 118 +++++++++++++++ .../shared/src/react/hooks/useAPIKeys.swr.tsx | 139 ++++++++++++++++++ packages/shared/src/react/hooks/useAPIKeys.ts | 120 +-------------- .../shared/src/types/virtual-data-hooks.d.ts | 1 + packages/shared/tsconfig.json | 1 + pnpm-lock.yaml | 5 +- 9 files changed, 267 insertions(+), 154 deletions(-) create mode 100644 packages/shared/src/react/hooks/useAPIKeys.rq.tsx create mode 100644 packages/shared/src/react/hooks/useAPIKeys.swr.tsx diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index fb10c0a2158..69dd4f94243 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -82,8 +82,7 @@ "dequal": "2.0.3", "input-otp": "1.4.2", "qrcode.react": "4.2.0", - "regenerator-runtime": "0.14.1", - "swr": "2.3.4" + "regenerator-runtime": "0.14.1" }, "devDependencies": { "@clerk/testing": "workspace:^", diff --git a/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx b/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx index 1c95a6288de..f60898e19d2 100644 --- a/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx +++ b/packages/clerk-js/src/ui/components/APIKeys/APIKeys.tsx @@ -68,6 +68,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr fetchPage, pageCount, count: itemCount, + revalidate: invalidateAll, } = useAPIKeys({ subject, pageSize: perPage ?? API_KEYS_PAGE_SIZE, @@ -76,12 +77,11 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr enabled: isOrg ? canReadAPIKeys : true, }); - const { invalidateAll } = useAPIKeysPagination({ + useAPIKeysPagination({ query, page, pageCount, isFetching, - subject, fetchPage, }); diff --git a/packages/clerk-js/src/ui/components/APIKeys/utils.ts b/packages/clerk-js/src/ui/components/APIKeys/utils.ts index 5eb2657c558..9cf18c59b69 100644 --- a/packages/clerk-js/src/ui/components/APIKeys/utils.ts +++ b/packages/clerk-js/src/ui/components/APIKeys/utils.ts @@ -1,12 +1,10 @@ -import { useCallback, useEffect, useRef } from 'react'; -import { useSWRConfig } from 'swr'; +import { useEffect, useRef } from 'react'; type UseAPIKeysPaginationParams = { query: string; page: number; pageCount: number; isFetching: boolean; - subject: string; fetchPage: (page: number) => void; }; @@ -16,27 +14,7 @@ type UseAPIKeysPaginationParams = { * - Adjusts page when current page exceeds available pages (e.g., after deletion) * - Provides cache invalidation function for mutations */ -export const useAPIKeysPagination = ({ - query, - page, - pageCount, - isFetching, - subject, - fetchPage, -}: UseAPIKeysPaginationParams) => { - const { mutate } = useSWRConfig(); - - // Invalidate all cache entries for this user or organization - const invalidateAll = useCallback(() => { - void 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 === subject; - }); - }, [mutate, subject]); - +export const useAPIKeysPagination = ({ query, page, pageCount, isFetching, fetchPage }: UseAPIKeysPaginationParams) => { // Reset to first page when query changes const previousQueryRef = useRef(query); useEffect(() => { @@ -53,8 +31,4 @@ export const useAPIKeysPagination = ({ fetchPage(Math.max(1, pageCount)); } }, [pageCount, page, isFetching, fetchPage]); - - return { - invalidateAll, - }; }; diff --git a/packages/shared/src/react/hooks/useAPIKeys.rq.tsx b/packages/shared/src/react/hooks/useAPIKeys.rq.tsx new file mode 100644 index 00000000000..d6fc14fdebc --- /dev/null +++ b/packages/shared/src/react/hooks/useAPIKeys.rq.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { eventMethodCalled } from '../../telemetry/events/method-called'; +import type { APIKeyResource, GetAPIKeysParams } from '../../types'; +import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; +import { STABLE_KEYS } from '../stable-keys'; +import type { PaginatedHookConfig, PaginatedResources } from '../types'; +import { createCacheKeys } from './createCacheKeys'; +import { usePagesOrInfinite, useWithSafeValues } from './usePagesOrInfinite'; + +/** + * @interface + */ +export type UseAPIKeysParams = PaginatedHookConfig< + GetAPIKeysParams & { + /** + * If `true`, a request will be triggered when the hook is mounted. + * + * @default true + */ + enabled?: boolean; + } +>; + +/** + * @interface + */ +export type UseAPIKeysReturn = PaginatedResources< + APIKeyResource, + T extends { infinite: true } ? true : false +>; + +/** + * 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; + + return usePagesOrInfinite({ + fetcher: clerk.apiKeys?.getAll ? (params: GetAPIKeysParams) => clerk.apiKeys.getAll(params) : 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; +} diff --git a/packages/shared/src/react/hooks/useAPIKeys.swr.tsx b/packages/shared/src/react/hooks/useAPIKeys.swr.tsx new file mode 100644 index 00000000000..a1c0be47da2 --- /dev/null +++ b/packages/shared/src/react/hooks/useAPIKeys.swr.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useCallback } from 'react'; +import { useSWRConfig } from 'swr'; + +import { eventMethodCalled } from '../../telemetry/events/method-called'; +import type { APIKeyResource, GetAPIKeysParams } from '../../types'; +import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; +import { STABLE_KEYS } from '../stable-keys'; +import type { PaginatedHookConfig, PaginatedResources } from '../types'; +import { createCacheKeys } from './createCacheKeys'; +import { usePagesOrInfinite, useWithSafeValues } from './usePagesOrInfinite'; + +/** + * @interface + */ +export type UseAPIKeysParams = PaginatedHookConfig< + GetAPIKeysParams & { + /** + * If `true`, a request will be triggered when the hook is mounted. + * + * @default true + */ + enabled?: boolean; + } +>; + +/** + * @interface + */ +export type UseAPIKeysReturn = PaginatedResources< + APIKeyResource, + T extends { infinite: true } ? true : false +>; + +/** + * 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) : 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 index d6fc14fdebc..cd899c1e737 100644 --- a/packages/shared/src/react/hooks/useAPIKeys.ts +++ b/packages/shared/src/react/hooks/useAPIKeys.ts @@ -1,118 +1,2 @@ -'use client'; - -import { eventMethodCalled } from '../../telemetry/events/method-called'; -import type { APIKeyResource, GetAPIKeysParams } from '../../types'; -import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; -import { STABLE_KEYS } from '../stable-keys'; -import type { PaginatedHookConfig, PaginatedResources } from '../types'; -import { createCacheKeys } from './createCacheKeys'; -import { usePagesOrInfinite, useWithSafeValues } from './usePagesOrInfinite'; - -/** - * @interface - */ -export type UseAPIKeysParams = PaginatedHookConfig< - GetAPIKeysParams & { - /** - * If `true`, a request will be triggered when the hook is mounted. - * - * @default true - */ - enabled?: boolean; - } ->; - -/** - * @interface - */ -export type UseAPIKeysReturn = PaginatedResources< - APIKeyResource, - T extends { infinite: true } ? true : false ->; - -/** - * 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; - - return usePagesOrInfinite({ - fetcher: clerk.apiKeys?.getAll ? (params: GetAPIKeysParams) => clerk.apiKeys.getAll(params) : 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; -} +export { useAPIKeys } from 'virtual:data-hooks/useAPIKeys'; +export type { UseAPIKeysParams, UseAPIKeysReturn } from './useAPIKeys.rq'; diff --git a/packages/shared/src/types/virtual-data-hooks.d.ts b/packages/shared/src/types/virtual-data-hooks.d.ts index f46f2cfb657..680d0d56269 100644 --- a/packages/shared/src/types/virtual-data-hooks.d.ts +++ b/packages/shared/src/types/virtual-data-hooks.d.ts @@ -3,6 +3,7 @@ declare module 'virtual:data-hooks/*' { 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; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 6d67b1b0d82..27300eb64f4 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -23,6 +23,7 @@ "declarationMap": true, "allowJs": true, "paths": { + "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"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f65b6e000b..b4e64573239 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -500,9 +500,6 @@ importers: regenerator-runtime: specifier: 0.14.1 version: 0.14.1 - swr: - specifier: 2.3.4 - version: 2.3.4(react@18.3.1) devDependencies: '@clerk/testing': specifier: workspace:^ @@ -2599,7 +2596,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} From 94ef80bf200d463af326b214a2292f722c6166c7 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 13:57:47 +0200 Subject: [PATCH 09/18] add tests for invalidations in api keys --- .../tests/machine-auth/component.test.ts | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) diff --git a/integration/tests/machine-auth/component.test.ts b/integration/tests/machine-auth/component.test.ts index df3845fd0ed..f03881e4632 100644 --- a/integration/tests/machine-auth/component.test.ts +++ b/integration/tests/machine-auth/component.test.ts @@ -344,4 +344,376 @@ testAgainstRunningApps({ await u.page.unrouteAll(); }); + + test.describe('api key list invalidation', () => { + // Helper function to count actual API key rows (not empty state) + const createAPIKeyCountHelper = (u: any) => async () => { + // Wait for the table to be fully loaded first + await u.page.locator('.cl-apiKeysTable').waitFor({ timeout: 10000 }); + + // Wait for any ongoing navigation/pagination to complete by waiting for network idle + await u.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + // Ignore timeout - continue with other checks + }); + + // Wait for content to stabilize - check multiple times to ensure consistency + let stableCount = -1; + let retryCount = 0; + const maxRetries = 10; + + while (retryCount < maxRetries) { + // Wait for content to load (either empty state or actual data) + await u.page + .waitForFunction( + () => { + const emptyText = document.querySelector( + 'text[data-localization-key*="emptyRow"], [data-localization-key*="emptyRow"]', + ); + const menuButtons = document.querySelectorAll( + '.cl-apiKeysTable .cl-tableBody .cl-tableRow .cl-menuButton', + ); + const spinner = document.querySelector('.cl-spinner'); + + // Content is loaded if we have either empty state, menu buttons, or no spinner + return emptyText || menuButtons.length > 0 || !spinner; + }, + { timeout: 3000 }, + ) + .catch(() => { + // Continue to next check if this fails + }); + + // Check if spinner is still visible (still loading) + const spinner = u.page.locator('.cl-spinner'); + if (await spinner.isVisible().catch(() => false)) { + await spinner.waitFor({ state: 'hidden', timeout: 3000 }).catch(() => { + // Continue if spinner doesn't disappear + }); + } + + // Check for empty state first + const emptyStateText = await u.page + .getByText('No API keys found') + .isVisible() + .catch(() => false); + if (emptyStateText) { + return 0; + } + + // Count menu buttons (each API key row has one) + const menuButtons = u.page.locator('.cl-apiKeysTable .cl-tableBody .cl-tableRow .cl-menuButton'); + const currentCount = await menuButtons.count(); + + // Check if count has stabilized (same as previous check) + if (currentCount === stableCount) { + return currentCount; + } + + stableCount = currentCount; + retryCount++; + + // Small delay before next check to allow for DOM updates + if (retryCount < maxRetries) { + await u.page.waitForTimeout(200); + } + } + + // Return the last stable count if we've exhausted retries + return stableCount; + }; + + test('api key list invalidation: new key appears immediately after creation', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const getAPIKeyCount = createAPIKeyCountHelper(u); + const initialRowCount = await getAPIKeyCount(); + + // Create a new API key with unique name + const newApiKeyName = `invalidation-test-${Date.now()}`; + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(newApiKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + // Close copy modal + await u.po.apiKeys.waitForCopyModalOpened(); + await u.po.apiKeys.clickCopyAndCloseButton(); + await u.po.apiKeys.waitForCopyModalClosed(); + await u.po.apiKeys.waitForFormClosed(); + + // Verify the new API key appears in the list immediately (invalidation worked) + const table = u.page.locator('.cl-apiKeysTable'); + await expect(table.locator('.cl-tableRow', { hasText: newApiKeyName })).toBeVisible({ timeout: 5000 }); + + // Verify the total count increased + const finalRowCount = await getAPIKeyCount(); + expect(finalRowCount).toBe(initialRowCount + 1); + + // Clean up - revoke the API key created in this test to avoid interfering with other tests + const menuButton = table.locator('.cl-tableRow', { hasText: newApiKeyName }).locator('.cl-menuButton'); + await menuButton.click(); + const revokeButton = u.page.getByRole('menuitem', { name: 'Revoke key' }); + await revokeButton.click(); + await u.po.apiKeys.waitForRevokeModalOpened(); + await u.po.apiKeys.typeRevokeConfirmation('Revoke'); + await u.po.apiKeys.clickConfirmRevokeButton(); + await u.po.apiKeys.waitForRevokeModalClosed(); + }); + + test('api key list invalidation: pagination info updates after creation', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Create a dedicated user for this test to ensure clean state + const dedicatedUser = u.services.users.createFakeUser(); + const bapiUser = await u.services.users.createBapiUser(dedicatedUser); + + // Create exactly 9 API keys for this user (not using shared organization) + const existingKeys = await Promise.all( + Array.from({ length: 9 }, () => u.services.users.createFakeAPIKey(bapiUser.id)), + ); + + // Sign in with the dedicated user + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: dedicatedUser.email, + password: dedicatedUser.password, + }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const getAPIKeyCount = createAPIKeyCountHelper(u); + + // Verify we have 9 keys and no pagination (all fit in first page) + // The helper function already has robust waiting logic + const actualCount = await getAPIKeyCount(); + expect(actualCount).toBe(9); + await expect(u.page.getByText(/Displaying.*of.*/i)).toBeHidden(); + + // Create the 10th API key which should not trigger pagination yet + const newApiKeyName = `boundary-test-${Date.now()}`; + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(newApiKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + await u.po.apiKeys.waitForCopyModalOpened(); + await u.po.apiKeys.clickCopyAndCloseButton(); + await u.po.apiKeys.waitForCopyModalClosed(); + await u.po.apiKeys.waitForFormClosed(); + + // Verify we now have 10 keys and still no pagination (exactly fits in one page) + expect(await getAPIKeyCount()).toBe(10); + await expect(u.page.getByText(/Displaying.*of.*/i)).toBeHidden(); + + // Create the 11th API key which should trigger pagination + const eleventhKeyName = `pagination-trigger-${Date.now()}`; + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(eleventhKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + await u.po.apiKeys.waitForCopyModalOpened(); + await u.po.apiKeys.clickCopyAndCloseButton(); + await u.po.apiKeys.waitForCopyModalClosed(); + await u.po.apiKeys.waitForFormClosed(); + + // Verify pagination info appears and shows correct count (invalidation updated pagination) + await expect(u.page.getByText(/Displaying 1 – 10 of 11/i)).toBeVisible({ timeout: 5000 }); + expect(await getAPIKeyCount()).toBe(10); + + // Cleanup - revoke the API keys created for this test and delete the user + await Promise.all(existingKeys.map(key => key.revoke())); + await dedicatedUser.deleteIfExists(); + }); + + test('api key list invalidation: works with active search filter', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const getAPIKeyCount = createAPIKeyCountHelper(u); + + // Create a specific search term that will match our new key + const searchTerm = 'search-test'; + const newApiKeyName = `${searchTerm}-${Date.now()}`; + + // Apply search filter first + const searchInput = u.page.locator('input.cl-apiKeysSearchInput'); + await searchInput.fill(searchTerm); + + // Wait for search to be applied (debounced) - wait for empty state or results + await u.page.waitForFunction( + () => { + const emptyMessage = document.querySelector('[data-localization-key*="emptyRow"]'); + const hasResults = + document.querySelectorAll('.cl-apiKeysTable .cl-tableBody .cl-tableRow .cl-menuButton').length > 0; + return emptyMessage || hasResults; + }, + { timeout: 2000 }, + ); + + // Verify no results initially match + expect(await getAPIKeyCount()).toBe(0); + + // Create API key that matches the search + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(newApiKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + await u.po.apiKeys.waitForCopyModalOpened(); + await u.po.apiKeys.clickCopyAndCloseButton(); + await u.po.apiKeys.waitForCopyModalClosed(); + await u.po.apiKeys.waitForFormClosed(); + + // Verify the new key appears in filtered results (invalidation worked with search) + const table = u.page.locator('.cl-apiKeysTable'); + await expect(table.locator('.cl-tableRow', { hasText: newApiKeyName })).toBeVisible({ timeout: 5000 }); + expect(await getAPIKeyCount()).toBe(1); + + // Clear search and verify key appears in full list too + await searchInput.clear(); + // Wait for search to clear and show all results + await u.page.waitForFunction( + () => { + return document.querySelectorAll('.cl-apiKeysTable .cl-tableBody .cl-tableRow .cl-menuButton').length > 0; + }, + { timeout: 2000 }, + ); + await expect(table.locator('.cl-tableRow', { hasText: newApiKeyName })).toBeVisible(); + }); + + test('api key list invalidation: works when on second page of results', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Create a dedicated user for this test to ensure clean state + const dedicatedUser = u.services.users.createFakeUser(); + const bapiUser = await u.services.users.createBapiUser(dedicatedUser); + + // Create exactly 15 API keys for this user to have 2 pages (10 per page) + const existingKeys = await Promise.all( + Array.from({ length: 15 }, () => u.services.users.createFakeAPIKey(bapiUser.id)), + ); + + // Sign in with the dedicated user + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: dedicatedUser.email, + password: dedicatedUser.password, + }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const getAPIKeyCount = createAPIKeyCountHelper(u); + + // Verify pagination and go to second page + await expect(u.page.getByText(/Displaying 1 – 10 of 15/i)).toBeVisible(); + const page2Button = u.page.locator('.cl-paginationButton').filter({ hasText: /^2$/ }); + await page2Button.click(); + await expect(u.page.getByText(/Displaying 11 – 15 of 15/i)).toBeVisible(); + expect(await getAPIKeyCount()).toBe(5); + + // Create a new API key while on page 2 + const newApiKeyName = `page2-test-${Date.now()}`; + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(newApiKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + await u.po.apiKeys.waitForCopyModalOpened(); + await u.po.apiKeys.clickCopyAndCloseButton(); + await u.po.apiKeys.waitForCopyModalClosed(); + await u.po.apiKeys.waitForFormClosed(); + + // Verify pagination info updated (invalidation refreshed all pages) + await expect(u.page.getByText(/Displaying 11 – 16 of 16/i)).toBeVisible({ timeout: 5000 }); + expect(await getAPIKeyCount()).toBe(6); + + // The new key should appear on page 1 since it's the most recent + const table = u.page.locator('.cl-apiKeysTable'); + await expect(table.locator('.cl-tableRow', { hasText: newApiKeyName })).toBeVisible(); + + // Cleanup - revoke the API keys created for this test and delete the user + await Promise.all(existingKeys.map(key => key.revoke())); + await dedicatedUser.deleteIfExists(); + }); + + test('api key list invalidation: multiple rapid creations update correctly', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const getAPIKeyCount = createAPIKeyCountHelper(u); + const initialRowCount = await getAPIKeyCount(); + const timestamp = Date.now(); + + // Create multiple API keys rapidly to test invalidation handles concurrent updates + for (let i = 0; i < 3; i++) { + const keyName = `rapid-test-${timestamp}-${i}`; + + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(keyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + await u.po.apiKeys.waitForCopyModalOpened(); + await u.po.apiKeys.clickCopyAndCloseButton(); + await u.po.apiKeys.waitForCopyModalClosed(); + await u.po.apiKeys.waitForFormClosed(); + } + + // Verify all 3 new keys appear in the list + const table = u.page.locator('.cl-apiKeysTable'); + for (let i = 0; i < 3; i++) { + const keyName = `rapid-test-${timestamp}-${i}`; + await expect(table.locator('.cl-tableRow', { hasText: keyName })).toBeVisible({ timeout: 5000 }); + } + + // Verify total count increased by 3 + const finalRowCount = await getAPIKeyCount(); + expect(finalRowCount).toBe(initialRowCount + 3); + + // Clean up - revoke the API keys created in this test to avoid interfering with other tests + for (let i = 0; i < 3; i++) { + const keyName = `rapid-test-${timestamp}-${i}`; + const menuButton = table.locator('.cl-tableRow', { hasText: keyName }).locator('.cl-menuButton'); + await menuButton.click(); + const revokeButton = u.page.getByRole('menuitem', { name: 'Revoke key' }); + await revokeButton.click(); + await u.po.apiKeys.waitForRevokeModalOpened(); + await u.po.apiKeys.typeRevokeConfirmation('Revoke'); + await u.po.apiKeys.clickConfirmRevokeButton(); + await u.po.apiKeys.waitForRevokeModalClosed(); + } + }); + }); }); From 42fda7bb6bfdebdde61942fcd79ad2eb5258166c Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 15:08:01 +0200 Subject: [PATCH 10/18] drop SWRConfig from the compat rq variant --- packages/shared/src/react/hooks/__tests__/wrapper.tsx | 10 +++++----- .../shared/src/react/providers/SWRConfigCompat.rq.tsx | 7 ++----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/react/hooks/__tests__/wrapper.tsx b/packages/shared/src/react/hooks/__tests__/wrapper.tsx index 4ad4b326fc8..90fad9e4a39 100644 --- a/packages/shared/src/react/hooks/__tests__/wrapper.tsx +++ b/packages/shared/src/react/hooks/__tests__/wrapper.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import { SWRConfig } from 'swr'; + +import { SWRConfigCompat } from '../../providers/SWRConfigCompat'; export const wrapper = ({ children }: { children: React.ReactNode }) => ( - // TODO: Replace SWRConfig with the react-query equivalent. - new Map(), }} > {children} - + ); diff --git a/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx b/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx index 4c581300747..40810747d89 100644 --- a/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx +++ b/packages/shared/src/react/providers/SWRConfigCompat.rq.tsx @@ -1,11 +1,8 @@ import type { PropsWithChildren } from 'react'; import React 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}; +export function SWRConfigCompat({ children }: PropsWithChildren<{ swrConfig?: any }>) { + return <>{children}; } From bc36ccd376f3b5e3ac03e1e2fb647e8490a1d0e0 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 16:37:29 +0200 Subject: [PATCH 11/18] cleanup --- .../billing/useInitializePaymentMethod.rq.tsx | 11 +++++--- .../react/billing/useStripeClerkLibs.rq.tsx | 8 +++--- .../src/react/billing/useStripeLoader.rq.tsx | 5 +++- .../hooks/createBillingPaginatedHook.tsx | 21 +++++++-------- .../shared/src/react/hooks/createCacheKeys.ts | 3 +++ .../src/react/hooks/useBillingHookEnabled.ts | 27 +++++++++++++++++++ .../react/hooks/usePaymentAttemptQuery.rq.tsx | 21 +++++---------- .../hooks/usePaymentAttemptQuery.swr.tsx | 14 +--------- .../react/hooks/usePlanDetailsQuery.rq.tsx | 18 ++++++------- .../react/hooks/usePlanDetailsQuery.swr.tsx | 9 +------ .../src/react/hooks/useStatementQuery.rq.tsx | 21 +++++---------- .../src/react/hooks/useStatementQuery.swr.tsx | 16 ++--------- .../src/react/hooks/useSubscription.rq.tsx | 11 +++----- 13 files changed, 83 insertions(+), 102 deletions(-) create mode 100644 packages/shared/src/react/hooks/useBillingHookEnabled.ts diff --git a/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx b/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx index 9761c367222..be27c93b640 100644 --- a/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx +++ b/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx @@ -5,6 +5,7 @@ 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; @@ -22,17 +23,19 @@ export type UseInitializePaymentMethodResult = { * @internal */ function useInitializePaymentMethod(options?: InitializePaymentMethodOptions): UseInitializePaymentMethodResult { - const { for: forType = 'user' } = options ?? {}; + 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, for: forType }] as const; - }, [resource?.id, forType]); + return ['billing-payment-method-initialize', { resourceId: resource?.id }] as const; + }, [resource?.id]); - const isEnabled = Boolean(resource?.id); + const isEnabled = Boolean(resource?.id) && billingEnabled; const query = useClerkQuery({ queryKey, diff --git a/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx b/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx index 8a1d45f80a7..08dc1059f9a 100644 --- a/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx +++ b/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx @@ -2,6 +2,7 @@ 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; @@ -10,23 +11,24 @@ type StripeClerkLibs = { loadStripe: LoadStripeFn; }; -export type UseStripeClerkLibsResult = StripeClerkLibs | null; - /** * This is the new implementation of the Stripe libraries loader using React Query. * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. * * @internal */ -function useStripeClerkLibs(): UseStripeClerkLibsResult { +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), diff --git a/packages/shared/src/react/billing/useStripeLoader.rq.tsx b/packages/shared/src/react/billing/useStripeLoader.rq.tsx index cb437c0b2ec..c1cb4404d1a 100644 --- a/packages/shared/src/react/billing/useStripeLoader.rq.tsx +++ b/packages/shared/src/react/billing/useStripeLoader.rq.tsx @@ -3,6 +3,7 @@ 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 = { @@ -26,7 +27,9 @@ function useStripeLoader(options: StripeLoaderOptions): UseStripeLoaderResult { return ['stripe-sdk', { externalGatewayId, stripePublishableKey }] as const; }, [externalGatewayId, stripePublishableKey]); - const isEnabled = Boolean(stripeClerkLibs && externalGatewayId && stripePublishableKey); + const billingEnabled = useBillingHookEnabled({ authenticated: true }); + + const isEnabled = Boolean(stripeClerkLibs && externalGatewayId && stripePublishableKey) && billingEnabled; const query = useClerkQuery({ queryKey, diff --git a/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx b/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx index ffd07b42e30..1bd63a9817e 100644 --- a/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx +++ b/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx @@ -1,5 +1,5 @@ import { eventMethodCalled } from '../../telemetry/events/method-called'; -import type { ClerkPaginatedResponse, ClerkResource, EnvironmentResource, ForPayerType } from '../../types'; +import type { ClerkPaginatedResponse, ClerkResource, ForPayerType } from '../../types'; import { useAssertWrappedByClerkProvider, useClerkInstanceContext, @@ -9,6 +9,7 @@ import { import type { ResourceCacheStableKey } from '../stable-keys'; import type { PagesOrInfiniteOptions, PaginatedHookConfig, PaginatedResources } from '../types'; import { createCacheKeys } from './createCacheKeys'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; import { usePagesOrInfinite, useWithSafeValues } from './usePagesOrInfinite'; /** @@ -98,8 +99,6 @@ export function createBillingPaginatedHook }>(keys: T) { const { queryKey } = keys; return { diff --git a/packages/shared/src/react/hooks/useBillingHookEnabled.ts b/packages/shared/src/react/hooks/useBillingHookEnabled.ts new file mode 100644 index 00000000000..80299a0d6b4 --- /dev/null +++ b/packages/shared/src/react/hooks/useBillingHookEnabled.ts @@ -0,0 +1,27 @@ +import type { ForPayerType } from '../../types/billing'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; + +/** + * @internal + */ +export function useBillingHookEnabled(params?: { for?: ForPayerType; enabled?: boolean; authenticated?: boolean }) { + const clerk = useClerkInstanceContext(); + + const enabledFromParam = params?.enabled ?? true; + + // @ts-expect-error `__unstable__environment` is not typed + const environment = clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined; + + const user = useUserContext(); + const { organization } = useOrganizationContext(); + + const isOrganization = params?.for === 'organization'; + const billingEnabled = isOrganization + ? environment?.commerceSettings.billing.organization.enabled + : environment?.commerceSettings.billing.user.enabled; + + const requireUserAndOrganizationWhenAuthenticated = + (params?.authenticated ?? true) ? (isOrganization ? Boolean(organization?.id) : true) && user?.id : true; + + return billingEnabled && enabledFromParam && clerk.loaded && requireUserAndOrganizationWhenAuthenticated; +} diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx index 6f8bc7e2c17..4ca180ddc0a 100644 --- a/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx @@ -1,17 +1,10 @@ -import { eventMethodCalled } from '../../telemetry/events'; import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; import { useClerkQuery } from '../clerk-rq/useQuery'; -import { - useAssertWrappedByClerkProvider, - useClerkInstanceContext, - useOrganizationContext, - useUserContext, -} from '../contexts'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; import { usePaymentAttemptQueryCacheKeys } from './usePaymentAttemptQuery.shared'; import type { PaymentAttemptQueryResult, UsePaymentAttemptQueryParams } from './usePaymentAttemptQuery.types'; -const HOOK_NAME = 'usePaymentAttemptQuery'; - /** * This is the new implementation of usePaymentAttemptQuery using React Query. * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. @@ -19,15 +12,11 @@ const HOOK_NAME = 'usePaymentAttemptQuery'; * @internal */ function usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAttemptQueryResult { - useAssertWrappedByClerkProvider(HOOK_NAME); - - const { paymentAttemptId, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; + const { paymentAttemptId, keepPreviousData = false, for: forType = 'user' } = params; const clerk = useClerkInstanceContext(); const user = useUserContext(); const { organization } = useOrganizationContext(); - clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); - const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; const userId = user?.id ?? null; @@ -38,7 +27,9 @@ function usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAt for: forType, }); - const queryEnabled = Boolean(paymentAttemptId) && enabled && (forType !== 'organization' || Boolean(organizationId)); + const billingEnabled = useBillingHookEnabled(params); + + const queryEnabled = Boolean(paymentAttemptId) && billingEnabled; const query = useClerkQuery({ queryKey, diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx index 1fcc2fa2881..d47d5d52246 100644 --- a/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.swr.tsx @@ -1,16 +1,8 @@ -import { eventMethodCalled } from '../../telemetry/events'; import { useSWR } from '../clerk-swr'; -import { - useAssertWrappedByClerkProvider, - useClerkInstanceContext, - useOrganizationContext, - useUserContext, -} from '../contexts'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; import { usePaymentAttemptQueryCacheKeys } from './usePaymentAttemptQuery.shared'; import type { PaymentAttemptQueryResult, UsePaymentAttemptQueryParams } from './usePaymentAttemptQuery.types'; -const HOOK_NAME = 'usePaymentAttemptQuery'; - /** * This is the existing implementation of usePaymentAttemptQuery using SWR. * It is kept here for backwards compatibility until our next major version. @@ -18,15 +10,11 @@ const HOOK_NAME = 'usePaymentAttemptQuery'; * @internal */ export function __internal_usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAttemptQueryResult { - useAssertWrappedByClerkProvider(HOOK_NAME); - const { paymentAttemptId, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; const clerk = useClerkInstanceContext(); const user = useUserContext(); const { organization } = useOrganizationContext(); - clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); - const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; const userId = user?.id ?? null; diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx index 7a72f4f9761..0ece0902fbf 100644 --- a/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx @@ -1,12 +1,10 @@ -import { eventMethodCalled } from '../../telemetry/events'; import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; import { useClerkQuery } from '../clerk-rq/useQuery'; -import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; +import { useClerkInstanceContext } from '../contexts'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; import { usePlanDetailsQueryCacheKeys } from './usePlanDetailsQuery.shared'; import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePlanDetailsQuery.types'; -const HOOK_NAME = 'usePlanDetailsQuery'; - /** * This is the new implementation of usePlanDetailsQuery using React Query. * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. @@ -14,18 +12,18 @@ const HOOK_NAME = 'usePlanDetailsQuery'; * @internal */ export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams = {}): PlanDetailsQueryResult { - useAssertWrappedByClerkProvider(HOOK_NAME); - - const { planId, initialPlan = null, enabled = true, keepPreviousData = true } = params; + const { planId, initialPlan = null, keepPreviousData = true } = params; const clerk = useClerkInstanceContext(); - clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); - const targetPlanId = planId ?? initialPlan?.id ?? null; const { queryKey } = usePlanDetailsQueryCacheKeys({ planId: targetPlanId }); - const queryEnabled = Boolean(targetPlanId) && enabled; + const billingEnabled = useBillingHookEnabled({ + authenticated: false, + }); + + const queryEnabled = Boolean(targetPlanId) && billingEnabled; const query = useClerkQuery({ queryKey, diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx index 37c5337b658..ce544fce5b4 100644 --- a/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.swr.tsx @@ -1,11 +1,8 @@ -import { eventMethodCalled } from '../../telemetry/events'; import { useSWR } from '../clerk-swr'; -import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; +import { useClerkInstanceContext } from '../contexts'; import { usePlanDetailsQueryCacheKeys } from './usePlanDetailsQuery.shared'; import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePlanDetailsQuery.types'; -const HOOK_NAME = 'usePlanDetailsQuery'; - /** * This is the existing implementation of usePlanDetailsQuery using SWR. * It is kept here for backwards compatibility until our next major version. @@ -13,13 +10,9 @@ const HOOK_NAME = 'usePlanDetailsQuery'; * @internal */ function usePlanDetailsQuery(params: UsePlanDetailsQueryParams = {}): PlanDetailsQueryResult { - useAssertWrappedByClerkProvider(HOOK_NAME); - const { planId, initialPlan = null, enabled = true, keepPreviousData = true } = params; const clerk = useClerkInstanceContext(); - clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); - const targetPlanId = planId ?? initialPlan?.id ?? null; const { queryKey } = usePlanDetailsQueryCacheKeys({ planId: targetPlanId }); diff --git a/packages/shared/src/react/hooks/useStatementQuery.rq.tsx b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx index 6b9bbf4e037..12cb5eb5074 100644 --- a/packages/shared/src/react/hooks/useStatementQuery.rq.tsx +++ b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx @@ -1,17 +1,10 @@ -import { eventMethodCalled } from '../../telemetry/events'; import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; import { useClerkQuery } from '../clerk-rq/useQuery'; -import { - useAssertWrappedByClerkProvider, - useClerkInstanceContext, - useOrganizationContext, - useUserContext, -} from '../contexts'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; import { useStatementQueryCacheKeys } from './useStatementQuery.shared'; import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; -const HOOK_NAME = 'useStatementQuery'; - /** * This is the new implementation of useStatementQuery using React Query. * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. @@ -19,15 +12,11 @@ const HOOK_NAME = 'useStatementQuery'; * @internal */ function useStatementQuery(params: UseStatementQueryParams = {}): StatementQueryResult { - useAssertWrappedByClerkProvider(HOOK_NAME); - - const { statementId = null, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; + const { statementId = null, keepPreviousData = false, for: forType = 'user' } = params; const clerk = useClerkInstanceContext(); const user = useUserContext(); const { organization } = useOrganizationContext(); - clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); - const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; const userId = user?.id ?? null; @@ -38,7 +27,9 @@ function useStatementQuery(params: UseStatementQueryParams = {}): StatementQuery for: forType, }); - const queryEnabled = Boolean(statementId) && enabled && (forType !== 'organization' || Boolean(organizationId)); + const billingEnabled = useBillingHookEnabled(params); + + const queryEnabled = Boolean(statementId) && billingEnabled; const query = useClerkQuery({ queryKey, diff --git a/packages/shared/src/react/hooks/useStatementQuery.swr.tsx b/packages/shared/src/react/hooks/useStatementQuery.swr.tsx index ad979b8fce7..8d209d75f66 100644 --- a/packages/shared/src/react/hooks/useStatementQuery.swr.tsx +++ b/packages/shared/src/react/hooks/useStatementQuery.swr.tsx @@ -1,15 +1,7 @@ -import { eventMethodCalled } from '../../telemetry/events'; import { useSWR } from '../clerk-swr'; -import { - useAssertWrappedByClerkProvider, - useClerkInstanceContext, - useOrganizationContext, - useUserContext, -} from '../contexts'; -import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; import { useStatementQueryCacheKeys } from './useStatementQuery.shared'; - -const HOOK_NAME = 'useStatementQuery'; +import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; /** * This is the existing implementation of useStatementQuery using SWR. @@ -18,15 +10,11 @@ const HOOK_NAME = 'useStatementQuery'; * @internal */ export function __internal_useStatementQuery(params: UseStatementQueryParams = {}): StatementQueryResult { - useAssertWrappedByClerkProvider(HOOK_NAME); - const { statementId = null, enabled = true, keepPreviousData = false, for: forType = 'user' } = params; const clerk = useClerkInstanceContext(); const user = useUserContext(); const { organization } = useOrganizationContext(); - clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); - const organizationId = forType === 'organization' ? (organization?.id ?? null) : null; const userId = user?.id ?? null; diff --git a/packages/shared/src/react/hooks/useSubscription.rq.tsx b/packages/shared/src/react/hooks/useSubscription.rq.tsx index 2f5f2accad7..1fe429e9ceb 100644 --- a/packages/shared/src/react/hooks/useSubscription.rq.tsx +++ b/packages/shared/src/react/hooks/useSubscription.rq.tsx @@ -1,7 +1,6 @@ import { useCallback } from 'react'; import { eventMethodCalled } from '../../telemetry/events'; -import type { EnvironmentResource } 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'; @@ -11,6 +10,7 @@ import { useOrganizationContext, useUserContext, } from '../contexts'; +import { useBillingHookEnabled } from './useBillingHookEnabled'; import { useSubscriptionCacheKeys } from './useSubscription.shared'; import type { SubscriptionResult, UseSubscriptionParams } from './useSubscription.types'; @@ -29,15 +29,10 @@ export function useSubscription(params?: UseSubscriptionParams): SubscriptionRes const user = useUserContext(); const { organization } = useOrganizationContext(); - // @ts-expect-error `__unstable__environment` is not typed - const environment = clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined; + const billingEnabled = useBillingHookEnabled(params); clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); - const isOrganization = params?.for === 'organization'; - const billingEnabled = isOrganization - ? environment?.commerceSettings.billing.organization.enabled - : environment?.commerceSettings.billing.user.enabled; const keepPreviousData = params?.keepPreviousData ?? false; const [queryClient] = useClerkQueryClient(); @@ -48,7 +43,7 @@ export function useSubscription(params?: UseSubscriptionParams): SubscriptionRes for: params?.for, }); - const queriesEnabled = Boolean(user?.id && billingEnabled) && (params?.enabled ?? true); + const queriesEnabled = Boolean(user?.id && billingEnabled); const query = useClerkQuery({ queryKey, From 121977ebe001cab543341bf041bfb400d091c7c0 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 16:37:53 +0200 Subject: [PATCH 12/18] computedValues in usePagesOrInfinite.rq.tsx --- .../src/react/hooks/usePagesOrInfinite.rq.tsx | 100 ++++++++---------- 1 file changed, 43 insertions(+), 57 deletions(-) diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx index bf9fba8e0f9..da0ee3e5680 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx @@ -127,16 +127,52 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { } }, [isSignedIn, queryClient, previousIsSignedIn, forceUpdate]); - const page = useMemo(() => { + // 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 = infiniteQuery.data?.pages ?? cachedData?.pages ?? []; - // Return pages.length if > 0, otherwise return initialPage (default 1) - return pages.length > 0 ? pages.length : initialPageRef.current; + 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, + }; } - return paginatedPage; - }, [triggerInfinite, infiniteQuery.data?.pages, paginatedPage, queryClient, infiniteQueryKey]); + + // 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 => { @@ -157,56 +193,6 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { [infiniteQuery, page, triggerInfinite, queryClient, infiniteQueryKey], ); - const data = useMemo(() => { - if (triggerInfinite) { - const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); - // When query is disabled, the hook's data is stale, so only read from cache - const pages = queriesEnabled ? (infiniteQuery.data?.pages ?? cachedData?.pages ?? []) : (cachedData?.pages ?? []); - return pages.map((a: ClerkPaginatedResponse) => a?.data).flat() ?? []; - } - - // 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 empty data - const pageData = queriesEnabled - ? (singlePageQuery.data ?? queryClient.getQueryData>(pagesQueryKey)) - : queryClient.getQueryData>(pagesQueryKey); - return pageData?.data ?? []; - }, [ - queriesEnabled, - forceUpdateCounter, - triggerInfinite, - singlePageQuery.data, - infiniteQuery.data, - queryClient, - pagesQueryKey, - infiniteQueryKey, - ]); - - const count = useMemo(() => { - if (triggerInfinite) { - const cachedData = queryClient.getQueryData<{ pages?: Array> }>(infiniteQueryKey); - // When query is disabled, the hook's data is stale, so only read from cache - const pages = queriesEnabled ? (infiniteQuery.data?.pages ?? cachedData?.pages ?? []) : (cachedData?.pages ?? []); - return pages[pages.length - 1]?.total_count || 0; - } - - // 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 0 - const pageData = queriesEnabled - ? (singlePageQuery.data ?? queryClient.getQueryData>(pagesQueryKey)) - : queryClient.getQueryData>(pagesQueryKey); - return pageData?.total_count ?? 0; - }, [ - queriesEnabled, - forceUpdateCounter, - triggerInfinite, - singlePageQuery.data, - infiniteQuery.data, - queryClient, - pagesQueryKey, - infiniteQueryKey, - ]); - const isLoading = triggerInfinite ? infiniteQuery.isLoading : singlePageQuery.isLoading; const isFetching = triggerInfinite ? infiniteQuery.isFetching : singlePageQuery.isFetching; const error = (triggerInfinite ? infiniteQuery.error : singlePageQuery.error) ?? null; @@ -246,7 +232,7 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { >; return { ...prevValue, pages: nextPages }; }); - // Force re-render to reflect cache changes + // Force immediate re-render to reflect cache changes forceUpdate(n => n + 1); return Promise.resolve(); } From 3b52ab4c814762834c635338cf16771dfe30de67 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 16:45:34 +0200 Subject: [PATCH 13/18] patch tests --- packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx b/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx index 1576cccd9be..075b794a406 100644 --- a/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx @@ -132,7 +132,7 @@ describe('useApiKeys', () => { if (isRQ) { await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); } else { - expect(getAllSpy).toHaveBeenCalledTimes(1); + expect(getAllSpy).toHaveBeenCalledTimes(2); } }); @@ -170,7 +170,7 @@ describe('useApiKeys', () => { if (isRQ) { await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); } else { - expect(getAllSpy).toHaveBeenCalledTimes(1); + expect(getAllSpy).toHaveBeenCalledTimes(2); } }); From 10c1b5f8d8ca1e05df5c494c26033e2fbc3e5dbc Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 16:47:40 +0200 Subject: [PATCH 14/18] add changeset --- .changeset/early-clowns-dance.md | 5 +++++ .changeset/fuzzy-donuts-jam.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/early-clowns-dance.md create mode 100644 .changeset/fuzzy-donuts-jam.md diff --git a/.changeset/early-clowns-dance.md b/.changeset/early-clowns-dance.md new file mode 100644 index 00000000000..2640543ddfb --- /dev/null +++ b/.changeset/early-clowns-dance.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +Removes SWR as direct dependency diff --git a/.changeset/fuzzy-donuts-jam.md b/.changeset/fuzzy-donuts-jam.md new file mode 100644 index 00000000000..593efe22d1e --- /dev/null +++ b/.changeset/fuzzy-donuts-jam.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +Creates compatibility layer for SWR hooks that were previously inside `@clerk/clerk-js` From 8141be7962ae3e8715e5e779e0dd364794f47d6e Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 17:01:54 +0200 Subject: [PATCH 15/18] fix useBillingHookEnabled.ts --- packages/shared/src/react/hooks/useBillingHookEnabled.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/react/hooks/useBillingHookEnabled.ts b/packages/shared/src/react/hooks/useBillingHookEnabled.ts index 80299a0d6b4..f52aecfddfb 100644 --- a/packages/shared/src/react/hooks/useBillingHookEnabled.ts +++ b/packages/shared/src/react/hooks/useBillingHookEnabled.ts @@ -21,7 +21,7 @@ export function useBillingHookEnabled(params?: { for?: ForPayerType; enabled?: b : environment?.commerceSettings.billing.user.enabled; const requireUserAndOrganizationWhenAuthenticated = - (params?.authenticated ?? true) ? (isOrganization ? Boolean(organization?.id) : true) && user?.id : true; + (params?.authenticated ?? true) ? (isOrganization ? Boolean(organization?.id) : true) && Boolean(user?.id) : true; return billingEnabled && enabledFromParam && clerk.loaded && requireUserAndOrganizationWhenAuthenticated; } From acbc63810bde55c1947dc3e9a354c49b6b26ea70 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 17:02:54 +0200 Subject: [PATCH 16/18] fix tests --- .../src/ui/components/Plans/__tests__/PlanDetails.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx b/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx index fab81348ba5..d87e182b054 100644 --- a/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx +++ b/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx @@ -70,6 +70,7 @@ describe('PlanDetails', () => { it('displays spinner when loading with planId', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); fixtures.clerk.billing.getPlan.mockImplementation(() => new Promise(() => {})); @@ -118,6 +119,7 @@ describe('PlanDetails', () => { it('fetches and renders plan details when planId is provided', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); fixtures.clerk.billing.getPlan.mockResolvedValue(mockPlan); From dc4843dd57e63323a3707061caef394810c4da78 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 17:13:48 +0200 Subject: [PATCH 17/18] remove comments --- .../shared/src/react/billing/useInitializePaymentMethod.rq.tsx | 3 --- packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx | 3 --- packages/shared/src/react/billing/useStripeLoader.rq.tsx | 3 --- packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx | 3 --- packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx | 3 --- packages/shared/src/react/hooks/useStatementQuery.rq.tsx | 3 --- 6 files changed, 18 deletions(-) diff --git a/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx b/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx index be27c93b640..5c58075c6b6 100644 --- a/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx +++ b/packages/shared/src/react/billing/useInitializePaymentMethod.rq.tsx @@ -17,9 +17,6 @@ export type UseInitializePaymentMethodResult = { }; /** - * This is the new implementation of the payment method initializer using React Query. - * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. - * * @internal */ function useInitializePaymentMethod(options?: InitializePaymentMethodOptions): UseInitializePaymentMethodResult { diff --git a/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx b/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx index 08dc1059f9a..e2dd394b24c 100644 --- a/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx +++ b/packages/shared/src/react/billing/useStripeClerkLibs.rq.tsx @@ -12,9 +12,6 @@ type StripeClerkLibs = { }; /** - * This is the new implementation of the Stripe libraries loader using React Query. - * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. - * * @internal */ function useStripeClerkLibs(): StripeClerkLibs | null { diff --git a/packages/shared/src/react/billing/useStripeLoader.rq.tsx b/packages/shared/src/react/billing/useStripeLoader.rq.tsx index c1cb4404d1a..59dee615f6b 100644 --- a/packages/shared/src/react/billing/useStripeLoader.rq.tsx +++ b/packages/shared/src/react/billing/useStripeLoader.rq.tsx @@ -15,9 +15,6 @@ type StripeLoaderOptions = { export type UseStripeLoaderResult = Stripe | null | undefined; /** - * This is the new implementation of the Stripe instance loader using React Query. - * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. - * * @internal */ function useStripeLoader(options: StripeLoaderOptions): UseStripeLoaderResult { diff --git a/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx index 4ca180ddc0a..4dfe06c1a40 100644 --- a/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx +++ b/packages/shared/src/react/hooks/usePaymentAttemptQuery.rq.tsx @@ -6,9 +6,6 @@ import { usePaymentAttemptQueryCacheKeys } from './usePaymentAttemptQuery.shared import type { PaymentAttemptQueryResult, UsePaymentAttemptQueryParams } from './usePaymentAttemptQuery.types'; /** - * This is the new implementation of usePaymentAttemptQuery using React Query. - * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. - * * @internal */ function usePaymentAttemptQuery(params: UsePaymentAttemptQueryParams): PaymentAttemptQueryResult { diff --git a/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx index 0ece0902fbf..c2a7ec96cbd 100644 --- a/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx +++ b/packages/shared/src/react/hooks/usePlanDetailsQuery.rq.tsx @@ -6,9 +6,6 @@ import { usePlanDetailsQueryCacheKeys } from './usePlanDetailsQuery.shared'; import type { PlanDetailsQueryResult, UsePlanDetailsQueryParams } from './usePlanDetailsQuery.types'; /** - * This is the new implementation of usePlanDetailsQuery using React Query. - * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. - * * @internal */ export function __internal_usePlanDetailsQuery(params: UsePlanDetailsQueryParams = {}): PlanDetailsQueryResult { diff --git a/packages/shared/src/react/hooks/useStatementQuery.rq.tsx b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx index 12cb5eb5074..c0be99ef100 100644 --- a/packages/shared/src/react/hooks/useStatementQuery.rq.tsx +++ b/packages/shared/src/react/hooks/useStatementQuery.rq.tsx @@ -6,9 +6,6 @@ import { useStatementQueryCacheKeys } from './useStatementQuery.shared'; import type { StatementQueryResult, UseStatementQueryParams } from './useStatementQuery.types'; /** - * This is the new implementation of useStatementQuery using React Query. - * It is exported only if the package is built with the `CLERK_USE_RQ` environment variable set to `true`. - * * @internal */ function useStatementQuery(params: UseStatementQueryParams = {}): StatementQueryResult { From f820c960bae6fa4718817296ae4ad0801e26c2a9 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 21 Nov 2025 20:03:27 +0200 Subject: [PATCH 18/18] remove 'use client' --- packages/shared/src/react/clerk-rq/useInfiniteQuery.ts | 2 -- packages/shared/src/react/clerk-rq/useQuery.ts | 1 - packages/shared/src/react/hooks/useAPIKeys.rq.tsx | 2 -- packages/shared/src/react/hooks/useAPIKeys.swr.tsx | 2 -- packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx | 2 -- packages/shared/src/react/hooks/usePagesOrInfinite.shared.ts | 2 -- 6 files changed, 11 deletions(-) diff --git a/packages/shared/src/react/clerk-rq/useInfiniteQuery.ts b/packages/shared/src/react/clerk-rq/useInfiniteQuery.ts index 8f9104a7145..2f949336de4 100644 --- a/packages/shared/src/react/clerk-rq/useInfiniteQuery.ts +++ b/packages/shared/src/react/clerk-rq/useInfiniteQuery.ts @@ -1,5 +1,3 @@ -'use client'; - import type { DefaultError, InfiniteData, QueryKey, QueryObserver } from '@tanstack/query-core'; import { InfiniteQueryObserver } from '@tanstack/query-core'; diff --git a/packages/shared/src/react/clerk-rq/useQuery.ts b/packages/shared/src/react/clerk-rq/useQuery.ts index 89416b2e24b..cbe0b0d7367 100644 --- a/packages/shared/src/react/clerk-rq/useQuery.ts +++ b/packages/shared/src/react/clerk-rq/useQuery.ts @@ -1,4 +1,3 @@ -'use client'; import type { DefaultError, NoInfer, QueryKey } from '@tanstack/query-core'; import { QueryObserver } from '@tanstack/query-core'; diff --git a/packages/shared/src/react/hooks/useAPIKeys.rq.tsx b/packages/shared/src/react/hooks/useAPIKeys.rq.tsx index d6fc14fdebc..05b8962e90f 100644 --- a/packages/shared/src/react/hooks/useAPIKeys.rq.tsx +++ b/packages/shared/src/react/hooks/useAPIKeys.rq.tsx @@ -1,5 +1,3 @@ -'use client'; - import { eventMethodCalled } from '../../telemetry/events/method-called'; import type { APIKeyResource, GetAPIKeysParams } from '../../types'; import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; diff --git a/packages/shared/src/react/hooks/useAPIKeys.swr.tsx b/packages/shared/src/react/hooks/useAPIKeys.swr.tsx index a1c0be47da2..6696bb49b92 100644 --- a/packages/shared/src/react/hooks/useAPIKeys.swr.tsx +++ b/packages/shared/src/react/hooks/useAPIKeys.swr.tsx @@ -1,5 +1,3 @@ -'use client'; - import { useCallback } from 'react'; import { useSWRConfig } from 'swr'; diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx index da0ee3e5680..79d9c6ae96d 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx @@ -1,5 +1,3 @@ -'use client'; - import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ClerkPaginatedResponse } from '../../types'; diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.shared.ts b/packages/shared/src/react/hooks/usePagesOrInfinite.shared.ts index d89696be360..de5ac4a0747 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.shared.ts +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.shared.ts @@ -1,5 +1,3 @@ -'use client'; - import { useRef } from 'react'; import type { PagesOrInfiniteOptions } from '../types';