From 55e340cdb4f18a614872ab11b7998f4c1aac6d73 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 24 Jun 2025 15:07:21 +0300 Subject: [PATCH 01/36] chore(clerk-js, shared): Expose `useCheckout` as experimental --- integration/tests/pricing-table.test.ts | 4 +- .../ui/components/Checkout/CheckoutForm.tsx | 33 ++-- .../ui/components/Checkout/CheckoutPage.tsx | 147 ++++++------------ .../src/ui/components/Checkout/index.tsx | 40 ++--- .../src/ui/components/Checkout/parts.tsx | 16 +- packages/shared/src/react/hooks/index.ts | 1 + .../shared/src/react/hooks/useCheckout.ts | 129 +++++++++++++++ .../unstable/page-objects/checkout.ts | 7 +- 8 files changed, 219 insertions(+), 158 deletions(-) create mode 100644 packages/shared/src/react/hooks/useCheckout.ts diff --git a/integration/tests/pricing-table.test.ts b/integration/tests/pricing-table.test.ts index bbd70bf845c..ee4c7bcaace 100644 --- a/integration/tests/pricing-table.test.ts +++ b/integration/tests/pricing-table.test.ts @@ -309,7 +309,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })('pricing tabl }); await u.po.checkout.clickPayOrSubscribe(); await expect(u.po.page.locator('.cl-checkout-root').getByText('The card was declined.').first()).toBeVisible(); - await u.po.checkout.waitForStipeElements(); + // It should unmount and remount the payment element + await u.po.checkout.waitForStipeElements({ state: 'hidden' }); + await u.po.checkout.waitForStipeElements({ state: 'visible' }); await u.po.checkout.fillTestCard(); await u.po.checkout.clickPayOrSubscribe(); await expect(u.po.page.locator('.cl-checkout-root').getByText('Payment was successful!')).toBeVisible(); diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index a6f08a684bc..b4b2fc9dbc9 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -122,8 +122,8 @@ export const CheckoutForm = withCardStateProvider(() => { const useCheckoutMutations = () => { const { organization } = useOrganization(); - const { subscriberType } = useCheckoutContext(); - const { updateCheckout, checkout } = useCheckoutContextRoot(); + const { subscriberType, onSubscriptionComplete } = useCheckoutContext(); + const { confirm, checkout } = useCheckoutContextRoot(); const card = useCardState(); if (!checkout) { @@ -134,11 +134,11 @@ const useCheckoutMutations = () => { card.setLoading(); card.setError(undefined); try { - const newCheckout = await checkout.confirm({ + await confirm({ ...params, ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), }); - updateCheckout(newCheckout); + onSubscriptionComplete?.(); } catch (error) { handleError(error, [], card.setError); } finally { @@ -152,36 +152,25 @@ const useCheckoutMutations = () => { const data = new FormData(e.currentTarget); const paymentSourceId = data.get('payment_source_id') as string; - await confirmCheckout({ + return confirmCheckout({ paymentSourceId, ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), }); }; const addPaymentSourceAndPay = async (ctx: { stripeSetupIntent?: SetupIntent }) => { - await confirmCheckout({ + return confirmCheckout({ gateway: 'stripe', paymentToken: ctx.stripeSetupIntent?.payment_method as string, ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), }); }; - const payWithTestCard = async () => { - card.setLoading(); - card.setError(undefined); - try { - const newCheckout = await checkout.confirm({ - gateway: 'stripe', - useTestCard: true, - ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), - }); - updateCheckout(newCheckout); - } catch (error) { - handleError(error, [], card.setError); - } finally { - card.setIdle(); - } - }; + const payWithTestCard = () => + confirmCheckout({ + gateway: 'stripe', + useTestCard: true, + }); return { payWithExistingPaymentSource, diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx index 6d8f2747ee2..9aa61e32212 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -1,21 +1,9 @@ -import { useClerk, useOrganization, useUser } from '@clerk/shared/react'; -import type { ClerkAPIError, CommerceCheckoutResource } from '@clerk/types'; +import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react'; import { createContext, useContext, useEffect, useMemo } from 'react'; -import useSWR from 'swr'; -import useSWRMutation from 'swr/mutation'; -import { useCheckoutContext } from '../../contexts'; +import { useCheckoutContext } from '@/ui/contexts/components'; -type CheckoutStatus = 'pending' | 'ready' | 'completed' | 'missing_payer_email' | 'invalid_plan_change' | 'error'; - -const CheckoutContextRoot = createContext<{ - checkout: CommerceCheckoutResource | undefined; - isLoading: boolean; - updateCheckout: (checkout: CommerceCheckoutResource) => void; - errors: ClerkAPIError[]; - startCheckout: () => void; - status: CheckoutStatus; -} | null>(null); +const CheckoutContextRoot = createContext | null>(null); export const useCheckoutContextRoot = () => { const ctx = useContext(CheckoutContextRoot); @@ -25,106 +13,59 @@ export const useCheckoutContextRoot = () => { return ctx; }; -const useCheckoutCreator = () => { - const { planId, planPeriod, subscriberType = 'user', onSubscriptionComplete } = useCheckoutContext(); - const clerk = useClerk(); - const { organization } = useOrganization(); - - const { user } = useUser(); - - const cacheKey = { - key: `commerce-checkout`, - userId: user?.id, - arguments: { - ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), - planId, - planPeriod, - }, - }; - - // Manually handle the cache - const { data, mutate } = useSWR(cacheKey); - - // Use `useSWRMutation` to avoid revalidations on stale-data/focus etc. - const { - trigger: startCheckout, - isMutating, - error, - } = useSWRMutation( - cacheKey, - key => - clerk.billing?.startCheckout( - // @ts-expect-error things are typed as optional - key.arguments, - ), - { - // Never throw on error, we want to handle it during rendering - throwOnError: false, - onSuccess: data => { - mutate(data, false); - }, - }, - ); +const Root = ({ children }: { children: React.ReactNode }) => { + const { planId, planPeriod, subscriberType } = useCheckoutContext(); + const checkout = useCheckout({ + for: subscriberType === 'org' ? 'organization' : undefined, + planId: planId!, + planPeriod: planPeriod!, + }); useEffect(() => { - void startCheckout(); - return () => { - // Clear the cache on unmount - mutate(undefined, false); - }; + checkout.start().catch(() => null); + return checkout.clear; }, []); - return { - checkout: data, - startCheckout, - updateCheckout: (checkout: CommerceCheckoutResource) => { - void mutate(checkout, false); - onSubscriptionComplete?.(); - }, - isMutating, - errors: error?.errors, - }; + return {children}; }; -const Root = ({ children }: { children: React.ReactNode }) => { - const { checkout, isMutating, updateCheckout, errors, startCheckout } = useCheckoutCreator(); +const Stage = ({ children, name }: { children: React.ReactNode; name: ReturnType['status'] }) => { + const ctx = useCheckoutContextRoot(); + if (ctx.status !== name) { + return null; + } + return children; +}; - const status = useMemo(() => { - if (isMutating) return 'pending'; - const completedCode = 'completed'; - if (checkout?.status === completedCode) return completedCode; - if (checkout) return 'ready'; +const FetchStatus = ({ + children, + status, +}: { + children: React.ReactNode; + status: 'idle' | 'fetching' | 'error' | 'invalid_plan_change' | 'missing_payer_email'; +}) => { + const { fetchStatus, error } = useCheckoutContextRoot(); - const missingCode = 'missing_payer_email'; - const isMissingPayerEmail = !!errors?.some((e: ClerkAPIError) => e.code === missingCode); - if (isMissingPayerEmail) return missingCode; - const invalidChangeCode = 'invalid_plan_change'; - if (errors?.[0]?.code === invalidChangeCode) return invalidChangeCode; - return 'error'; - }, [isMutating, errors, checkout, checkout?.status]); + const internalFetchStatus = useMemo(() => { + if (fetchStatus === 'error' && error?.errors) { + const errorCodes = error.errors.map(e => e.code); - return ( - - {children} - - ); -}; + if (errorCodes.includes('missing_payer_email')) { + return 'missing_payer_email'; + } -const Stage = ({ children, name }: { children: React.ReactNode; name: CheckoutStatus }) => { - const ctx = useCheckoutContextRoot(); - if (ctx.status !== name) { + if (errorCodes.includes('invalid_plan_change')) { + return 'invalid_plan_change'; + } + } + + return fetchStatus; + }, [fetchStatus, error]); + + if (internalFetchStatus !== status) { return null; } return children; }; -export { Root, Stage }; +export { Root, Stage, FetchStatus }; diff --git a/packages/clerk-js/src/ui/components/Checkout/index.tsx b/packages/clerk-js/src/ui/components/Checkout/index.tsx index 67c37daecc4..251510e75b4 100644 --- a/packages/clerk-js/src/ui/components/Checkout/index.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/index.tsx @@ -23,31 +23,33 @@ export const Checkout = (props: __internal_CheckoutProps) => { - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + diff --git a/packages/clerk-js/src/ui/components/Checkout/parts.tsx b/packages/clerk-js/src/ui/components/Checkout/parts.tsx index 4fd79a91562..4af311a1b53 100644 --- a/packages/clerk-js/src/ui/components/Checkout/parts.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/parts.tsx @@ -11,7 +11,7 @@ import { EmailForm } from '../UserProfile/EmailForm'; import { useCheckoutContextRoot } from './CheckoutPage'; export const GenericError = () => { - const { errors } = useCheckoutContextRoot(); + const { error } = useCheckoutContextRoot(); const { translateError } = useLocalizations(); const { t } = useLocalizations(); return ( @@ -29,7 +29,7 @@ export const GenericError = () => { variant='danger' colorScheme='danger' > - {errors ? translateError(errors[0]) : t(localizationKeys('unstable__errors.form_param_value_invalid'))} + {error ? translateError(error.errors[0]) : t(localizationKeys('unstable__errors.form_param_value_invalid'))} @@ -37,12 +37,12 @@ export const GenericError = () => { }; export const InvalidPlanScreen = () => { - const { errors } = useCheckoutContextRoot(); + const { error } = useCheckoutContextRoot(); const planFromError = useMemo(() => { - const error = errors?.find(e => e.code === 'invalid_plan_change'); - return error?.meta?.plan; - }, [errors]); + const _error = error?.errors.find(e => e.code === 'invalid_plan_change'); + return _error?.meta?.plan; + }, [error]); const { planPeriod } = useCheckoutContext(); @@ -92,7 +92,7 @@ export const InvalidPlanScreen = () => { }; export const AddEmailForm = () => { - const { startCheckout } = useCheckoutContextRoot(); + const { start } = useCheckoutContextRoot(); const { setIsOpen } = useDrawerContext(); return ( @@ -105,7 +105,7 @@ export const AddEmailForm = () => { start().catch(() => null)} onReset={() => setIsOpen(false)} disableAutoFocus /> diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index e9d80343a6f..8a4946730c0 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -8,3 +8,4 @@ export { useUser } from './useUser'; export { useClerk } from './useClerk'; export { useDeepEqualMemo, isDeeplyEqual } from './useDeepEqualMemo'; export { useReverification } from './useReverification'; +export { useCheckout as __experimental_useCheckout } from './useCheckout'; diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts new file mode 100644 index 00000000000..900f8e62ae4 --- /dev/null +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -0,0 +1,129 @@ +import type { CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, ConfirmCheckoutParams } from '@clerk/types'; +import { useCallback, useMemo } from 'react'; +import useSWRMutation from 'swr/mutation'; + +import type { ClerkAPIResponseError } from '../..'; +import { useSWR } from '../clerk-swr'; +import { useClerk } from './useClerk'; +import { useOrganization } from './useOrganization'; +import { useSession } from './useSession'; +import { useUser } from './useUser'; + +type CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; + +type UseCheckoutReturn = { + checkout: CommerceCheckoutResource | undefined; + confirm: (params: ConfirmCheckoutParams) => Promise; + start: () => Promise; + isStarting: boolean; + isConfirming: boolean; + error: ClerkAPIResponseError | undefined; + status: CheckoutStatus; + clear: () => void; + finalize: (params: { redirectUrl?: string }) => void; + fetchStatus: 'idle' | 'fetching' | 'error'; +}; + +type UseCheckoutOptions = { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; +}; + +export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { + const { for: forOrganization, planId, planPeriod } = options; + const clerk = useClerk(); + const { organization } = useOrganization(); + const { user } = useUser(); + const { session } = useSession(); + + const cacheKey = { + key: `commerce-checkout`, + userId: user?.id, + arguments: { + ...(forOrganization === 'organization' ? { orgId: organization?.id } : {}), + planId, + planPeriod, + }, + }; + + // Manually handle the cache + const { data: checkout, mutate } = useSWR(cacheKey); + + // Use `useSWRMutation` to avoid revalidations on stale-data/focus etc. + const { + trigger: start, + isMutating: isStarting, + error, + } = useSWRMutation( + cacheKey, + key => clerk.billing?.startCheckout(key.arguments), + { + throwOnError: true, + onSuccess: data => { + void mutate(data, false); + }, + }, + ); + + const cacheKeyConfirm = { + key: `commerce-checkout-confirm`, + userId: user?.id, + checkoutId: checkout?.id, + }; + + const { + trigger: confirm, + isMutating: isConfirming, + error: confirmError, + } = useSWRMutation( + cacheKeyConfirm, + // @ts-expect-error things are typed as optional + (_, { arg }) => checkout?.confirm(arg), + { + throwOnError: true, + onSuccess: data => { + void mutate(data, false); + }, + }, + ); + + const fetchStatus = useMemo(() => { + if (isStarting || isConfirming) return 'fetching'; + if (error || confirmError) return 'error'; + return 'idle'; + }, [isStarting, isConfirming, error, confirmError]); + + const finalize = useCallback( + ({ redirectUrl }: { redirectUrl?: string }) => { + void clerk.setActive({ session: session?.id, redirectUrl }); + }, + [clerk, session?.id], + ); + + const clear = useCallback(() => { + void mutate(undefined, false); + }, [mutate]); + + const status = useMemo(() => { + const completedCode = 'completed'; + if (checkout?.status === completedCode) return 'completed'; + if (checkout) { + return 'awaiting_confirmation'; + } + return 'awaiting_initialization'; + }, [checkout, checkout?.status]); + + return { + checkout, + start, + isStarting, + isConfirming, + error: error || confirmError, + status, + fetchStatus, + confirm, + clear, + finalize, + }; +}; diff --git a/packages/testing/src/playwright/unstable/page-objects/checkout.ts b/packages/testing/src/playwright/unstable/page-objects/checkout.ts index 07e8b75e496..3aa123ee3d1 100644 --- a/packages/testing/src/playwright/unstable/page-objects/checkout.ts +++ b/packages/testing/src/playwright/unstable/page-objects/checkout.ts @@ -28,11 +28,8 @@ export const createCheckoutPageObject = (testArgs: { page: EnhancedPage }) => { await frame.getByLabel('Country').selectOption(card.country); await frame.getByLabel('ZIP code').fill(card.zip); }, - waitForStipeElements: async () => { - return page - .frameLocator('iframe[src*="elements-inner-payment"]') - .getByLabel('Card number') - .waitFor({ state: 'visible' }); + waitForStipeElements: async ({ state = 'visible' }: { state?: 'visible' | 'hidden' } = {}) => { + return page.frameLocator('iframe[src*="elements-inner-payment"]').getByLabel('Card number').waitFor({ state }); }, clickPayOrSubscribe: async () => { await self.root.getByRole('button', { name: /subscribe|pay\s\$/i }).click(); From f000508991e5009fd55d53befe938fa5aa063753 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 24 Jun 2025 16:14:01 +0300 Subject: [PATCH 02/36] wip nested useCheckout --- .../components/Checkout/CheckoutComplete.tsx | 9 +++-- .../ui/components/Checkout/CheckoutForm.tsx | 28 ++++++++++----- .../ui/components/Checkout/CheckoutPage.tsx | 36 ++++++++++++------- .../src/ui/components/Checkout/parts.tsx | 26 ++++++++++---- 4 files changed, 70 insertions(+), 29 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index 38fd00deb0e..e48bceeee08 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -1,3 +1,4 @@ +import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react'; import { useEffect, useRef, useState } from 'react'; import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; @@ -9,7 +10,6 @@ import { transitionDurationValues, transitionTiming } from '../../foundations/tr import { usePrefersReducedMotion } from '../../hooks'; import { useRouter } from '../../router'; import { formatDate } from '../../utils'; -import { useCheckoutContextRoot } from './CheckoutPage'; const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); const lerp = (start: number, end: number, amt: number) => start + (end - start) * amt; @@ -18,7 +18,12 @@ export const CheckoutComplete = () => { const router = useRouter(); const { setIsOpen } = useDrawerContext(); const { newSubscriptionRedirectUrl } = useCheckoutContext(); - const { checkout } = useCheckoutContextRoot(); + const { planId, planPeriod, subscriberType } = useCheckoutContext(); + const { checkout } = useCheckout({ + for: subscriberType === 'org' ? 'organization' : undefined, + planId: planId!, + planPeriod: planPeriod!, + }); const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 }); const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 }); diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index b4b2fc9dbc9..8405461a7a2 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -1,4 +1,4 @@ -import { useOrganization } from '@clerk/shared/react'; +import { __experimental_useCheckout as useCheckout, useOrganization } from '@clerk/shared/react'; import type { CommerceCheckoutResource, CommerceMoney, @@ -23,21 +23,24 @@ import { ChevronUpDown, InformationCircle } from '../../icons'; import { handleError } from '../../utils'; import * as AddPaymentSource from '../PaymentSources/AddPaymentSource'; import { PaymentSourceRow } from '../PaymentSources/PaymentSourceRow'; -import { useCheckoutContextRoot } from './CheckoutPage'; type PaymentMethodSource = 'existing' | 'new'; const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); export const CheckoutForm = withCardStateProvider(() => { - const ctx = useCheckoutContextRoot(); - const { checkout } = ctx; + const { planId, planPeriod, subscriberType } = useCheckoutContext(); + const { checkout } = useCheckout({ + for: subscriberType === 'org' ? 'organization' : undefined, + planId: planId!, + planPeriod: planPeriod!, + }); if (!checkout) { return null; } - const { plan, planPeriod, totals, isImmediatePlanChange } = checkout; + const { plan, totals, isImmediatePlanChange } = checkout; const showCredits = !!totals.credit?.amount && totals.credit.amount > 0; const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0; const showDowngradeInfo = !isImmediatePlanChange; @@ -122,8 +125,12 @@ export const CheckoutForm = withCardStateProvider(() => { const useCheckoutMutations = () => { const { organization } = useOrganization(); - const { subscriberType, onSubscriptionComplete } = useCheckoutContext(); - const { confirm, checkout } = useCheckoutContextRoot(); + const { planId, planPeriod, subscriberType, onSubscriptionComplete } = useCheckoutContext(); + const { checkout, confirm } = useCheckout({ + for: subscriberType === 'org' ? 'organization' : undefined, + planId: planId!, + planPeriod: planPeriod!, + }); const card = useCardState(); if (!checkout) { @@ -285,7 +292,12 @@ export const PayWithTestPaymentSource = () => { const AddPaymentSourceForCheckout = withCardStateProvider(() => { const { addPaymentSourceAndPay } = useCheckoutMutations(); - const { checkout } = useCheckoutContextRoot(); + const { planId, planPeriod, subscriberType } = useCheckoutContext(); + const { checkout } = useCheckout({ + for: subscriberType === 'org' ? 'organization' : undefined, + planId: planId!, + planPeriod: planPeriod!, + }); if (!checkout) { return null; diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx index 9aa61e32212..90c283e2581 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -1,17 +1,17 @@ import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react'; -import { createContext, useContext, useEffect, useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useCheckoutContext } from '@/ui/contexts/components'; -const CheckoutContextRoot = createContext | null>(null); +// const CheckoutContextRoot = createContext | null>(null); -export const useCheckoutContextRoot = () => { - const ctx = useContext(CheckoutContextRoot); - if (!ctx) { - throw new Error('CheckoutContextRoot not found'); - } - return ctx; -}; +// export const useCheckoutContextRoot = () => { +// const ctx = useContext(CheckoutContextRoot); +// if (!ctx) { +// throw new Error('CheckoutContextRoot not found'); +// } +// return ctx; +// }; const Root = ({ children }: { children: React.ReactNode }) => { const { planId, planPeriod, subscriberType } = useCheckoutContext(); @@ -26,12 +26,17 @@ const Root = ({ children }: { children: React.ReactNode }) => { return checkout.clear; }, []); - return {children}; + return <>{children}; }; const Stage = ({ children, name }: { children: React.ReactNode; name: ReturnType['status'] }) => { - const ctx = useCheckoutContextRoot(); - if (ctx.status !== name) { + const { planId, planPeriod, subscriberType } = useCheckoutContext(); + const { status } = useCheckout({ + for: subscriberType === 'org' ? 'organization' : undefined, + planId: planId!, + planPeriod: planPeriod!, + }); + if (status !== name) { return null; } return children; @@ -44,7 +49,12 @@ const FetchStatus = ({ children: React.ReactNode; status: 'idle' | 'fetching' | 'error' | 'invalid_plan_change' | 'missing_payer_email'; }) => { - const { fetchStatus, error } = useCheckoutContextRoot(); + const { planId, planPeriod, subscriberType } = useCheckoutContext(); + const { fetchStatus, error } = useCheckout({ + for: subscriberType === 'org' ? 'organization' : undefined, + planId: planId!, + planPeriod: planPeriod!, + }); const internalFetchStatus = useMemo(() => { if (fetchStatus === 'error' && error?.errors) { diff --git a/packages/clerk-js/src/ui/components/Checkout/parts.tsx b/packages/clerk-js/src/ui/components/Checkout/parts.tsx index 4af311a1b53..934a1f5ad15 100644 --- a/packages/clerk-js/src/ui/components/Checkout/parts.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/parts.tsx @@ -1,3 +1,4 @@ +import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react'; import { useMemo } from 'react'; import { Alert } from '@/ui/elements/Alert'; @@ -8,10 +9,15 @@ import { useCheckoutContext } from '../../contexts'; import { Box, descriptors, Flex, localizationKeys, useLocalizations } from '../../customizables'; // TODO(@COMMERCE): Is this causing bundle size issues ? import { EmailForm } from '../UserProfile/EmailForm'; -import { useCheckoutContextRoot } from './CheckoutPage'; export const GenericError = () => { - const { error } = useCheckoutContextRoot(); + const { planId, planPeriod, subscriberType } = useCheckoutContext(); + const { error } = useCheckout({ + for: subscriberType === 'org' ? 'organization' : undefined, + planId: planId!, + planPeriod: planPeriod!, + }); + const { translateError } = useLocalizations(); const { t } = useLocalizations(); return ( @@ -37,15 +43,18 @@ export const GenericError = () => { }; export const InvalidPlanScreen = () => { - const { error } = useCheckoutContextRoot(); + const { planId, planPeriod, subscriberType } = useCheckoutContext(); + const { error } = useCheckout({ + for: subscriberType === 'org' ? 'organization' : undefined, + planId: planId!, + planPeriod: planPeriod!, + }); const planFromError = useMemo(() => { const _error = error?.errors.find(e => e.code === 'invalid_plan_change'); return _error?.meta?.plan; }, [error]); - const { planPeriod } = useCheckoutContext(); - if (!planFromError) { return null; } @@ -92,7 +101,12 @@ export const InvalidPlanScreen = () => { }; export const AddEmailForm = () => { - const { start } = useCheckoutContextRoot(); + const { planId, planPeriod, subscriberType } = useCheckoutContext(); + const { start } = useCheckout({ + for: subscriberType === 'org' ? 'organization' : undefined, + planId: planId!, + planPeriod: planPeriod!, + }); const { setIsOpen } = useDrawerContext(); return ( From 1689aacae3663d2241f5e7339815cb3202be13fc Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 24 Jun 2025 16:14:14 +0300 Subject: [PATCH 03/36] wip singleton checkout --- .../shared/src/react/hooks/useCheckout.ts | 248 ++++++++++++++---- 1 file changed, 193 insertions(+), 55 deletions(-) diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 900f8e62ae4..d31e89d0007 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -1,9 +1,7 @@ import type { CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, ConfirmCheckoutParams } from '@clerk/types'; -import { useCallback, useMemo } from 'react'; -import useSWRMutation from 'swr/mutation'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ClerkAPIResponseError } from '../..'; -import { useSWR } from '../clerk-swr'; import { useClerk } from './useClerk'; import { useOrganization } from './useOrganization'; import { useSession } from './useSession'; @@ -30,6 +28,152 @@ type UseCheckoutOptions = { planId: string; }; +type CheckoutOperationState = { + isStarting: boolean; + isConfirming: boolean; + error: ClerkAPIResponseError | undefined; +}; + +type CheckoutKey = string; + +type CheckoutGlobalState = { + checkouts: Map; + operations: Map; +}; + +const defaultOperationState: CheckoutOperationState = { + isStarting: false, + isConfirming: false, + error: undefined, +}; + +// Global state manager using a simple class-based singleton +class CheckoutGlobalManager { + private static instance: CheckoutGlobalManager; + private state: CheckoutGlobalState = { + checkouts: new Map(), + operations: new Map(), + }; + private listeners = new Set<() => void>(); + private pendingOperations = new Set(); + + static getInstance(): CheckoutGlobalManager { + if (!CheckoutGlobalManager.instance) { + CheckoutGlobalManager.instance = new CheckoutGlobalManager(); + } + return CheckoutGlobalManager.instance; + } + + private notifyListeners() { + this.listeners.forEach(listener => listener()); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + getCheckout(key: CheckoutKey): CommerceCheckoutResource | undefined { + return this.state.checkouts.get(key); + } + + getOperationState(key: CheckoutKey): CheckoutOperationState { + return this.state.operations.get(key) || defaultOperationState; + } + + private setCheckout(key: CheckoutKey, checkout: CommerceCheckoutResource | undefined): void { + if (checkout) { + this.state.checkouts.set(key, checkout); + } else { + this.state.checkouts.delete(key); + } + this.notifyListeners(); + } + + private updateOperationState(key: CheckoutKey, updates: Partial): void { + const currentState = this.getOperationState(key); + const newState = { ...currentState, ...updates }; + this.state.operations.set(key, newState); + this.notifyListeners(); + } + + async startCheckout( + key: CheckoutKey, + startFn: () => Promise, + ): Promise { + const operationId = `${key}-start`; + + // Prevent duplicate operations + if (this.getOperationState(key).isStarting || this.pendingOperations.has(operationId)) { + throw new Error('Checkout start already in progress'); + } + + this.pendingOperations.add(operationId); + + try { + this.updateOperationState(key, { isStarting: true, error: undefined }); + const result = await startFn(); + this.updateOperationState(key, { isStarting: false, error: undefined }); + this.setCheckout(key, result); + return result; + } catch (error) { + const clerkError = error as ClerkAPIResponseError; + this.updateOperationState(key, { isStarting: false, error: clerkError }); + throw error; + } finally { + this.pendingOperations.delete(operationId); + } + } + + async confirmCheckout( + key: CheckoutKey, + confirmFn: () => Promise, + ): Promise { + const operationId = `${key}-confirm`; + + // Prevent duplicate operations + if (this.getOperationState(key).isConfirming || this.pendingOperations.has(operationId)) { + throw new Error('Checkout confirm already in progress'); + } + + this.pendingOperations.add(operationId); + + try { + this.updateOperationState(key, { isConfirming: true, error: undefined }); + const result = await confirmFn(); + this.updateOperationState(key, { isConfirming: false, error: undefined }); + this.setCheckout(key, result); + return result; + } catch (error) { + const clerkError = error as ClerkAPIResponseError; + this.updateOperationState(key, { isConfirming: false, error: clerkError }); + throw error; + } finally { + this.pendingOperations.delete(operationId); + } + } + + clearCheckout(key: CheckoutKey): void { + this.setCheckout(key, undefined); + this.updateOperationState(key, defaultOperationState); + } +} + +/** + * + */ +function generateCheckoutKey(options: { + userId?: string; + orgId?: string; + planId: string; + planPeriod: string; +}): CheckoutKey { + const { userId, orgId, planId, planPeriod } = options; + return `${userId || 'anonymous'}-${orgId || 'user'}-${planId}-${planPeriod}`; +} + export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { const { for: forOrganization, planId, planPeriod } = options; const clerk = useClerk(); @@ -37,62 +181,56 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { const { user } = useUser(); const { session } = useSession(); - const cacheKey = { - key: `commerce-checkout`, + const manager = CheckoutGlobalManager.getInstance(); + const [, forceUpdate] = useState({}); + + const checkoutKey = generateCheckoutKey({ userId: user?.id, - arguments: { - ...(forOrganization === 'organization' ? { orgId: organization?.id } : {}), - planId, - planPeriod, - }, - }; + orgId: forOrganization === 'organization' ? organization?.id : undefined, + planId, + planPeriod, + }); - // Manually handle the cache - const { data: checkout, mutate } = useSWR(cacheKey); - - // Use `useSWRMutation` to avoid revalidations on stale-data/focus etc. - const { - trigger: start, - isMutating: isStarting, - error, - } = useSWRMutation( - cacheKey, - key => clerk.billing?.startCheckout(key.arguments), - { - throwOnError: true, - onSuccess: data => { - void mutate(data, false); - }, - }, - ); + // Subscribe to global state changes + useEffect(() => { + const unsubscribe = manager.subscribe(() => forceUpdate({})); + return unsubscribe; + }, [manager]); - const cacheKeyConfirm = { - key: `commerce-checkout-confirm`, - userId: user?.id, - checkoutId: checkout?.id, - }; + // Get current state from global manager + const checkout = manager.getCheckout(checkoutKey); + const operationState = manager.getOperationState(checkoutKey); + + const start = useCallback(async (): Promise => { + return manager.startCheckout(checkoutKey, async () => { + const result = await clerk.billing?.startCheckout({ + ...(forOrganization === 'organization' ? { orgId: organization?.id } : {}), + planId, + planPeriod, + }); + if (!result) { + throw new Error('Failed to start checkout'); + } + return result; + }); + }, [manager, checkoutKey, clerk.billing, forOrganization, organization?.id, planId, planPeriod]); + + const confirm = useCallback( + async (params: ConfirmCheckoutParams): Promise => { + if (!checkout) { + throw new Error('No checkout to confirm'); + } - const { - trigger: confirm, - isMutating: isConfirming, - error: confirmError, - } = useSWRMutation( - cacheKeyConfirm, - // @ts-expect-error things are typed as optional - (_, { arg }) => checkout?.confirm(arg), - { - throwOnError: true, - onSuccess: data => { - void mutate(data, false); - }, + return manager.confirmCheckout(checkoutKey, () => checkout.confirm(params)); }, + [manager, checkoutKey, checkout], ); const fetchStatus = useMemo(() => { - if (isStarting || isConfirming) return 'fetching'; - if (error || confirmError) return 'error'; + if (operationState.isStarting || operationState.isConfirming) return 'fetching'; + if (operationState.error) return 'error'; return 'idle'; - }, [isStarting, isConfirming, error, confirmError]); + }, [operationState.isStarting, operationState.isConfirming, operationState.error]); const finalize = useCallback( ({ redirectUrl }: { redirectUrl?: string }) => { @@ -102,8 +240,8 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { ); const clear = useCallback(() => { - void mutate(undefined, false); - }, [mutate]); + manager.clearCheckout(checkoutKey); + }, [manager, checkoutKey]); const status = useMemo(() => { const completedCode = 'completed'; @@ -117,9 +255,9 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { return { checkout, start, - isStarting, - isConfirming, - error: error || confirmError, + isStarting: operationState.isStarting, + isConfirming: operationState.isConfirming, + error: operationState.error, status, fetchStatus, confirm, From a1cea146446d0cba52af9509dae6f3c7d2c1b254 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 25 Jun 2025 17:11:25 +0300 Subject: [PATCH 04/36] remove singleton --- .../shared/src/react/hooks/useCheckout.ts | 259 +++++++++--------- 1 file changed, 123 insertions(+), 136 deletions(-) diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index d31e89d0007..c64c4953b00 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -1,5 +1,5 @@ import type { CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, ConfirmCheckoutParams } from '@clerk/types'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; import type { ClerkAPIResponseError } from '../..'; import { useClerk } from './useClerk'; @@ -28,137 +28,128 @@ type UseCheckoutOptions = { planId: string; }; -type CheckoutOperationState = { +type CheckoutKey = string; + +type CheckoutCacheState = { isStarting: boolean; isConfirming: boolean; - error: ClerkAPIResponseError | undefined; + error: ClerkAPIResponseError | null; + checkout: CommerceCheckoutResource | null; }; -type CheckoutKey = string; +// Global cache state +const globalCheckoutCache = new Map(); -type CheckoutGlobalState = { - checkouts: Map; - operations: Map; -}; +// Global listeners and pending operations keyed by cacheKey +const globalListeners = new Map void>>(); +const globalPendingOperations = new Map>(); -const defaultOperationState: CheckoutOperationState = { +const defaultCacheState: CheckoutCacheState = { isStarting: false, isConfirming: false, - error: undefined, + error: null, + checkout: null, }; -// Global state manager using a simple class-based singleton -class CheckoutGlobalManager { - private static instance: CheckoutGlobalManager; - private state: CheckoutGlobalState = { - checkouts: new Map(), - operations: new Map(), - }; - private listeners = new Set<() => void>(); - private pendingOperations = new Set(); +// Factory function that creates a checkout manager for a specific cache key +/** + * + */ +function createCheckoutManager(cacheKey: CheckoutKey) { + console.log('[createCheckoutManager] initializing', cacheKey); - static getInstance(): CheckoutGlobalManager { - if (!CheckoutGlobalManager.instance) { - CheckoutGlobalManager.instance = new CheckoutGlobalManager(); - } - return CheckoutGlobalManager.instance; + // Get or create listeners for this cacheKey + if (!globalListeners.has(cacheKey)) { + globalListeners.set(cacheKey, new Set()); } - - private notifyListeners() { - this.listeners.forEach(listener => listener()); + if (!globalPendingOperations.has(cacheKey)) { + globalPendingOperations.set(cacheKey, new Set()); } - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- it is handled above + const listeners = globalListeners.get(cacheKey)!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- it is handled above + const pendingOperations = globalPendingOperations.get(cacheKey)!; - getCheckout(key: CheckoutKey): CommerceCheckoutResource | undefined { - return this.state.checkouts.get(key); - } - - getOperationState(key: CheckoutKey): CheckoutOperationState { - return this.state.operations.get(key) || defaultOperationState; - } + const notifyListeners = () => { + listeners.forEach(listener => listener(getCacheState())); + }; - private setCheckout(key: CheckoutKey, checkout: CommerceCheckoutResource | undefined): void { - if (checkout) { - this.state.checkouts.set(key, checkout); - } else { - this.state.checkouts.delete(key); - } - this.notifyListeners(); - } + const getCacheState = (): CheckoutCacheState => { + return globalCheckoutCache.get(cacheKey) || defaultCacheState; + }; - private updateOperationState(key: CheckoutKey, updates: Partial): void { - const currentState = this.getOperationState(key); + const updateCacheState = (updates: Partial): void => { + const currentState = getCacheState(); const newState = { ...currentState, ...updates }; - this.state.operations.set(key, newState); - this.notifyListeners(); - } + globalCheckoutCache.set(cacheKey, newState); + notifyListeners(); + }; - async startCheckout( - key: CheckoutKey, - startFn: () => Promise, - ): Promise { - const operationId = `${key}-start`; + return { + subscribe(listener: (newState: CheckoutCacheState) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, - // Prevent duplicate operations - if (this.getOperationState(key).isStarting || this.pendingOperations.has(operationId)) { - throw new Error('Checkout start already in progress'); - } + getCacheState, - this.pendingOperations.add(operationId); + async startCheckout(startFn: () => Promise): Promise { + const operationId = `${cacheKey}-start`; - try { - this.updateOperationState(key, { isStarting: true, error: undefined }); - const result = await startFn(); - this.updateOperationState(key, { isStarting: false, error: undefined }); - this.setCheckout(key, result); - return result; - } catch (error) { - const clerkError = error as ClerkAPIResponseError; - this.updateOperationState(key, { isStarting: false, error: clerkError }); - throw error; - } finally { - this.pendingOperations.delete(operationId); - } - } + // Prevent duplicate operations + if (getCacheState().isStarting || pendingOperations.has(operationId)) { + // TODO: improve it + throw new Error('Checkout start already in progress'); + } - async confirmCheckout( - key: CheckoutKey, - confirmFn: () => Promise, - ): Promise { - const operationId = `${key}-confirm`; + pendingOperations.add(operationId); + + try { + updateCacheState({ isStarting: true, error: null }); + const result = await startFn(); + updateCacheState({ isStarting: false, error: null, checkout: result }); + return result; + } catch (error) { + const clerkError = error as ClerkAPIResponseError; + updateCacheState({ isStarting: false, error: clerkError }); + throw error; + } finally { + pendingOperations.delete(operationId); + } + }, - // Prevent duplicate operations - if (this.getOperationState(key).isConfirming || this.pendingOperations.has(operationId)) { - throw new Error('Checkout confirm already in progress'); - } + async confirmCheckout(confirmFn: () => Promise): Promise { + const operationId = `${cacheKey}-confirm`; - this.pendingOperations.add(operationId); + // Prevent duplicate operations + if (getCacheState().isConfirming || pendingOperations.has(operationId)) { + // TODO: improve it + throw new Error('Checkout confirm already in progress'); + } - try { - this.updateOperationState(key, { isConfirming: true, error: undefined }); - const result = await confirmFn(); - this.updateOperationState(key, { isConfirming: false, error: undefined }); - this.setCheckout(key, result); - return result; - } catch (error) { - const clerkError = error as ClerkAPIResponseError; - this.updateOperationState(key, { isConfirming: false, error: clerkError }); - throw error; - } finally { - this.pendingOperations.delete(operationId); - } - } + pendingOperations.add(operationId); + + try { + updateCacheState({ isConfirming: true, error: null }); + const result = await confirmFn(); + updateCacheState({ isConfirming: false, error: null, checkout: result }); + return result; + } catch (error) { + const clerkError = error as ClerkAPIResponseError; + updateCacheState({ isConfirming: false, error: clerkError }); + throw error; + } finally { + pendingOperations.delete(operationId); + } + }, - clearCheckout(key: CheckoutKey): void { - this.setCheckout(key, undefined); - this.updateOperationState(key, defaultOperationState); - } + clearCheckout(): void { + updateCacheState(defaultCacheState); + }, + }; } /** @@ -181,8 +172,9 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { const { user } = useUser(); const { session } = useSession(); - const manager = CheckoutGlobalManager.getInstance(); - const [, forceUpdate] = useState({}); + if (!user) { + throw new Error('Clerk: User is not authenticated'); + } const checkoutKey = generateCheckoutKey({ userId: user?.id, @@ -191,46 +183,41 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { planPeriod, }); - // Subscribe to global state changes - useEffect(() => { - const unsubscribe = manager.subscribe(() => forceUpdate({})); - return unsubscribe; - }, [manager]); + const manager = useMemo(() => createCheckoutManager(checkoutKey), [checkoutKey]); - // Get current state from global manager - const checkout = manager.getCheckout(checkoutKey); - const operationState = manager.getOperationState(checkoutKey); + const managerState = useSyncExternalStore( + (...args) => manager.subscribe(...args), + () => manager.getCacheState(), + () => manager.getCacheState(), + ); const start = useCallback(async (): Promise => { - return manager.startCheckout(checkoutKey, async () => { + return manager.startCheckout(async () => { const result = await clerk.billing?.startCheckout({ ...(forOrganization === 'organization' ? { orgId: organization?.id } : {}), planId, planPeriod, }); - if (!result) { - throw new Error('Failed to start checkout'); - } return result; }); - }, [manager, checkoutKey, clerk.billing, forOrganization, organization?.id, planId, planPeriod]); + }, [manager, clerk.billing, forOrganization, organization?.id, planId, planPeriod]); const confirm = useCallback( async (params: ConfirmCheckoutParams): Promise => { - if (!checkout) { + if (!manager.getCacheState().checkout) { throw new Error('No checkout to confirm'); } - - return manager.confirmCheckout(checkoutKey, () => checkout.confirm(params)); + // @ts-ignore Handle this + return manager.confirmCheckout(() => manager.getCacheState().checkout.confirm(params)); }, - [manager, checkoutKey, checkout], + [manager], ); const fetchStatus = useMemo(() => { - if (operationState.isStarting || operationState.isConfirming) return 'fetching'; - if (operationState.error) return 'error'; + if (managerState.isStarting || managerState.isConfirming) return 'fetching'; + if (managerState.error) return 'error'; return 'idle'; - }, [operationState.isStarting, operationState.isConfirming, operationState.error]); + }, [managerState.isStarting, managerState.isConfirming, managerState.error]); const finalize = useCallback( ({ redirectUrl }: { redirectUrl?: string }) => { @@ -240,24 +227,24 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { ); const clear = useCallback(() => { - manager.clearCheckout(checkoutKey); - }, [manager, checkoutKey]); + manager.clearCheckout(); + }, [manager]); const status = useMemo(() => { const completedCode = 'completed'; - if (checkout?.status === completedCode) return 'completed'; - if (checkout) { + if (managerState.checkout?.status === completedCode) return 'completed'; + if (managerState.checkout) { return 'awaiting_confirmation'; } return 'awaiting_initialization'; - }, [checkout, checkout?.status]); + }, [managerState.checkout?.status]); return { - checkout, + checkout: managerState.checkout || undefined, start, - isStarting: operationState.isStarting, - isConfirming: operationState.isConfirming, - error: operationState.error, + isStarting: managerState.isStarting, + isConfirming: managerState.isConfirming, + error: managerState.error || undefined, status, fetchStatus, confirm, From f46e630faaa269acf76388d9c648bcd56187ade3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 25 Jun 2025 18:33:30 +0300 Subject: [PATCH 05/36] flatten properties --- .../components/Checkout/CheckoutComplete.tsx | 2 +- .../ui/components/Checkout/CheckoutForm.tsx | 22 +++---- .../shared/src/react/hooks/useCheckout.ts | 57 ++++++++++++++++++- 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index e48bceeee08..21a4adc7151 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -19,7 +19,7 @@ export const CheckoutComplete = () => { const { setIsOpen } = useDrawerContext(); const { newSubscriptionRedirectUrl } = useCheckoutContext(); const { planId, planPeriod, subscriberType } = useCheckoutContext(); - const { checkout } = useCheckout({ + const checkout = useCheckout({ for: subscriberType === 'org' ? 'organization' : undefined, planId: planId!, planPeriod: planPeriod!, diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 8405461a7a2..132bbefbba7 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -30,17 +30,17 @@ const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); export const CheckoutForm = withCardStateProvider(() => { const { planId, planPeriod, subscriberType } = useCheckoutContext(); - const { checkout } = useCheckout({ + const checkout = useCheckout({ for: subscriberType === 'org' ? 'organization' : undefined, planId: planId!, planPeriod: planPeriod!, }); + const { id, plan, totals, isImmediatePlanChange, __internal_checkout } = checkout; - if (!checkout) { + if (!id) { return null; } - const { plan, totals, isImmediatePlanChange } = checkout; const showCredits = !!totals.credit?.amount && totals.credit.amount > 0; const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0; const showDowngradeInfo = !isImmediatePlanChange; @@ -118,7 +118,7 @@ export const CheckoutForm = withCardStateProvider(() => { )} - + ); }); @@ -126,14 +126,14 @@ export const CheckoutForm = withCardStateProvider(() => { const useCheckoutMutations = () => { const { organization } = useOrganization(); const { planId, planPeriod, subscriberType, onSubscriptionComplete } = useCheckoutContext(); - const { checkout, confirm } = useCheckout({ + const { id, confirm } = useCheckout({ for: subscriberType === 'org' ? 'organization' : undefined, planId: planId!, planPeriod: planPeriod!, }); const card = useCardState(); - if (!checkout) { + if (!id) { throw new Error('Checkout not found'); } @@ -293,29 +293,29 @@ export const PayWithTestPaymentSource = () => { const AddPaymentSourceForCheckout = withCardStateProvider(() => { const { addPaymentSourceAndPay } = useCheckoutMutations(); const { planId, planPeriod, subscriberType } = useCheckoutContext(); - const { checkout } = useCheckout({ + const { id, __internal_checkout, totals } = useCheckout({ for: subscriberType === 'org' ? 'organization' : undefined, planId: planId!, planPeriod: planPeriod!, }); - if (!checkout) { + if (!id) { return null; } return ( - {checkout.totals.totalDueNow.amount > 0 ? ( + {totals.totalDueNow.amount > 0 ? ( ) : ( diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index c64c4953b00..40f6eba32ae 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -9,8 +9,38 @@ import { useUser } from './useUser'; type CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; -type UseCheckoutReturn = { - checkout: CommerceCheckoutResource | undefined; +/** + * Utility type that removes function properties from a type. + */ +type RemoveFunctions = { + [K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K]; +}; + +/** + * Utility type that makes all properties nullable. + */ +type Nullable = { + [K in keyof T]: null; +}; + +type CheckoutProperties = Omit< + RemoveFunctions, + 'paymentSource' | 'plan' | 'pathRoot' | 'reload' | 'confirm' +> & { + plan: RemoveFunctions; + paymentSource: RemoveFunctions; + __internal_checkout: CommerceCheckoutResource; +}; +type NullableCheckoutProperties = Nullable< + Omit, 'paymentSource' | 'plan' | 'pathRoot' | 'reload' | 'confirm'> +> & { + plan: null; + paymentSource: null; + __internal_checkout: null; +}; + +type UseCheckoutReturn = (CheckoutProperties | NullableCheckoutProperties) & { + // checkout: CommerceCheckoutResource | undefined; confirm: (params: ConfirmCheckoutParams) => Promise; start: () => Promise; isStarting: boolean; @@ -239,8 +269,29 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { return 'awaiting_initialization'; }, [managerState.checkout?.status]); + const properties = useMemo(() => { + if (!managerState.checkout) { + return { + id: null, + externalClientSecret: null, + externalGatewayId: null, + statement_id: null, + status: null, + totals: null, + isImmediatePlanChange: null, + planPeriod: null, + plan: null, + paymentSource: null, + }; + } + const { reload, confirm, pathRoot, ...rest } = managerState.checkout; + return rest; + }, [managerState.checkout]); + return { - checkout: managerState.checkout || undefined, + ...properties, + // @ts-expect-error + __internal_checkout: managerState.checkout, start, isStarting: managerState.isStarting, isConfirming: managerState.isConfirming, From ffbbd0537c5621754f3ff34425b9a2915c671da2 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 25 Jun 2025 19:40:52 +0300 Subject: [PATCH 06/36] remove duplicate code from mutation methods --- .../shared/src/react/hooks/useCheckout.ts | 71 ++++++++----------- 1 file changed, 28 insertions(+), 43 deletions(-) diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 40f6eba32ae..90886f3e181 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -81,13 +81,12 @@ const defaultCacheState: CheckoutCacheState = { checkout: null, }; -// Factory function that creates a checkout manager for a specific cache key /** + * Factory function that creates a checkout manager for a specific cache key. * + * @internal */ function createCheckoutManager(cacheKey: CheckoutKey) { - console.log('[createCheckoutManager] initializing', cacheKey); - // Get or create listeners for this cacheKey if (!globalListeners.has(cacheKey)) { globalListeners.set(cacheKey, new Set()); @@ -126,54 +125,41 @@ function createCheckoutManager(cacheKey: CheckoutKey) { getCacheState, - async startCheckout(startFn: () => Promise): Promise { - const operationId = `${cacheKey}-start`; + // Shared operation handler to eliminate duplication + async executeOperation( + operationType: 'start' | 'confirm', + operationFn: () => Promise, + ): Promise { + const operationId = `${cacheKey}-${operationType}`; + const isRunningField = operationType === 'start' ? 'isStarting' : 'isConfirming'; // Prevent duplicate operations - if (getCacheState().isStarting || pendingOperations.has(operationId)) { - // TODO: improve it - throw new Error('Checkout start already in progress'); + if (getCacheState()[isRunningField] || pendingOperations.has(operationId)) { + throw new Error(`Checkout ${operationType} already in progress`); } pendingOperations.add(operationId); try { - updateCacheState({ isStarting: true, error: null }); - const result = await startFn(); - updateCacheState({ isStarting: false, error: null, checkout: result }); + updateCacheState({ [isRunningField]: true, error: null }); + const result = await operationFn(); + updateCacheState({ [isRunningField]: false, error: null, checkout: result }); return result; } catch (error) { const clerkError = error as ClerkAPIResponseError; - updateCacheState({ isStarting: false, error: clerkError }); + updateCacheState({ [isRunningField]: false, error: clerkError }); throw error; } finally { pendingOperations.delete(operationId); } }, - async confirmCheckout(confirmFn: () => Promise): Promise { - const operationId = `${cacheKey}-confirm`; - - // Prevent duplicate operations - if (getCacheState().isConfirming || pendingOperations.has(operationId)) { - // TODO: improve it - throw new Error('Checkout confirm already in progress'); - } - - pendingOperations.add(operationId); + async startCheckout(startFn: () => Promise): Promise { + return this.executeOperation('start', startFn); + }, - try { - updateCacheState({ isConfirming: true, error: null }); - const result = await confirmFn(); - updateCacheState({ isConfirming: false, error: null, checkout: result }); - return result; - } catch (error) { - const clerkError = error as ClerkAPIResponseError; - updateCacheState({ isConfirming: false, error: clerkError }); - throw error; - } finally { - pendingOperations.delete(operationId); - } + async confirmCheckout(confirmFn: () => Promise): Promise { + return this.executeOperation('confirm', confirmFn); }, clearCheckout(): void { @@ -183,16 +169,11 @@ function createCheckoutManager(cacheKey: CheckoutKey) { } /** - * + * @internal */ -function generateCheckoutKey(options: { - userId?: string; - orgId?: string; - planId: string; - planPeriod: string; -}): CheckoutKey { +function cacheKey(options: { userId: string; orgId?: string; planId: string; planPeriod: string }): CheckoutKey { const { userId, orgId, planId, planPeriod } = options; - return `${userId || 'anonymous'}-${orgId || 'user'}-${planId}-${planPeriod}`; + return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}`; } export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { @@ -206,7 +187,11 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { throw new Error('Clerk: User is not authenticated'); } - const checkoutKey = generateCheckoutKey({ + if (forOrganization === 'organization' && !organization) { + throw new Error('Clerk: Use `setActive` to set the organization'); + } + + const checkoutKey = cacheKey({ userId: user?.id, orgId: forOrganization === 'organization' ? organization?.id : undefined, planId, From 6d6d527c5e5a11b4d1b359a58e545cf9266970e5 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 25 Jun 2025 20:18:04 +0300 Subject: [PATCH 07/36] wip --- .../shared/src/react/hooks/useCheckout.ts | 68 +++++++++++-------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 90886f3e181..49df8ef9588 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -67,12 +67,27 @@ type CheckoutCacheState = { checkout: CommerceCheckoutResource | null; }; -// Global cache state -const globalCheckoutCache = new Map(); +const createManagerCache = () => { + // Global cache state + const cache = new Map(); + // Global listeners and pending operations keyed by cacheKey + const listeners = new Map void>>(); + const pendingOperations = new Map>(); -// Global listeners and pending operations keyed by cacheKey -const globalListeners = new Map void>>(); -const globalPendingOperations = new Map>(); + return { + cache, + listeners, + pendingOperations, + safeGet>(key: K, map: Map): NonNullable { + if (!map.has(key)) { + map.set(key, new Set() as V); + } + // We know this is non-null because we just set it above if it didn't exist + return map.get(key) as NonNullable; + }, + }; +}; +const managerCache = createManagerCache(); const defaultCacheState: CheckoutCacheState = { isStarting: false, @@ -88,30 +103,21 @@ const defaultCacheState: CheckoutCacheState = { */ function createCheckoutManager(cacheKey: CheckoutKey) { // Get or create listeners for this cacheKey - if (!globalListeners.has(cacheKey)) { - globalListeners.set(cacheKey, new Set()); - } - if (!globalPendingOperations.has(cacheKey)) { - globalPendingOperations.set(cacheKey, new Set()); - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- it is handled above - const listeners = globalListeners.get(cacheKey)!; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- it is handled above - const pendingOperations = globalPendingOperations.get(cacheKey)!; + const listeners = managerCache.safeGet(cacheKey, managerCache.listeners); + const pendingOperations = managerCache.safeGet(cacheKey, managerCache.pendingOperations); const notifyListeners = () => { listeners.forEach(listener => listener(getCacheState())); }; const getCacheState = (): CheckoutCacheState => { - return globalCheckoutCache.get(cacheKey) || defaultCacheState; + return managerCache.cache.get(cacheKey) || defaultCacheState; }; const updateCacheState = (updates: Partial): void => { const currentState = getCacheState(); const newState = { ...currentState, ...updates }; - globalCheckoutCache.set(cacheKey, newState); + managerCache.cache.set(cacheKey, newState); notifyListeners(); }; @@ -154,13 +160,13 @@ function createCheckoutManager(cacheKey: CheckoutKey) { } }, - async startCheckout(startFn: () => Promise): Promise { - return this.executeOperation('start', startFn); - }, + // async startCheckout(startFn: () => Promise): Promise { + // return this.executeOperation('start', startFn); + // }, - async confirmCheckout(confirmFn: () => Promise): Promise { - return this.executeOperation('confirm', confirmFn); - }, + // async confirmCheckout(confirmFn: () => Promise): Promise { + // return this.executeOperation('confirm', confirmFn); + // }, clearCheckout(): void { updateCacheState(defaultCacheState); @@ -207,7 +213,7 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { ); const start = useCallback(async (): Promise => { - return manager.startCheckout(async () => { + return manager.executeOperation('start', async () => { const result = await clerk.billing?.startCheckout({ ...(forOrganization === 'organization' ? { orgId: organization?.id } : {}), planId, @@ -219,11 +225,13 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { const confirm = useCallback( async (params: ConfirmCheckoutParams): Promise => { - if (!manager.getCacheState().checkout) { - throw new Error('No checkout to confirm'); - } - // @ts-ignore Handle this - return manager.confirmCheckout(() => manager.getCacheState().checkout.confirm(params)); + return manager.executeOperation('confirm', async () => { + const checkout = manager.getCacheState().checkout; + if (!checkout) { + throw new Error('Clerk: Call `start` before `confirm`'); + } + return checkout.confirm(params); + }); }, [manager], ); From 87d3e6a03a7935e99af8e2383e316722cee6d896 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 25 Jun 2025 20:30:19 +0300 Subject: [PATCH 08/36] derive state in manager --- .../shared/src/react/hooks/useCheckout.ts | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 49df8ef9588..7af1c914082 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -40,7 +40,6 @@ type NullableCheckoutProperties = Nullable< }; type UseCheckoutReturn = (CheckoutProperties | NullableCheckoutProperties) & { - // checkout: CommerceCheckoutResource | undefined; confirm: (params: ConfirmCheckoutParams) => Promise; start: () => Promise; isStarting: boolean; @@ -65,12 +64,12 @@ type CheckoutCacheState = { isConfirming: boolean; error: ClerkAPIResponseError | null; checkout: CommerceCheckoutResource | null; + fetchStatus: 'idle' | 'fetching' | 'error'; + status: CheckoutStatus; }; const createManagerCache = () => { - // Global cache state const cache = new Map(); - // Global listeners and pending operations keyed by cacheKey const listeners = new Map void>>(); const pendingOperations = new Map>(); @@ -87,14 +86,41 @@ const createManagerCache = () => { }, }; }; + const managerCache = createManagerCache(); -const defaultCacheState: CheckoutCacheState = { +/** + * Derives the checkout state from the base state. + * + * @internal + */ +function deriveCheckoutState(baseState: Omit): CheckoutCacheState { + const fetchStatus = (() => { + if (baseState.isStarting || baseState.isConfirming) return 'fetching' as const; + if (baseState.error) return 'error' as const; + return 'idle' as const; + })(); + + const status = (() => { + const completedCode = 'completed'; + if (baseState.checkout?.status === completedCode) return 'completed' as const; + if (baseState.checkout) return 'awaiting_confirmation' as const; + return 'awaiting_initialization' as const; + })(); + + return { + ...baseState, + fetchStatus, + status, + }; +} + +const defaultCacheState: CheckoutCacheState = deriveCheckoutState({ isStarting: false, isConfirming: false, error: null, checkout: null, -}; +}); /** * Factory function that creates a checkout manager for a specific cache key. @@ -114,9 +140,10 @@ function createCheckoutManager(cacheKey: CheckoutKey) { return managerCache.cache.get(cacheKey) || defaultCacheState; }; - const updateCacheState = (updates: Partial): void => { + const updateCacheState = (updates: Partial>): void => { const currentState = getCacheState(); - const newState = { ...currentState, ...updates }; + const baseState = { ...currentState, ...updates }; + const newState = deriveCheckoutState(baseState); managerCache.cache.set(cacheKey, newState); notifyListeners(); }; @@ -160,14 +187,6 @@ function createCheckoutManager(cacheKey: CheckoutKey) { } }, - // async startCheckout(startFn: () => Promise): Promise { - // return this.executeOperation('start', startFn); - // }, - - // async confirmCheckout(confirmFn: () => Promise): Promise { - // return this.executeOperation('confirm', confirmFn); - // }, - clearCheckout(): void { updateCacheState(defaultCacheState); }, @@ -236,12 +255,6 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { [manager], ); - const fetchStatus = useMemo(() => { - if (managerState.isStarting || managerState.isConfirming) return 'fetching'; - if (managerState.error) return 'error'; - return 'idle'; - }, [managerState.isStarting, managerState.isConfirming, managerState.error]); - const finalize = useCallback( ({ redirectUrl }: { redirectUrl?: string }) => { void clerk.setActive({ session: session?.id, redirectUrl }); @@ -253,15 +266,6 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { manager.clearCheckout(); }, [manager]); - const status = useMemo(() => { - const completedCode = 'completed'; - if (managerState.checkout?.status === completedCode) return 'completed'; - if (managerState.checkout) { - return 'awaiting_confirmation'; - } - return 'awaiting_initialization'; - }, [managerState.checkout?.status]); - const properties = useMemo(() => { if (!managerState.checkout) { return { @@ -277,20 +281,27 @@ export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { paymentSource: null, }; } - const { reload, confirm, pathRoot, ...rest } = managerState.checkout; + const { + // eslint-disable-next-line @typescript-eslint/unbound-method + reload, + confirm, + pathRoot, + // All the above need to be removed from the properties + ...rest + } = managerState.checkout; return rest; }, [managerState.checkout]); return { ...properties, - // @ts-expect-error + // @ts-expect-error - checkout can be null but UseCheckoutReturn expects it to be CommerceCheckoutResource | undefined __internal_checkout: managerState.checkout, start, isStarting: managerState.isStarting, isConfirming: managerState.isConfirming, error: managerState.error || undefined, - status, - fetchStatus, + status: managerState.status, + fetchStatus: managerState.fetchStatus, confirm, clear, finalize, From 752a2443a1e4faa41137e5b89000d2668d8f3fd4 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 25 Jun 2025 21:08:50 +0300 Subject: [PATCH 09/36] introduce CheckoutProvider --- .../components/Checkout/CheckoutComplete.tsx | 7 +--- .../ui/components/Checkout/CheckoutForm.tsx | 24 +++-------- .../ui/components/Checkout/CheckoutPage.tsx | 41 +++++++++---------- .../src/ui/components/Checkout/parts.tsx | 22 ++-------- .../ui/contexts/CoreClerkContextWrapper.tsx | 10 ++++- .../src/contexts/ClerkContextProvider.tsx | 17 +++++++- packages/shared/src/react/contexts.tsx | 21 ++++++++++ .../shared/src/react/hooks/useCheckout.ts | 15 +++++-- packages/shared/src/react/index.ts | 1 + 9 files changed, 88 insertions(+), 70 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index 21a4adc7151..77354e263e4 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -18,12 +18,7 @@ export const CheckoutComplete = () => { const router = useRouter(); const { setIsOpen } = useDrawerContext(); const { newSubscriptionRedirectUrl } = useCheckoutContext(); - const { planId, planPeriod, subscriberType } = useCheckoutContext(); - const checkout = useCheckout({ - for: subscriberType === 'org' ? 'organization' : undefined, - planId: planId!, - planPeriod: planPeriod!, - }); + const checkout = useCheckout(); const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 }); const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 }); diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 132bbefbba7..b1967a56a7e 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -29,13 +29,8 @@ type PaymentMethodSource = 'existing' | 'new'; const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); export const CheckoutForm = withCardStateProvider(() => { - const { planId, planPeriod, subscriberType } = useCheckoutContext(); - const checkout = useCheckout({ - for: subscriberType === 'org' ? 'organization' : undefined, - planId: planId!, - planPeriod: planPeriod!, - }); - const { id, plan, totals, isImmediatePlanChange, __internal_checkout } = checkout; + const checkout = useCheckout(); + const { id, plan, totals, isImmediatePlanChange, __internal_checkout, planPeriod } = checkout; if (!id) { return null; @@ -125,12 +120,8 @@ export const CheckoutForm = withCardStateProvider(() => { const useCheckoutMutations = () => { const { organization } = useOrganization(); - const { planId, planPeriod, subscriberType, onSubscriptionComplete } = useCheckoutContext(); - const { id, confirm } = useCheckout({ - for: subscriberType === 'org' ? 'organization' : undefined, - planId: planId!, - planPeriod: planPeriod!, - }); + const { subscriberType, onSubscriptionComplete } = useCheckoutContext(); + const { id, confirm } = useCheckout(); const card = useCardState(); if (!id) { @@ -292,12 +283,7 @@ export const PayWithTestPaymentSource = () => { const AddPaymentSourceForCheckout = withCardStateProvider(() => { const { addPaymentSourceAndPay } = useCheckoutMutations(); - const { planId, planPeriod, subscriberType } = useCheckoutContext(); - const { id, __internal_checkout, totals } = useCheckout({ - for: subscriberType === 'org' ? 'organization' : undefined, - planId: planId!, - planPeriod: planPeriod!, - }); + const { id, __internal_checkout, totals } = useCheckout(); if (!id) { return null; diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx index 90c283e2581..6d2762bce22 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -1,4 +1,4 @@ -import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react'; +import { __experimental_useCheckout as useCheckout, CheckoutProvider } from '@clerk/shared/react'; import { useEffect, useMemo } from 'react'; import { useCheckoutContext } from '@/ui/contexts/components'; @@ -13,29 +13,33 @@ import { useCheckoutContext } from '@/ui/contexts/components'; // return ctx; // }; -const Root = ({ children }: { children: React.ReactNode }) => { - const { planId, planPeriod, subscriberType } = useCheckoutContext(); - const checkout = useCheckout({ - for: subscriberType === 'org' ? 'organization' : undefined, - planId: planId!, - planPeriod: planPeriod!, - }); +const Initiator = () => { + const checkout = useCheckout(); useEffect(() => { checkout.start().catch(() => null); return checkout.clear; }, []); + return null; +}; + +const Root = ({ children }: { children: React.ReactNode }) => { + const { planId, planPeriod, subscriberType } = useCheckoutContext(); - return <>{children}; + return ( + + + {children} + + ); }; const Stage = ({ children, name }: { children: React.ReactNode; name: ReturnType['status'] }) => { - const { planId, planPeriod, subscriberType } = useCheckoutContext(); - const { status } = useCheckout({ - for: subscriberType === 'org' ? 'organization' : undefined, - planId: planId!, - planPeriod: planPeriod!, - }); + const { status } = useCheckout(); if (status !== name) { return null; } @@ -49,12 +53,7 @@ const FetchStatus = ({ children: React.ReactNode; status: 'idle' | 'fetching' | 'error' | 'invalid_plan_change' | 'missing_payer_email'; }) => { - const { planId, planPeriod, subscriberType } = useCheckoutContext(); - const { fetchStatus, error } = useCheckout({ - for: subscriberType === 'org' ? 'organization' : undefined, - planId: planId!, - planPeriod: planPeriod!, - }); + const { fetchStatus, error } = useCheckout(); const internalFetchStatus = useMemo(() => { if (fetchStatus === 'error' && error?.errors) { diff --git a/packages/clerk-js/src/ui/components/Checkout/parts.tsx b/packages/clerk-js/src/ui/components/Checkout/parts.tsx index 934a1f5ad15..3bbcd29e5f1 100644 --- a/packages/clerk-js/src/ui/components/Checkout/parts.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/parts.tsx @@ -11,12 +11,7 @@ import { Box, descriptors, Flex, localizationKeys, useLocalizations } from '../. import { EmailForm } from '../UserProfile/EmailForm'; export const GenericError = () => { - const { planId, planPeriod, subscriberType } = useCheckoutContext(); - const { error } = useCheckout({ - for: subscriberType === 'org' ? 'organization' : undefined, - planId: planId!, - planPeriod: planPeriod!, - }); + const { error } = useCheckout(); const { translateError } = useLocalizations(); const { t } = useLocalizations(); @@ -43,12 +38,8 @@ export const GenericError = () => { }; export const InvalidPlanScreen = () => { - const { planId, planPeriod, subscriberType } = useCheckoutContext(); - const { error } = useCheckout({ - for: subscriberType === 'org' ? 'organization' : undefined, - planId: planId!, - planPeriod: planPeriod!, - }); + const { planPeriod } = useCheckoutContext(); + const { error } = useCheckout(); const planFromError = useMemo(() => { const _error = error?.errors.find(e => e.code === 'invalid_plan_change'); @@ -101,12 +92,7 @@ export const InvalidPlanScreen = () => { }; export const AddEmailForm = () => { - const { planId, planPeriod, subscriberType } = useCheckoutContext(); - const { start } = useCheckout({ - for: subscriberType === 'org' ? 'organization' : undefined, - planId: planId!, - planPeriod: planPeriod!, - }); + const { start } = useCheckout(); const { setIsOpen } = useDrawerContext(); return ( diff --git a/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx b/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx index 78b186bba95..0bbabbf6f1e 100644 --- a/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx +++ b/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx @@ -1,4 +1,5 @@ import { + CheckoutProvider, ClerkInstanceContext, ClientContext, OrganizationProvider, @@ -54,7 +55,14 @@ export function CoreClerkContextWrapper(props: CoreClerkContextWrapperProps): JS {...organizationCtx.value} swrConfig={props.swrConfig} > - {props.children} + + + {props.children} + + diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index a86a84a47e1..c1e6b855dd5 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -1,5 +1,11 @@ import { deriveState } from '@clerk/shared/deriveState'; -import { ClientContext, OrganizationProvider, SessionContext, UserContext } from '@clerk/shared/react'; +import { + CheckoutProvider, + ClientContext, + OrganizationProvider, + SessionContext, + UserContext, +} from '@clerk/shared/react'; import type { ClientResource, InitialState, Resources } from '@clerk/types'; import React from 'react'; @@ -89,7 +95,14 @@ export function ClerkContextProvider(props: ClerkContextProvider) { - {children} + + + {children} + + diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index e3170145c09..3565d87cd0e 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -3,6 +3,7 @@ import type { ClerkOptions, ClientResource, + CommerceSubscriptionPlanPeriod, LoadedClerk, OrganizationResource, SignedInSessionResource, @@ -23,6 +24,21 @@ const [SessionContext, useSessionContext] = createContextAndHook({}); +type UseCheckoutOptions = { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; +}; + +const [CheckoutContext, useCheckoutContext] = createContextAndHook('CheckoutContext'); + +const CheckoutProvider = ({ children, ...rest }: PropsWithChildren) => { + return {children}; +}; + +/** + * @internal + */ function useOptionsContext(): ClerkOptions { const context = React.useContext(OptionsContext); if (context === undefined) { @@ -61,6 +77,9 @@ const OrganizationProvider = ({ ); }; +/** + * @internal + */ function useAssertWrappedByClerkProvider(displayNameOrFn: string | (() => void)): void { const ctx = React.useContext(ClerkInstanceContext); @@ -95,5 +114,7 @@ export { useSessionContext, ClerkInstanceContext, useClerkInstanceContext, + useCheckoutContext, + CheckoutProvider, useAssertWrappedByClerkProvider, }; diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 7af1c914082..f57158622be 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -2,6 +2,7 @@ import type { CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, ConfirmC import { useCallback, useMemo, useSyncExternalStore } from 'react'; import type { ClerkAPIResponseError } from '../..'; +import { useCheckoutContext } from '../contexts'; import { useClerk } from './useClerk'; import { useOrganization } from './useOrganization'; import { useSession } from './useSession'; @@ -175,11 +176,14 @@ function createCheckoutManager(cacheKey: CheckoutKey) { try { updateCacheState({ [isRunningField]: true, error: null }); + console.log('dispatching operation', isRunningField, true); const result = await operationFn(); updateCacheState({ [isRunningField]: false, error: null, checkout: result }); + console.log('dispatching operation', isRunningField, false); return result; } catch (error) { const clerkError = error as ClerkAPIResponseError; + console.log('dispatching operation', isRunningField, false, clerkError); updateCacheState({ [isRunningField]: false, error: clerkError }); throw error; } finally { @@ -188,7 +192,10 @@ function createCheckoutManager(cacheKey: CheckoutKey) { }, clearCheckout(): void { - updateCacheState(defaultCacheState); + // Only reset the state if there are no pending operations + if (pendingOperations.size === 0) { + updateCacheState(defaultCacheState); + } }, }; } @@ -201,8 +208,10 @@ function cacheKey(options: { userId: string; orgId?: string; planId: string; pla return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}`; } -export const useCheckout = (options: UseCheckoutOptions): UseCheckoutReturn => { - const { for: forOrganization, planId, planPeriod } = options; +export const useCheckout = (options?: UseCheckoutOptions): UseCheckoutReturn => { + const contextOptions = useCheckoutContext(); + const { for: forOrganization, planId, planPeriod } = options || contextOptions; + const clerk = useClerk(); const { organization } = useOrganization(); const { user } = useUser(); diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index 4b716f41052..be2c2e2aa3f 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -14,4 +14,5 @@ export { UserContext, useSessionContext, useUserContext, + CheckoutProvider, } from './contexts'; From 24a22f0e25c57b2f4eebc60678de6bf27e84d345 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 25 Jun 2025 23:27:35 +0300 Subject: [PATCH 10/36] refactor subscribe of useSyncExternalStore --- packages/shared/src/react/hooks/useCheckout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index f57158622be..05dcf1cfa58 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -235,7 +235,7 @@ export const useCheckout = (options?: UseCheckoutOptions): UseCheckoutReturn => const manager = useMemo(() => createCheckoutManager(checkoutKey), [checkoutKey]); const managerState = useSyncExternalStore( - (...args) => manager.subscribe(...args), + cb => manager.subscribe(cb), () => manager.getCacheState(), () => manager.getCacheState(), ); From f3e2c79403097b5d25d5ce728e56d56bd3e2dd5b Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 26 Jun 2025 11:48:03 +0300 Subject: [PATCH 11/36] create vanilla js api --- packages/clerk-js/src/core/clerk.ts | 10 + .../clerk-js/src/core/modules/checkout.ts | 230 +++++++++++++++++ .../shared/src/react/hooks/useCheckout.ts | 238 ++---------------- packages/types/src/clerk.ts | 22 ++ 4 files changed, 285 insertions(+), 215 deletions(-) create mode 100644 packages/clerk-js/src/core/modules/checkout.ts diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 7e27858023f..d24ee9fc805 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -136,6 +136,8 @@ import type { FapiClient, FapiRequestCallback } from './fapiClient'; import { createFapiClient } from './fapiClient'; import { createClientFromJwt } from './jwt-client'; import { APIKeys } from './modules/apiKeys'; +import type { CheckoutFunction } from './modules/checkout'; +import { createCheckoutInstance } from './modules/checkout'; import { CommerceBilling } from './modules/commerce'; import { BaseResource, @@ -195,6 +197,7 @@ export class Clerk implements ClerkInterface { }; private static _billing: CommerceBillingNamespace; private static _apiKeys: APIKeysNamespace; + private _checkout: CheckoutFunction | undefined; public client: ClientResource | undefined; public session: SignedInSessionResource | null | undefined; @@ -337,6 +340,13 @@ export class Clerk implements ClerkInterface { return Clerk._apiKeys; } + get checkout() { + if (!this._checkout) { + this._checkout = params => createCheckoutInstance(this, params); + } + return this._checkout; + } + public __internal_getOption(key: K): ClerkOptions[K] { return this.#options[key]; } diff --git a/packages/clerk-js/src/core/modules/checkout.ts b/packages/clerk-js/src/core/modules/checkout.ts new file mode 100644 index 00000000000..1f19422af67 --- /dev/null +++ b/packages/clerk-js/src/core/modules/checkout.ts @@ -0,0 +1,230 @@ +import type { CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, ConfirmCheckoutParams } from '@clerk/types'; + +import type { ClerkAPIResponseError } from '../..'; +import type { Clerk } from '../clerk'; + +type CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; + +export type CheckoutOptions = { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; +}; + +export type CheckoutInstance = { + confirm: (params: ConfirmCheckoutParams) => Promise; + start: () => Promise; + clear: () => void; + finalize: (params: { redirectUrl?: string }) => void; + subscribe: (listener: (state: CheckoutCacheState) => void) => () => void; + getState: () => CheckoutCacheState; +}; + +type CheckoutKey = string; + +type CheckoutCacheState = { + isStarting: boolean; + isConfirming: boolean; + error: ClerkAPIResponseError | null; + checkout: CommerceCheckoutResource | null; + fetchStatus: 'idle' | 'fetching' | 'error'; + status: CheckoutStatus; +}; + +const createManagerCache = () => { + const cache = new Map(); + const listeners = new Map void>>(); + const pendingOperations = new Map>(); + + return { + cache, + listeners, + pendingOperations, + safeGet>(key: K, map: Map): NonNullable { + if (!map.has(key)) { + map.set(key, new Set() as V); + } + return map.get(key) as NonNullable; + }, + }; +}; + +const managerCache = createManagerCache(); + +/** + * Derives the checkout state from the base state. + */ +function deriveCheckoutState(baseState: Omit): CheckoutCacheState { + const fetchStatus = (() => { + if (baseState.isStarting || baseState.isConfirming) return 'fetching' as const; + if (baseState.error) return 'error' as const; + return 'idle' as const; + })(); + + const status = (() => { + const completedCode = 'completed'; + if (baseState.checkout?.status === completedCode) return 'completed' as const; + if (baseState.checkout) return 'awaiting_confirmation' as const; + return 'awaiting_initialization' as const; + })(); + + return { + ...baseState, + fetchStatus, + status, + }; +} + +const defaultCacheState: CheckoutCacheState = deriveCheckoutState({ + isStarting: false, + isConfirming: false, + error: null, + checkout: null, +}); + +/** + * Factory function that creates a checkout manager for a specific cache key. + */ +function createCheckoutManager(cacheKey: CheckoutKey) { + const listeners = managerCache.safeGet(cacheKey, managerCache.listeners); + const pendingOperations = managerCache.safeGet(cacheKey, managerCache.pendingOperations); + + const notifyListeners = () => { + listeners.forEach(listener => listener(getCacheState())); + }; + + const getCacheState = (): CheckoutCacheState => { + return managerCache.cache.get(cacheKey) || defaultCacheState; + }; + + const updateCacheState = (updates: Partial>): void => { + const currentState = getCacheState(); + const baseState = { ...currentState, ...updates }; + const newState = deriveCheckoutState(baseState); + managerCache.cache.set(cacheKey, newState); + notifyListeners(); + }; + + return { + subscribe(listener: (newState: CheckoutCacheState) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + getCacheState, + + // Shared operation handler to eliminate duplication + async executeOperation( + operationType: 'start' | 'confirm', + operationFn: () => Promise, + ): Promise { + const operationId = `${cacheKey}-${operationType}`; + const isRunningField = operationType === 'start' ? 'isStarting' : 'isConfirming'; + + // Prevent duplicate operations + if (getCacheState()[isRunningField] || pendingOperations.has(operationId)) { + throw new Error(`Checkout ${operationType} already in progress`); + } + + pendingOperations.add(operationId); + + try { + updateCacheState({ [isRunningField]: true, error: null }); + const result = await operationFn(); + updateCacheState({ [isRunningField]: false, error: null, checkout: result }); + return result; + } catch (error) { + const clerkError = error as ClerkAPIResponseError; + updateCacheState({ [isRunningField]: false, error: clerkError }); + throw error; + } finally { + pendingOperations.delete(operationId); + } + }, + + clearCheckout(): void { + // Only reset the state if there are no pending operations + if (pendingOperations.size === 0) { + updateCacheState(defaultCacheState); + } + }, + }; +} + +/** + * Generate cache key for checkout instance + */ +function cacheKey(options: { userId: string; orgId?: string; planId: string; planPeriod: string }): CheckoutKey { + const { userId, orgId, planId, planPeriod } = options; + return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}`; +} + +export type CheckoutFunction = (options: CheckoutOptions) => CheckoutInstance; + +/** + * Create a checkout instance with the given options + */ +function createCheckoutInstance(clerk: Clerk, options: CheckoutOptions): CheckoutInstance { + const { for: forOrganization, planId, planPeriod } = options; + + if (!clerk.user) { + throw new Error('Clerk: User is not authenticated'); + } + + if (forOrganization === 'organization' && !clerk.organization) { + throw new Error('Clerk: Use `setActive` to set the organization'); + } + + const checkoutKey = cacheKey({ + userId: clerk.user.id, + orgId: forOrganization === 'organization' ? clerk.organization?.id : undefined, + planId, + planPeriod, + }); + + const manager = createCheckoutManager(checkoutKey); + + const start = async (): Promise => { + return manager.executeOperation('start', async () => { + const result = await clerk.billing?.startCheckout({ + ...(forOrganization === 'organization' ? { orgId: clerk.organization?.id } : {}), + planId, + planPeriod, + }); + return result; + }); + }; + + const confirm = async (params: ConfirmCheckoutParams): Promise => { + return manager.executeOperation('confirm', async () => { + const checkout = manager.getCacheState().checkout; + if (!checkout) { + throw new Error('Clerk: Call `start` before `confirm`'); + } + return checkout.confirm(params); + }); + }; + + const finalize = ({ redirectUrl }: { redirectUrl?: string }) => { + void clerk.setActive({ session: clerk.session?.id, redirectUrl }); + }; + + const clear = () => manager.clearCheckout(); + + const subscribe = (listener: (state: CheckoutCacheState) => void) => { + return manager.subscribe(listener); + }; + + return { + start, + confirm, + finalize, + clear, + subscribe, + getState: manager.getCacheState, + }; +} + +export { createCheckoutInstance }; diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 05dcf1cfa58..889cbb9321d 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -1,11 +1,10 @@ import type { CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, ConfirmCheckoutParams } from '@clerk/types'; -import { useCallback, useMemo, useSyncExternalStore } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; import type { ClerkAPIResponseError } from '../..'; import { useCheckoutContext } from '../contexts'; import { useClerk } from './useClerk'; import { useOrganization } from './useOrganization'; -import { useSession } from './useSession'; import { useUser } from './useUser'; type CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; @@ -45,7 +44,7 @@ type UseCheckoutReturn = (CheckoutProperties | NullableCheckoutProperties) & { start: () => Promise; isStarting: boolean; isConfirming: boolean; - error: ClerkAPIResponseError | undefined; + error: ClerkAPIResponseError | null; status: CheckoutStatus; clear: () => void; finalize: (params: { redirectUrl?: string }) => void; @@ -58,156 +57,6 @@ type UseCheckoutOptions = { planId: string; }; -type CheckoutKey = string; - -type CheckoutCacheState = { - isStarting: boolean; - isConfirming: boolean; - error: ClerkAPIResponseError | null; - checkout: CommerceCheckoutResource | null; - fetchStatus: 'idle' | 'fetching' | 'error'; - status: CheckoutStatus; -}; - -const createManagerCache = () => { - const cache = new Map(); - const listeners = new Map void>>(); - const pendingOperations = new Map>(); - - return { - cache, - listeners, - pendingOperations, - safeGet>(key: K, map: Map): NonNullable { - if (!map.has(key)) { - map.set(key, new Set() as V); - } - // We know this is non-null because we just set it above if it didn't exist - return map.get(key) as NonNullable; - }, - }; -}; - -const managerCache = createManagerCache(); - -/** - * Derives the checkout state from the base state. - * - * @internal - */ -function deriveCheckoutState(baseState: Omit): CheckoutCacheState { - const fetchStatus = (() => { - if (baseState.isStarting || baseState.isConfirming) return 'fetching' as const; - if (baseState.error) return 'error' as const; - return 'idle' as const; - })(); - - const status = (() => { - const completedCode = 'completed'; - if (baseState.checkout?.status === completedCode) return 'completed' as const; - if (baseState.checkout) return 'awaiting_confirmation' as const; - return 'awaiting_initialization' as const; - })(); - - return { - ...baseState, - fetchStatus, - status, - }; -} - -const defaultCacheState: CheckoutCacheState = deriveCheckoutState({ - isStarting: false, - isConfirming: false, - error: null, - checkout: null, -}); - -/** - * Factory function that creates a checkout manager for a specific cache key. - * - * @internal - */ -function createCheckoutManager(cacheKey: CheckoutKey) { - // Get or create listeners for this cacheKey - const listeners = managerCache.safeGet(cacheKey, managerCache.listeners); - const pendingOperations = managerCache.safeGet(cacheKey, managerCache.pendingOperations); - - const notifyListeners = () => { - listeners.forEach(listener => listener(getCacheState())); - }; - - const getCacheState = (): CheckoutCacheState => { - return managerCache.cache.get(cacheKey) || defaultCacheState; - }; - - const updateCacheState = (updates: Partial>): void => { - const currentState = getCacheState(); - const baseState = { ...currentState, ...updates }; - const newState = deriveCheckoutState(baseState); - managerCache.cache.set(cacheKey, newState); - notifyListeners(); - }; - - return { - subscribe(listener: (newState: CheckoutCacheState) => void): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - - getCacheState, - - // Shared operation handler to eliminate duplication - async executeOperation( - operationType: 'start' | 'confirm', - operationFn: () => Promise, - ): Promise { - const operationId = `${cacheKey}-${operationType}`; - const isRunningField = operationType === 'start' ? 'isStarting' : 'isConfirming'; - - // Prevent duplicate operations - if (getCacheState()[isRunningField] || pendingOperations.has(operationId)) { - throw new Error(`Checkout ${operationType} already in progress`); - } - - pendingOperations.add(operationId); - - try { - updateCacheState({ [isRunningField]: true, error: null }); - console.log('dispatching operation', isRunningField, true); - const result = await operationFn(); - updateCacheState({ [isRunningField]: false, error: null, checkout: result }); - console.log('dispatching operation', isRunningField, false); - return result; - } catch (error) { - const clerkError = error as ClerkAPIResponseError; - console.log('dispatching operation', isRunningField, false, clerkError); - updateCacheState({ [isRunningField]: false, error: clerkError }); - throw error; - } finally { - pendingOperations.delete(operationId); - } - }, - - clearCheckout(): void { - // Only reset the state if there are no pending operations - if (pendingOperations.size === 0) { - updateCacheState(defaultCacheState); - } - }, - }; -} - -/** - * @internal - */ -function cacheKey(options: { userId: string; orgId?: string; planId: string; planPeriod: string }): CheckoutKey { - const { userId, orgId, planId, planPeriod } = options; - return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}`; -} - export const useCheckout = (options?: UseCheckoutOptions): UseCheckoutReturn => { const contextOptions = useCheckoutContext(); const { for: forOrganization, planId, planPeriod } = options || contextOptions; @@ -215,7 +64,6 @@ export const useCheckout = (options?: UseCheckoutOptions): UseCheckoutReturn => const clerk = useClerk(); const { organization } = useOrganization(); const { user } = useUser(); - const { session } = useSession(); if (!user) { throw new Error('Clerk: User is not authenticated'); @@ -225,58 +73,19 @@ export const useCheckout = (options?: UseCheckoutOptions): UseCheckoutReturn => throw new Error('Clerk: Use `setActive` to set the organization'); } - const checkoutKey = cacheKey({ - userId: user?.id, - orgId: forOrganization === 'organization' ? organization?.id : undefined, - planId, - planPeriod, - }); - - const manager = useMemo(() => createCheckoutManager(checkoutKey), [checkoutKey]); - - const managerState = useSyncExternalStore( - cb => manager.subscribe(cb), - () => manager.getCacheState(), - () => manager.getCacheState(), - ); - - const start = useCallback(async (): Promise => { - return manager.executeOperation('start', async () => { - const result = await clerk.billing?.startCheckout({ - ...(forOrganization === 'organization' ? { orgId: organization?.id } : {}), - planId, - planPeriod, - }); - return result; - }); - }, [manager, clerk.billing, forOrganization, organization?.id, planId, planPeriod]); - - const confirm = useCallback( - async (params: ConfirmCheckoutParams): Promise => { - return manager.executeOperation('confirm', async () => { - const checkout = manager.getCacheState().checkout; - if (!checkout) { - throw new Error('Clerk: Call `start` before `confirm`'); - } - return checkout.confirm(params); - }); - }, - [manager], + const manager = useMemo( + () => clerk.checkout({ planId, planPeriod, for: forOrganization }), + [user.id, organization?.id, planId, planPeriod, forOrganization], ); - const finalize = useCallback( - ({ redirectUrl }: { redirectUrl?: string }) => { - void clerk.setActive({ session: session?.id, redirectUrl }); - }, - [clerk, session?.id], + const managerProperties = useSyncExternalStore( + cb => manager.subscribe(cb), + () => manager.getState(), + () => manager.getState(), ); - const clear = useCallback(() => { - manager.clearCheckout(); - }, [manager]); - const properties = useMemo(() => { - if (!managerState.checkout) { + if (!managerProperties.checkout) { return { id: null, externalClientSecret: null, @@ -291,28 +100,27 @@ export const useCheckout = (options?: UseCheckoutOptions): UseCheckoutReturn => }; } const { - // eslint-disable-next-line @typescript-eslint/unbound-method reload, confirm, pathRoot, // All the above need to be removed from the properties ...rest - } = managerState.checkout; + } = managerProperties.checkout; return rest; - }, [managerState.checkout]); + }, [managerProperties.checkout]); return { ...properties, - // @ts-expect-error - checkout can be null but UseCheckoutReturn expects it to be CommerceCheckoutResource | undefined - __internal_checkout: managerState.checkout, - start, - isStarting: managerState.isStarting, - isConfirming: managerState.isConfirming, - error: managerState.error || undefined, - status: managerState.status, - fetchStatus: managerState.fetchStatus, - confirm, - clear, - finalize, + checkout: null, + __internal_checkout: managerProperties.checkout, + start: manager.start, + confirm: manager.confirm, + clear: manager.clear, + finalize: manager.finalize, + isStarting: managerProperties.isStarting, + isConfirming: managerProperties.isConfirming, + error: managerProperties.error, + status: managerProperties.status, + fetchStatus: managerProperties.fetchStatus, }; }; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index bf0fd935b27..d436bc367db 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -55,6 +55,26 @@ import type { UserResource } from './user'; import type { Autocomplete, DeepPartial, DeepSnakeToCamel } from './utils'; import type { WaitlistResource } from './waitlist'; +// type CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; + +// type CheckoutCacheState = { +// isStarting: boolean; +// isConfirming: boolean; +// error: ClerkAPIResponseError | null; +// checkout: CommerceCheckoutResource | null; +// fetchStatus: 'idle' | 'fetching' | 'error'; +// status: CheckoutStatus; +// }; + +// export type CheckoutInstance = { +// confirm: (params: ConfirmCheckoutParams) => Promise; +// start: () => Promise; +// clear: () => void; +// finalize: (params: { redirectUrl?: string }) => void; +// subscribe: (listener: (state: CheckoutInstance) => void) => () => void; +// getState: () => CheckoutCacheState; +// }; + /** * @inline */ @@ -780,6 +800,8 @@ export interface Clerk { * This API is in early access and may change in future releases. */ apiKeys: APIKeysNamespace; + + checkout: (options: { for?: 'organization'; planPeriod: CommerceSubscriptionPlanPeriod; planId: string }) => any; } export type HandleOAuthCallbackParams = TransferableOption & From 17e54edcb804d46bd5efae3f91689f00bb49c517 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 26 Jun 2025 11:58:08 +0300 Subject: [PATCH 12/36] export useCheckout to nextjs --- .changeset/stale-pillows-sneeze.md | 9 +++++++++ packages/nextjs/src/client-boundary/hooks.ts | 2 ++ packages/nextjs/src/index.ts | 2 ++ packages/react/src/hooks/index.ts | 2 ++ 4 files changed, 15 insertions(+) create mode 100644 .changeset/stale-pillows-sneeze.md diff --git a/.changeset/stale-pillows-sneeze.md b/.changeset/stale-pillows-sneeze.md new file mode 100644 index 00000000000..7640b43579e --- /dev/null +++ b/.changeset/stale-pillows-sneeze.md @@ -0,0 +1,9 @@ +--- +'@clerk/clerk-js': minor +'@clerk/nextjs': minor +'@clerk/shared': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +wip diff --git a/packages/nextjs/src/client-boundary/hooks.ts b/packages/nextjs/src/client-boundary/hooks.ts index c7748535767..5555f2c6fe0 100644 --- a/packages/nextjs/src/client-boundary/hooks.ts +++ b/packages/nextjs/src/client-boundary/hooks.ts @@ -11,6 +11,8 @@ export { useSignUp, useUser, useReverification, + __experimental_useCheckout, + CheckoutProvider, } from '@clerk/clerk-react'; export { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index f57260044ac..d262f2279d0 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -54,6 +54,8 @@ export { useSignUp, useUser, useReverification, + __experimental_useCheckout, + CheckoutProvider, } from './client-boundary/hooks'; /** diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 5a6cb60cb6b..9d4719fd1e2 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -10,4 +10,6 @@ export { useUser, useSession, useReverification, + __experimental_useCheckout, + CheckoutProvider, } from '@clerk/shared/react'; From 2e1a850ed37e8c31667f4b52f7f37e633f1b99c4 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 26 Jun 2025 12:13:18 +0300 Subject: [PATCH 13/36] fix isomorphicClerk.ts --- packages/react/src/isomorphicClerk.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index c92f3d57692..2734e53df73 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -705,6 +705,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return this.clerkjs?.apiKeys; } + checkout = (...args: Parameters) => { + return this.clerkjs?.checkout(...args); + }; + __unstable__setEnvironment(...args: any): void { if (this.clerkjs && '__unstable__setEnvironment' in this.clerkjs) { (this.clerkjs as any).__unstable__setEnvironment(args); From e1ed2c7caa135d4386b17bf4171f6865e84c8838 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 26 Jun 2025 12:34:34 +0300 Subject: [PATCH 14/36] track inflight requests --- .../clerk-js/src/core/modules/checkout.ts | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/packages/clerk-js/src/core/modules/checkout.ts b/packages/clerk-js/src/core/modules/checkout.ts index 1f19422af67..74d1180e685 100644 --- a/packages/clerk-js/src/core/modules/checkout.ts +++ b/packages/clerk-js/src/core/modules/checkout.ts @@ -34,7 +34,7 @@ type CheckoutCacheState = { const createManagerCache = () => { const cache = new Map(); const listeners = new Map void>>(); - const pendingOperations = new Map>(); + const pendingOperations = new Map>>(); return { cache, @@ -46,6 +46,12 @@ const createManagerCache = () => { } return map.get(key) as NonNullable; }, + safeGetOperations(key: K): Map> { + if (!this.pendingOperations.has(key)) { + this.pendingOperations.set(key, new Map>()); + } + return this.pendingOperations.get(key) as Map>; + }, }; }; @@ -87,7 +93,7 @@ const defaultCacheState: CheckoutCacheState = deriveCheckoutState({ */ function createCheckoutManager(cacheKey: CheckoutKey) { const listeners = managerCache.safeGet(cacheKey, managerCache.listeners); - const pendingOperations = managerCache.safeGet(cacheKey, managerCache.pendingOperations); + const pendingOperations = managerCache.safeGetOperations(cacheKey); const notifyListeners = () => { listeners.forEach(listener => listener(getCacheState())); @@ -123,25 +129,32 @@ function createCheckoutManager(cacheKey: CheckoutKey) { const operationId = `${cacheKey}-${operationType}`; const isRunningField = operationType === 'start' ? 'isStarting' : 'isConfirming'; - // Prevent duplicate operations - if (getCacheState()[isRunningField] || pendingOperations.has(operationId)) { - throw new Error(`Checkout ${operationType} already in progress`); + // Check if there's already a pending operation + const existingOperation = pendingOperations.get(operationId); + if (existingOperation) { + // Wait for the existing operation to complete and return its result + // If it fails, all callers should receive the same error + return await existingOperation; } - pendingOperations.add(operationId); - - try { - updateCacheState({ [isRunningField]: true, error: null }); - const result = await operationFn(); - updateCacheState({ [isRunningField]: false, error: null, checkout: result }); - return result; - } catch (error) { - const clerkError = error as ClerkAPIResponseError; - updateCacheState({ [isRunningField]: false, error: clerkError }); - throw error; - } finally { - pendingOperations.delete(operationId); - } + // Create and store the operation promise + const operationPromise = (async () => { + try { + updateCacheState({ [isRunningField]: true, error: null }); + const result = await operationFn(); + updateCacheState({ [isRunningField]: false, error: null, checkout: result }); + return result; + } catch (error) { + const clerkError = error as ClerkAPIResponseError; + updateCacheState({ [isRunningField]: false, error: clerkError }); + throw error; + } finally { + pendingOperations.delete(operationId); + } + })(); + + pendingOperations.set(operationId, operationPromise); + return operationPromise; }, clearCheckout(): void { From 6b275b54f7d1e8de5db39b66d8eb7ffececfce68 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 27 Jun 2025 11:37:35 +0300 Subject: [PATCH 15/36] expose `getState()` from useCheckout --- packages/shared/src/react/hooks/useCheckout.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 889cbb9321d..75ef99246b5 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -39,6 +39,15 @@ type NullableCheckoutProperties = Nullable< __internal_checkout: null; }; +type CheckoutCacheState = { + isStarting: boolean; + isConfirming: boolean; + error: ClerkAPIResponseError | null; + checkout: CommerceCheckoutResource | null; + fetchStatus: 'idle' | 'fetching' | 'error'; + status: CheckoutStatus; +}; + type UseCheckoutReturn = (CheckoutProperties | NullableCheckoutProperties) & { confirm: (params: ConfirmCheckoutParams) => Promise; start: () => Promise; @@ -49,6 +58,7 @@ type UseCheckoutReturn = (CheckoutProperties | NullableCheckoutProperties) & { clear: () => void; finalize: (params: { redirectUrl?: string }) => void; fetchStatus: 'idle' | 'fetching' | 'error'; + getState: () => CheckoutCacheState; }; type UseCheckoutOptions = { @@ -111,6 +121,7 @@ export const useCheckout = (options?: UseCheckoutOptions): UseCheckoutReturn => return { ...properties, + getState: manager.getState, checkout: null, __internal_checkout: managerProperties.checkout, start: manager.start, From f563d05a78fb4eaa522f8905e1aaa411420da8b1 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 27 Jun 2025 18:29:57 +0300 Subject: [PATCH 16/36] mark checkout as experimental and cleanup --- packages/clerk-js/src/core/clerk.ts | 6 +-- .../clerk-js/src/core/modules/checkout.ts | 53 ++++++++++++++----- .../ui/components/Checkout/CheckoutPage.tsx | 10 ---- packages/react/src/isomorphicClerk.ts | 4 +- .../shared/src/react/hooks/useCheckout.ts | 2 +- packages/types/src/clerk.ts | 6 ++- 6 files changed, 51 insertions(+), 30 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index d24ee9fc805..dffd4594587 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -136,7 +136,7 @@ import type { FapiClient, FapiRequestCallback } from './fapiClient'; import { createFapiClient } from './fapiClient'; import { createClientFromJwt } from './jwt-client'; import { APIKeys } from './modules/apiKeys'; -import type { CheckoutFunction } from './modules/checkout'; +import type { CheckoutFunction, CheckoutInstance, CheckoutOptions } from './modules/checkout'; import { createCheckoutInstance } from './modules/checkout'; import { CommerceBilling } from './modules/commerce'; import { @@ -340,11 +340,11 @@ export class Clerk implements ClerkInterface { return Clerk._apiKeys; } - get checkout() { + __experimental_checkout(options: CheckoutOptions): CheckoutInstance { if (!this._checkout) { this._checkout = params => createCheckoutInstance(this, params); } - return this._checkout; + return this._checkout(options); } public __internal_getOption(key: K): ClerkOptions[K] { diff --git a/packages/clerk-js/src/core/modules/checkout.ts b/packages/clerk-js/src/core/modules/checkout.ts index 74d1180e685..635a8421c86 100644 --- a/packages/clerk-js/src/core/modules/checkout.ts +++ b/packages/clerk-js/src/core/modules/checkout.ts @@ -20,16 +20,16 @@ export type CheckoutInstance = { getState: () => CheckoutCacheState; }; -type CheckoutKey = string; +type CheckoutKey = string & { readonly __tag: 'CheckoutKey' }; -type CheckoutCacheState = { +type CheckoutCacheState = Readonly<{ isStarting: boolean; isConfirming: boolean; error: ClerkAPIResponseError | null; checkout: CommerceCheckoutResource | null; fetchStatus: 'idle' | 'fetching' | 'error'; status: CheckoutStatus; -}; +}>; const createManagerCache = () => { const cache = new Map(); @@ -57,21 +57,32 @@ const createManagerCache = () => { const managerCache = createManagerCache(); +const CHECKOUT_STATUS = { + AWAITING_INITIALIZATION: 'awaiting_initialization', + AWAITING_CONFIRMATION: 'awaiting_confirmation', + COMPLETED: 'completed', +} as const; + +export const FETCH_STATUS = { + IDLE: 'idle', + FETCHING: 'fetching', + ERROR: 'error', +} as const; + /** * Derives the checkout state from the base state. */ function deriveCheckoutState(baseState: Omit): CheckoutCacheState { const fetchStatus = (() => { - if (baseState.isStarting || baseState.isConfirming) return 'fetching' as const; - if (baseState.error) return 'error' as const; - return 'idle' as const; + if (baseState.isStarting || baseState.isConfirming) return FETCH_STATUS.FETCHING; + if (baseState.error) return FETCH_STATUS.ERROR; + return FETCH_STATUS.IDLE; })(); const status = (() => { - const completedCode = 'completed'; - if (baseState.checkout?.status === completedCode) return 'completed' as const; - if (baseState.checkout) return 'awaiting_confirmation' as const; - return 'awaiting_initialization' as const; + if (baseState.checkout?.status === CHECKOUT_STATUS.COMPLETED) return CHECKOUT_STATUS.COMPLETED; + if (baseState.checkout) return CHECKOUT_STATUS.AWAITING_CONFIRMATION; + return CHECKOUT_STATUS.AWAITING_INITIALIZATION; })(); return { @@ -89,7 +100,16 @@ const defaultCacheState: CheckoutCacheState = deriveCheckoutState({ }); /** - * Factory function that creates a checkout manager for a specific cache key. + * Creates a checkout manager for handling checkout operations and state management. + * + * @param cacheKey - Unique identifier for the checkout instance + * @returns Manager with methods for checkout operations and state subscription + * + * @example + * ```typescript + * const manager = createCheckoutManager('user-123-plan-456-monthly'); + * const unsubscribe = manager.subscribe(state => console.log(state)); + * ``` */ function createCheckoutManager(cacheKey: CheckoutKey) { const listeners = managerCache.safeGet(cacheKey, managerCache.listeners); @@ -107,7 +127,7 @@ function createCheckoutManager(cacheKey: CheckoutKey) { const currentState = getCacheState(); const baseState = { ...currentState, ...updates }; const newState = deriveCheckoutState(baseState); - managerCache.cache.set(cacheKey, newState); + managerCache.cache.set(cacheKey, Object.freeze(newState)); notifyListeners(); }; @@ -140,15 +160,22 @@ function createCheckoutManager(cacheKey: CheckoutKey) { // Create and store the operation promise const operationPromise = (async () => { try { + // Mark operation as in progress and clear any previous errors updateCacheState({ [isRunningField]: true, error: null }); + + // Execute the checkout operation const result = await operationFn(); + + // Update state with successful result updateCacheState({ [isRunningField]: false, error: null, checkout: result }); return result; } catch (error) { + // Cast error to expected type and update state const clerkError = error as ClerkAPIResponseError; updateCacheState({ [isRunningField]: false, error: clerkError }); throw error; } finally { + // Always clean up pending operation tracker pendingOperations.delete(operationId); } })(); @@ -171,7 +198,7 @@ function createCheckoutManager(cacheKey: CheckoutKey) { */ function cacheKey(options: { userId: string; orgId?: string; planId: string; planPeriod: string }): CheckoutKey { const { userId, orgId, planId, planPeriod } = options; - return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}`; + return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}` as CheckoutKey; } export type CheckoutFunction = (options: CheckoutOptions) => CheckoutInstance; diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx index 6d2762bce22..651f7304c93 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -3,16 +3,6 @@ import { useEffect, useMemo } from 'react'; import { useCheckoutContext } from '@/ui/contexts/components'; -// const CheckoutContextRoot = createContext | null>(null); - -// export const useCheckoutContextRoot = () => { -// const ctx = useContext(CheckoutContextRoot); -// if (!ctx) { -// throw new Error('CheckoutContextRoot not found'); -// } -// return ctx; -// }; - const Initiator = () => { const checkout = useCheckout(); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 2734e53df73..01ffd61a650 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -705,8 +705,8 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return this.clerkjs?.apiKeys; } - checkout = (...args: Parameters) => { - return this.clerkjs?.checkout(...args); + __experimental_checkout = (...args: Parameters) => { + return this.clerkjs?.__experimental_checkout(...args); }; __unstable__setEnvironment(...args: any): void { diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 75ef99246b5..d7803f405bd 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -84,7 +84,7 @@ export const useCheckout = (options?: UseCheckoutOptions): UseCheckoutReturn => } const manager = useMemo( - () => clerk.checkout({ planId, planPeriod, for: forOrganization }), + () => clerk.__experimental_checkout({ planId, planPeriod, for: forOrganization }), [user.id, organization?.id, planId, planPeriod, forOrganization], ); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index d436bc367db..a018b365585 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -801,7 +801,11 @@ export interface Clerk { */ apiKeys: APIKeysNamespace; - checkout: (options: { for?: 'organization'; planPeriod: CommerceSubscriptionPlanPeriod; planId: string }) => any; + __experimental_checkout: (options: { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; + }) => any; } export type HandleOAuthCallbackParams = TransferableOption & From 078e5dbf13ea6266038e3afab856feed5ba61738 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 27 Jun 2025 19:08:33 +0300 Subject: [PATCH 17/36] add unit tests for manager --- packages/clerk-js/src/core/clerk.ts | 4 +- .../checkout/__tests__/manager.spec.ts | 635 ++++++++++++++++++ .../src/core/modules/checkout/instance.ts | 95 +++ .../{checkout.ts => checkout/manager.ts} | 118 +--- 4 files changed, 748 insertions(+), 104 deletions(-) create mode 100644 packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts create mode 100644 packages/clerk-js/src/core/modules/checkout/instance.ts rename packages/clerk-js/src/core/modules/{checkout.ts => checkout/manager.ts} (64%) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index dffd4594587..c1fc30d4634 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -136,8 +136,8 @@ import type { FapiClient, FapiRequestCallback } from './fapiClient'; import { createFapiClient } from './fapiClient'; import { createClientFromJwt } from './jwt-client'; import { APIKeys } from './modules/apiKeys'; -import type { CheckoutFunction, CheckoutInstance, CheckoutOptions } from './modules/checkout'; -import { createCheckoutInstance } from './modules/checkout'; +import type { CheckoutFunction, CheckoutInstance, CheckoutOptions } from './modules/checkout/instance'; +import { createCheckoutInstance } from './modules/checkout/instance'; import { CommerceBilling } from './modules/commerce'; import { BaseResource, diff --git a/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts b/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts new file mode 100644 index 00000000000..3019063493b --- /dev/null +++ b/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts @@ -0,0 +1,635 @@ +import type { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { CommerceCheckoutResource } from '@clerk/types'; +import type { MockedFunction } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type CheckoutCacheState, type CheckoutKey, createCheckoutManager, FETCH_STATUS } from '../manager'; + +// Type-safe mock for CommerceCheckoutResource +const createMockCheckoutResource = (overrides: Partial = {}): CommerceCheckoutResource => ({ + id: 'checkout_123', + status: 'pending', + externalClientSecret: 'cs_test_123', + externalGatewayId: 'gateway_123', + statement_id: 'stmt_123', + totals: { + totalDueNow: { amount: 1000, currency: 'USD', currencySymbol: '$', amountFormatted: '10.00' }, + credit: { amount: 0, currency: 'USD', currencySymbol: '$', amountFormatted: '0.00' }, + pastDue: { amount: 0, currency: 'USD', currencySymbol: '$', amountFormatted: '0.00' }, + subtotal: { amount: 1000, currency: 'USD', currencySymbol: '$', amountFormatted: '10.00' }, + grandTotal: { amount: 1000, currency: 'USD', currencySymbol: '$', amountFormatted: '10.00' }, + taxTotal: { amount: 0, currency: 'USD', currencySymbol: '$', amountFormatted: '0.00' }, + }, + isImmediatePlanChange: false, + planPeriod: 'month', + plan: { + id: 'plan_123', + name: 'Pro Plan', + description: 'Professional plan', + features: [], + amount: 1000, + amountFormatted: '10.00', + annualAmount: 12000, + annualAmountFormatted: '120.00', + currency: 'USD', + currencySymbol: '$', + slug: 'pro-plan', + }, + paymentSource: undefined, + confirm: vi.fn(), + reload: vi.fn(), + pathRoot: '/checkout', + ...overrides, +}); + +// Type-safe mock for ClerkAPIResponseError +const createMockError = (message = 'Test error'): ClerkAPIResponseError => { + const error = new Error(message) as ClerkAPIResponseError; + error.status = 400; + error.clerkTraceId = 'trace_123'; + error.clerkError = true; + return error; +}; + +// Helper to create a typed cache key +const createCacheKey = (key: string): CheckoutKey => key as CheckoutKey; + +describe('createCheckoutManager', () => { + const testCacheKey = createCacheKey('user-123-plan-456-monthly'); + let manager: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + manager = createCheckoutManager(testCacheKey); + }); + + describe('getCacheState', () => { + it('should return default state when cache is empty', () => { + const state = manager.getCacheState(); + + expect(state).toEqual({ + isStarting: false, + isConfirming: false, + error: null, + checkout: null, + fetchStatus: 'idle', + status: 'awaiting_initialization', + }); + }); + + it('should return immutable state object', () => { + const state = manager.getCacheState(); + + // State should be frozen + expect(Object.isFrozen(state)).toBe(true); + }); + }); + + describe('subscribe', () => { + it('should add listener and return unsubscribe function', () => { + const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + + const unsubscribe = manager.subscribe(listener); + + expect(typeof unsubscribe).toBe('function'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('should remove listener when unsubscribe is called', async () => { + const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + + const unsubscribe = manager.subscribe(listener); + + // Trigger a state change + const mockCheckout = createMockCheckoutResource(); + const mockOperation = vi.fn().mockResolvedValue(mockCheckout); + await manager.executeOperation('start', mockOperation); + + expect(listener).toHaveBeenCalled(); + + // Clear the mock and unsubscribe + listener.mockClear(); + unsubscribe(); + + // Trigger another state change + const anotherMockOperation = vi.fn().mockResolvedValue(mockCheckout); + await manager.executeOperation('confirm', anotherMockOperation); + + // Listener should not be called after unsubscribing + expect(listener).not.toHaveBeenCalled(); + }); + + it('should notify all listeners when state changes', async () => { + const listener1: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + const listener2: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + const mockCheckout = createMockCheckoutResource(); + + manager.subscribe(listener1); + manager.subscribe(listener2); + + const mockOperation = vi.fn().mockResolvedValue(mockCheckout); + await manager.executeOperation('start', mockOperation); + + expect(listener1).toHaveBeenCalled(); + expect(listener2).toHaveBeenCalled(); + + // Verify they were called with the updated state + const expectedState = expect.objectContaining({ + checkout: mockCheckout, + isStarting: false, + error: null, + fetchStatus: 'idle', + status: 'awaiting_confirmation', + }); + + expect(listener1).toHaveBeenCalledWith(expectedState); + expect(listener2).toHaveBeenCalledWith(expectedState); + }); + + it('should handle multiple subscribe/unsubscribe cycles', () => { + const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + + // Subscribe and unsubscribe multiple times + const unsubscribe1 = manager.subscribe(listener); + unsubscribe1(); + + const unsubscribe2 = manager.subscribe(listener); + const unsubscribe3 = manager.subscribe(listener); + + unsubscribe2(); + unsubscribe3(); + + // Should not throw errors + expect(() => unsubscribe1()).not.toThrow(); + expect(() => unsubscribe2()).not.toThrow(); + }); + }); + + describe('executeOperation - start operations', () => { + it('should execute start operation successfully', async () => { + const mockCheckout = createMockCheckoutResource(); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(mockCheckout); + + const result = await manager.executeOperation('start', mockOperation); + + expect(mockOperation).toHaveBeenCalledOnce(); + expect(result).toBe(mockCheckout); + + const finalState = manager.getCacheState(); + expect(finalState).toEqual( + expect.objectContaining({ + isStarting: false, + checkout: mockCheckout, + error: null, + fetchStatus: 'idle', + status: 'awaiting_confirmation', + }), + ); + }); + + it('should set isStarting to true during operation', async () => { + let capturedState: CheckoutCacheState | null = null; + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + // Capture state while operation is running + capturedState = manager.getCacheState(); + return createMockCheckoutResource(); + }); + + await manager.executeOperation('start', mockOperation); + + expect(capturedState).toEqual( + expect.objectContaining({ + isStarting: true, + fetchStatus: 'fetching', + }), + ); + }); + + it('should handle operation errors correctly', async () => { + const mockError = createMockError('Operation failed'); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockRejectedValue(mockError); + + await expect(manager.executeOperation('start', mockOperation)).rejects.toThrow('Operation failed'); + + const finalState = manager.getCacheState(); + expect(finalState).toEqual( + expect.objectContaining({ + isStarting: false, + error: mockError, + fetchStatus: 'error', + status: 'awaiting_initialization', + }), + ); + }); + + it('should clear previous errors when starting new operation', async () => { + // First, create an error state + const mockError = createMockError('Previous error'); + const failingOperation: MockedFunction<() => Promise> = vi + .fn() + .mockRejectedValue(mockError); + + await expect(manager.executeOperation('start', failingOperation)).rejects.toThrow(); + + const errorState = manager.getCacheState(); + expect(errorState.error).toBe(mockError); + + // Now start a successful operation + const mockCheckout = createMockCheckoutResource(); + const successfulOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(mockCheckout); + + await manager.executeOperation('start', successfulOperation); + + const finalState = manager.getCacheState(); + expect(finalState.error).toBeNull(); + expect(finalState.checkout).toBe(mockCheckout); + }); + }); + + describe('executeOperation - confirm operations', () => { + it('should execute confirm operation successfully', async () => { + const mockCheckout = createMockCheckoutResource({ status: 'completed' }); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(mockCheckout); + + const result = await manager.executeOperation('confirm', mockOperation); + + expect(result).toBe(mockCheckout); + + const finalState = manager.getCacheState(); + expect(finalState).toEqual( + expect.objectContaining({ + isConfirming: false, + checkout: mockCheckout, + error: null, + fetchStatus: 'idle', + status: 'completed', + }), + ); + }); + + it('should set isConfirming to true during operation', async () => { + let capturedState: CheckoutCacheState | null = null; + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + capturedState = manager.getCacheState(); + return createMockCheckoutResource(); + }); + + await manager.executeOperation('confirm', mockOperation); + + expect(capturedState).toEqual( + expect.objectContaining({ + isConfirming: true, + fetchStatus: 'fetching', + }), + ); + }); + + it('should handle confirm operation errors', async () => { + const mockError = createMockError('Confirm failed'); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockRejectedValue(mockError); + + await expect(manager.executeOperation('confirm', mockOperation)).rejects.toThrow('Confirm failed'); + + const finalState = manager.getCacheState(); + expect(finalState).toEqual( + expect.objectContaining({ + isConfirming: false, + error: mockError, + fetchStatus: 'error', + }), + ); + }); + }); + + describe('operation deduplication', () => { + it('should deduplicate concurrent start operations', async () => { + const mockCheckout = createMockCheckoutResource(); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(mockCheckout), 50))); + + // Start multiple operations concurrently + const [result1, result2, result3] = await Promise.all([ + manager.executeOperation('start', mockOperation), + manager.executeOperation('start', mockOperation), + manager.executeOperation('start', mockOperation), + ]); + + // Operation should only be called once + expect(mockOperation).toHaveBeenCalledOnce(); + + // All results should be the same + expect(result1).toBe(mockCheckout); + expect(result2).toBe(mockCheckout); + expect(result3).toBe(mockCheckout); + }); + + it('should deduplicate concurrent confirm operations', async () => { + const mockCheckout = createMockCheckoutResource(); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(mockCheckout), 50))); + + const [result1, result2] = await Promise.all([ + manager.executeOperation('confirm', mockOperation), + manager.executeOperation('confirm', mockOperation), + ]); + + expect(mockOperation).toHaveBeenCalledOnce(); + expect(result1).toBe(result2); + }); + + it('should allow different operation types to run concurrently', async () => { + const startCheckout = createMockCheckoutResource({ id: 'start_checkout' }); + const confirmCheckout = createMockCheckoutResource({ id: 'confirm_checkout' }); + + const startOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(startCheckout), 50))); + const confirmOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(confirmCheckout), 50))); + + const [startResult, confirmResult] = await Promise.all([ + manager.executeOperation('start', startOperation), + manager.executeOperation('confirm', confirmOperation), + ]); + + expect(startOperation).toHaveBeenCalledOnce(); + expect(confirmOperation).toHaveBeenCalledOnce(); + expect(startResult).toBe(startCheckout); + expect(confirmResult).toBe(confirmCheckout); + }); + + it('should propagate errors to all concurrent callers', async () => { + const mockError = createMockError('Concurrent operation failed'); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise((_, reject) => setTimeout(() => reject(mockError), 50))); + + const promises = [ + manager.executeOperation('start', mockOperation), + manager.executeOperation('start', mockOperation), + manager.executeOperation('start', mockOperation), + ]; + + // All promises should reject with the same error + await expect(Promise.all(promises)).rejects.toThrow('Concurrent operation failed'); + expect(mockOperation).toHaveBeenCalledOnce(); + }); + + it('should allow sequential operations of the same type', async () => { + const checkout1 = createMockCheckoutResource({ id: 'checkout1' }); + const checkout2 = createMockCheckoutResource({ id: 'checkout2' }); + + const operation1: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout1); + const operation2: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout2); + + const result1 = await manager.executeOperation('start', operation1); + const result2 = await manager.executeOperation('start', operation2); + + expect(operation1).toHaveBeenCalledOnce(); + expect(operation2).toHaveBeenCalledOnce(); + expect(result1).toBe(checkout1); + expect(result2).toBe(checkout2); + }); + }); + + describe('clearCheckout', () => { + it('should clear checkout state when no operations are pending', () => { + const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + manager.subscribe(listener); + + manager.clearCheckout(); + + const state = manager.getCacheState(); + expect(state).toEqual({ + isStarting: false, + isConfirming: false, + error: null, + checkout: null, + fetchStatus: 'idle', + status: 'awaiting_initialization', + }); + + // Should notify listeners + expect(listener).toHaveBeenCalledWith(state); + }); + + it('should not clear checkout state when operations are pending', async () => { + const mockCheckout = createMockCheckoutResource(); + let resolveOperation: ((value: CommerceCheckoutResource) => void) | undefined; + + const mockOperation: MockedFunction<() => Promise> = vi.fn().mockImplementation( + () => + new Promise(resolve => { + resolveOperation = resolve; + }), + ); + + // Start an operation but don't resolve it yet + const operationPromise = manager.executeOperation('start', mockOperation); + + // Verify operation is in progress + let state = manager.getCacheState(); + expect(state.isStarting).toBe(true); + expect(state.fetchStatus).toBe('fetching'); + + // Try to clear while operation is pending + manager.clearCheckout(); + + // State should not be cleared + state = manager.getCacheState(); + expect(state.isStarting).toBe(true); + expect(state.fetchStatus).toBe('fetching'); + + // Resolve the operation + resolveOperation!(mockCheckout); + await operationPromise; + + // Now clearing should work + manager.clearCheckout(); + state = manager.getCacheState(); + expect(state.checkout).toBeNull(); + expect(state.status).toBe('awaiting_initialization'); + }); + }); + + describe('state derivation', () => { + it('should derive fetchStatus correctly based on operation state', async () => { + // Initially idle + expect(manager.getCacheState().fetchStatus).toBe(FETCH_STATUS.IDLE); + + // During operation - fetching + let capturedState: CheckoutCacheState | null = null; + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + capturedState = manager.getCacheState(); + return createMockCheckoutResource(); + }); + + await manager.executeOperation('start', mockOperation); + expect(capturedState?.fetchStatus).toBe(FETCH_STATUS.FETCHING); + + // After successful operation - idle + expect(manager.getCacheState().fetchStatus).toBe(FETCH_STATUS.IDLE); + + // After error - error + const mockError = createMockError(); + const failingOperation: MockedFunction<() => Promise> = vi + .fn() + .mockRejectedValue(mockError); + + await expect(manager.executeOperation('start', failingOperation)).rejects.toThrow(); + expect(manager.getCacheState().fetchStatus).toBe(FETCH_STATUS.ERROR); + }); + + it('should derive status based on checkout state', async () => { + // Initially awaiting initialization + expect(manager.getCacheState().status).toBe('awaiting_initialization'); + + // After starting checkout - awaiting confirmation + const pendingCheckout = createMockCheckoutResource({ status: 'pending' }); + const startOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(pendingCheckout); + + await manager.executeOperation('start', startOperation); + expect(manager.getCacheState().status).toBe('awaiting_confirmation'); + + // After completing checkout - completed + const completedCheckout = createMockCheckoutResource({ status: 'completed' }); + const confirmOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(completedCheckout); + + await manager.executeOperation('confirm', confirmOperation); + expect(manager.getCacheState().status).toBe('completed'); + }); + + it('should handle both operations running simultaneously', async () => { + let startCapturedState: CheckoutCacheState | null = null; + let confirmCapturedState: CheckoutCacheState | null = null; + + const startOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 30)); + startCapturedState = manager.getCacheState(); + return createMockCheckoutResource({ id: 'start' }); + }); + + const confirmOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 20)); + confirmCapturedState = manager.getCacheState(); + return createMockCheckoutResource({ id: 'confirm' }); + }); + + await Promise.all([ + manager.executeOperation('start', startOperation), + manager.executeOperation('confirm', confirmOperation), + ]); + + // Both should have seen fetching status + expect(startCapturedState?.fetchStatus).toBe(FETCH_STATUS.FETCHING); + expect(confirmCapturedState?.fetchStatus).toBe(FETCH_STATUS.FETCHING); + + // At least one should have seen both operations running + expect( + (startCapturedState?.isStarting && startCapturedState?.isConfirming) || + (confirmCapturedState?.isStarting && confirmCapturedState?.isConfirming), + ).toBe(true); + }); + }); + + describe('cache isolation', () => { + it('should isolate state between different cache keys', async () => { + const manager1 = createCheckoutManager(createCacheKey('key1')); + const manager2 = createCheckoutManager(createCacheKey('key2')); + + const checkout1 = createMockCheckoutResource({ id: 'checkout1' }); + const checkout2 = createMockCheckoutResource({ id: 'checkout2' }); + + const operation1: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout1); + const operation2: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout2); + + await manager1.executeOperation('start', operation1); + await manager2.executeOperation('confirm', operation2); + + const state1 = manager1.getCacheState(); + const state2 = manager2.getCacheState(); + + expect(state1.checkout?.id).toBe('checkout1'); + expect(state1.status).toBe('awaiting_confirmation'); + + expect(state2.checkout?.id).toBe('checkout2'); + expect(state2.isStarting).toBe(false); + expect(state2.isConfirming).toBe(false); + }); + + it('should isolate listeners between different cache keys', async () => { + const manager1 = createCheckoutManager(createCacheKey('key1')); + const manager2 = createCheckoutManager(createCacheKey('key2')); + + const listener1: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + const listener2: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + + manager1.subscribe(listener1); + manager2.subscribe(listener2); + + // Trigger operation on manager1 + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(createMockCheckoutResource()); + await manager1.executeOperation('start', mockOperation); + + // Only listener1 should be called + expect(listener1).toHaveBeenCalled(); + expect(listener2).not.toHaveBeenCalled(); + }); + + it('should isolate pending operations between different cache keys', async () => { + const manager1 = createCheckoutManager(createCacheKey('key1')); + const manager2 = createCheckoutManager(createCacheKey('key2')); + + const checkout1 = createMockCheckoutResource({ id: 'checkout1' }); + const checkout2 = createMockCheckoutResource({ id: 'checkout2' }); + + const operation1: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(checkout1), 50))); + const operation2: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(checkout2), 50))); + + // Start concurrent operations on both managers + const [result1, result2] = await Promise.all([ + manager1.executeOperation('start', operation1), + manager2.executeOperation('start', operation2), + ]); + + // Both operations should execute (not deduplicated across managers) + expect(operation1).toHaveBeenCalledOnce(); + expect(operation2).toHaveBeenCalledOnce(); + expect(result1).toBe(checkout1); + expect(result2).toBe(checkout2); + }); + }); +}); diff --git a/packages/clerk-js/src/core/modules/checkout/instance.ts b/packages/clerk-js/src/core/modules/checkout/instance.ts new file mode 100644 index 00000000000..49bdf3903f4 --- /dev/null +++ b/packages/clerk-js/src/core/modules/checkout/instance.ts @@ -0,0 +1,95 @@ +import type { CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, ConfirmCheckoutParams } from '@clerk/types'; + +import type { Clerk } from '../../clerk'; +import { type CheckoutCacheState, type CheckoutKey, createCheckoutManager } from './manager'; + +export type CheckoutOptions = { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; +}; + +export type CheckoutInstance = { + confirm: (params: ConfirmCheckoutParams) => Promise; + start: () => Promise; + clear: () => void; + finalize: (params: { redirectUrl?: string }) => void; + subscribe: (listener: (state: CheckoutCacheState) => void) => () => void; + getState: () => CheckoutCacheState; +}; + +/** + * Generate cache key for checkout instance + */ +function cacheKey(options: { userId: string; orgId?: string; planId: string; planPeriod: string }): CheckoutKey { + const { userId, orgId, planId, planPeriod } = options; + return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}` as CheckoutKey; +} + +export type CheckoutFunction = (options: CheckoutOptions) => CheckoutInstance; + +/** + * Create a checkout instance with the given options + */ +function createCheckoutInstance(clerk: Clerk, options: CheckoutOptions): CheckoutInstance { + const { for: forOrganization, planId, planPeriod } = options; + + if (!clerk.user) { + throw new Error('Clerk: User is not authenticated'); + } + + if (forOrganization === 'organization' && !clerk.organization) { + throw new Error('Clerk: Use `setActive` to set the organization'); + } + + const checkoutKey = cacheKey({ + userId: clerk.user.id, + orgId: forOrganization === 'organization' ? clerk.organization?.id : undefined, + planId, + planPeriod, + }); + + const manager = createCheckoutManager(checkoutKey); + + const start = async (): Promise => { + return manager.executeOperation('start', async () => { + const result = await clerk.billing?.startCheckout({ + ...(forOrganization === 'organization' ? { orgId: clerk.organization?.id } : {}), + planId, + planPeriod, + }); + return result; + }); + }; + + const confirm = async (params: ConfirmCheckoutParams): Promise => { + return manager.executeOperation('confirm', async () => { + const checkout = manager.getCacheState().checkout; + if (!checkout) { + throw new Error('Clerk: Call `start` before `confirm`'); + } + return checkout.confirm(params); + }); + }; + + const finalize = ({ redirectUrl }: { redirectUrl?: string }) => { + void clerk.setActive({ session: clerk.session?.id, redirectUrl }); + }; + + const clear = () => manager.clearCheckout(); + + const subscribe = (listener: (state: CheckoutCacheState) => void) => { + return manager.subscribe(listener); + }; + + return { + start, + confirm, + finalize, + clear, + subscribe, + getState: manager.getCacheState, + }; +} + +export { createCheckoutInstance }; diff --git a/packages/clerk-js/src/core/modules/checkout.ts b/packages/clerk-js/src/core/modules/checkout/manager.ts similarity index 64% rename from packages/clerk-js/src/core/modules/checkout.ts rename to packages/clerk-js/src/core/modules/checkout/manager.ts index 635a8421c86..b120abbea79 100644 --- a/packages/clerk-js/src/core/modules/checkout.ts +++ b/packages/clerk-js/src/core/modules/checkout/manager.ts @@ -1,25 +1,7 @@ -import type { CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, ConfirmCheckoutParams } from '@clerk/types'; - -import type { ClerkAPIResponseError } from '../..'; -import type { Clerk } from '../clerk'; +import type { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { CommerceCheckoutResource } from '@clerk/types'; type CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; - -export type CheckoutOptions = { - for?: 'organization'; - planPeriod: CommerceSubscriptionPlanPeriod; - planId: string; -}; - -export type CheckoutInstance = { - confirm: (params: ConfirmCheckoutParams) => Promise; - start: () => Promise; - clear: () => void; - finalize: (params: { redirectUrl?: string }) => void; - subscribe: (listener: (state: CheckoutCacheState) => void) => () => void; - getState: () => CheckoutCacheState; -}; - type CheckoutKey = string & { readonly __tag: 'CheckoutKey' }; type CheckoutCacheState = Readonly<{ @@ -92,12 +74,14 @@ function deriveCheckoutState(baseState: Omit { try { // Mark operation as in progress and clear any previous errors - updateCacheState({ [isRunningField]: true, error: null }); + updateCacheState({ + [isRunningField]: true, + error: null, + ...(operationType === 'start' ? { checkout: null } : {}), + }); // Execute the checkout operation const result = await operationFn(); @@ -193,78 +181,4 @@ function createCheckoutManager(cacheKey: CheckoutKey) { }; } -/** - * Generate cache key for checkout instance - */ -function cacheKey(options: { userId: string; orgId?: string; planId: string; planPeriod: string }): CheckoutKey { - const { userId, orgId, planId, planPeriod } = options; - return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}` as CheckoutKey; -} - -export type CheckoutFunction = (options: CheckoutOptions) => CheckoutInstance; - -/** - * Create a checkout instance with the given options - */ -function createCheckoutInstance(clerk: Clerk, options: CheckoutOptions): CheckoutInstance { - const { for: forOrganization, planId, planPeriod } = options; - - if (!clerk.user) { - throw new Error('Clerk: User is not authenticated'); - } - - if (forOrganization === 'organization' && !clerk.organization) { - throw new Error('Clerk: Use `setActive` to set the organization'); - } - - const checkoutKey = cacheKey({ - userId: clerk.user.id, - orgId: forOrganization === 'organization' ? clerk.organization?.id : undefined, - planId, - planPeriod, - }); - - const manager = createCheckoutManager(checkoutKey); - - const start = async (): Promise => { - return manager.executeOperation('start', async () => { - const result = await clerk.billing?.startCheckout({ - ...(forOrganization === 'organization' ? { orgId: clerk.organization?.id } : {}), - planId, - planPeriod, - }); - return result; - }); - }; - - const confirm = async (params: ConfirmCheckoutParams): Promise => { - return manager.executeOperation('confirm', async () => { - const checkout = manager.getCacheState().checkout; - if (!checkout) { - throw new Error('Clerk: Call `start` before `confirm`'); - } - return checkout.confirm(params); - }); - }; - - const finalize = ({ redirectUrl }: { redirectUrl?: string }) => { - void clerk.setActive({ session: clerk.session?.id, redirectUrl }); - }; - - const clear = () => manager.clearCheckout(); - - const subscribe = (listener: (state: CheckoutCacheState) => void) => { - return manager.subscribe(listener); - }; - - return { - start, - confirm, - finalize, - clear, - subscribe, - getState: manager.getCacheState, - }; -} - -export { createCheckoutInstance }; +export { createCheckoutManager, type CheckoutCacheState, type CheckoutKey }; From 61fe81ddb4fc3f8360f06dfbbf951f312f987c92 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 27 Jun 2025 19:29:04 +0300 Subject: [PATCH 18/36] experimental provider --- .../clerk-js/src/ui/components/Checkout/CheckoutPage.tsx | 5 ++++- .../clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx | 2 +- packages/nextjs/src/client-boundary/hooks.ts | 2 +- packages/nextjs/src/index.ts | 2 +- packages/react/src/contexts/ClerkContextProvider.tsx | 2 +- packages/react/src/hooks/index.ts | 2 +- packages/shared/src/react/contexts.tsx | 4 ++-- packages/shared/src/react/index.ts | 2 +- 8 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx index 651f7304c93..f183131897d 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -1,4 +1,7 @@ -import { __experimental_useCheckout as useCheckout, CheckoutProvider } from '@clerk/shared/react'; +import { + __experimental_CheckoutProvider as CheckoutProvider, + __experimental_useCheckout as useCheckout, +} from '@clerk/shared/react'; import { useEffect, useMemo } from 'react'; import { useCheckoutContext } from '@/ui/contexts/components'; diff --git a/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx b/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx index 0bbabbf6f1e..b4389dc9363 100644 --- a/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx +++ b/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx @@ -1,5 +1,5 @@ import { - CheckoutProvider, + __experimental_CheckoutProvider as CheckoutProvider, ClerkInstanceContext, ClientContext, OrganizationProvider, diff --git a/packages/nextjs/src/client-boundary/hooks.ts b/packages/nextjs/src/client-boundary/hooks.ts index 5555f2c6fe0..b421af88bf4 100644 --- a/packages/nextjs/src/client-boundary/hooks.ts +++ b/packages/nextjs/src/client-boundary/hooks.ts @@ -12,7 +12,7 @@ export { useUser, useReverification, __experimental_useCheckout, - CheckoutProvider, + __experimental_CheckoutProvider, } from '@clerk/clerk-react'; export { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index d262f2279d0..36cfe4a4940 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -55,7 +55,7 @@ export { useUser, useReverification, __experimental_useCheckout, - CheckoutProvider, + __experimental_CheckoutProvider, } from './client-boundary/hooks'; /** diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index c1e6b855dd5..f38f9f785f5 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -1,6 +1,6 @@ import { deriveState } from '@clerk/shared/deriveState'; import { - CheckoutProvider, + __experimental_CheckoutProvider as CheckoutProvider, ClientContext, OrganizationProvider, SessionContext, diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 9d4719fd1e2..b06246921dc 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -11,5 +11,5 @@ export { useSession, useReverification, __experimental_useCheckout, - CheckoutProvider, + __experimental_CheckoutProvider, } from '@clerk/shared/react'; diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index 3565d87cd0e..33b5e231c9f 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -32,7 +32,7 @@ type UseCheckoutOptions = { const [CheckoutContext, useCheckoutContext] = createContextAndHook('CheckoutContext'); -const CheckoutProvider = ({ children, ...rest }: PropsWithChildren) => { +const __experimental_CheckoutProvider = ({ children, ...rest }: PropsWithChildren) => { return {children}; }; @@ -115,6 +115,6 @@ export { ClerkInstanceContext, useClerkInstanceContext, useCheckoutContext, - CheckoutProvider, + __experimental_CheckoutProvider, useAssertWrappedByClerkProvider, }; diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index be2c2e2aa3f..dc0c3d1ecd3 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -14,5 +14,5 @@ export { UserContext, useSessionContext, useUserContext, - CheckoutProvider, + __experimental_CheckoutProvider, } from './contexts'; From ec9ea1c4235cbb3e132b4e5adf0c78f7986bb69c Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 27 Jun 2025 19:59:01 +0300 Subject: [PATCH 19/36] introduce `ClerkAPIResponseError` in @clerk/types --- packages/shared/src/error.ts | 63 +++++++++++++++++++++++++++++++++--- packages/types/src/api.ts | 12 +++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 77f2479e516..d5462079bc1 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -1,20 +1,36 @@ -import type { ClerkAPIError, ClerkAPIErrorJSON } from '@clerk/types'; +import type { + ClerkAPIError, + ClerkAPIErrorJSON, + ClerkAPIResponseError as ClerkAPIResponseErrorInterface, +} from '@clerk/types'; +/** + * + */ export function isUnauthorizedError(e: any): boolean { const status = e?.status; const code = e?.errors?.[0]?.code; return code === 'authentication_invalid' && status === 401; } +/** + * + */ export function isCaptchaError(e: ClerkAPIResponseError): boolean { return ['captcha_invalid', 'captcha_not_enabled', 'captcha_missing_token'].includes(e.errors[0].code); } +/** + * + */ export function is4xxError(e: any): boolean { const status = e?.status; return !!status && status >= 400 && status < 500; } +/** + * + */ export function isNetworkError(e: any): boolean { // TODO: revise during error handling epic const message = (`${e.message}${e.name}` || '').toLowerCase().replace(/\s+/g, ''); @@ -36,10 +52,16 @@ export interface MetamaskError extends Error { data?: unknown; } +/** + * + */ export function isKnownError(error: any): error is ClerkAPIResponseError | ClerkRuntimeError | MetamaskError { return isClerkAPIResponseError(error) || isMetamaskError(error) || isClerkRuntimeError(error); } +/** + * + */ export function isClerkAPIResponseError(err: any): err is ClerkAPIResponseError { return 'clerkError' in err; } @@ -47,8 +69,8 @@ export function isClerkAPIResponseError(err: any): err is ClerkAPIResponseError /** * Checks if the provided error object is an instance of ClerkRuntimeError. * - * @param {any} err - The error object to check. - * @returns {boolean} True if the error is a ClerkRuntimeError, false otherwise. + * @param err - The error object to check. + * @returns True if the error is a ClerkRuntimeError, false otherwise. * * @example * const error = new ClerkRuntimeError('An error occurred'); @@ -64,26 +86,44 @@ export function isClerkRuntimeError(err: any): err is ClerkRuntimeError { return 'clerkRuntimeError' in err; } +/** + * + */ export function isReverificationCancelledError(err: any) { return isClerkRuntimeError(err) && err.code === 'reverification_cancelled'; } +/** + * + */ export function isMetamaskError(err: any): err is MetamaskError { return 'code' in err && [4001, 32602, 32603].includes(err.code) && 'message' in err; } +/** + * + */ export function isUserLockedError(err: any) { return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'user_locked'; } +/** + * + */ export function isPasswordPwnedError(err: any) { return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'form_password_pwned'; } +/** + * + */ export function parseErrors(data: ClerkAPIErrorJSON[] = []): ClerkAPIError[] { return data.length > 0 ? data.map(parseError) : []; } +/** + * + */ export function parseError(error: ClerkAPIErrorJSON): ClerkAPIError { return { code: error.code, @@ -100,6 +140,9 @@ export function parseError(error: ClerkAPIErrorJSON): ClerkAPIError { }; } +/** + * + */ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { return { code: error?.code || '', @@ -116,7 +159,7 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { }; } -export class ClerkAPIResponseError extends Error { +export class ClerkAPIResponseError extends Error implements ClerkAPIResponseErrorInterface { clerkError: true; status: number; @@ -156,6 +199,7 @@ export class ClerkAPIResponseError extends Error { * Custom error class for representing Clerk runtime errors. * * @class ClerkRuntimeError + * * @example * throw new ClerkRuntimeError('An error occurred', { code: 'password_invalid' }); */ @@ -194,7 +238,7 @@ export class ClerkRuntimeError extends Error { /** * Returns a string representation of the error. * - * @returns {string} A formatted string with the error name and message. + * @returns A formatted string with the error name and message. */ public toString = () => { return `[${this.name}]\nMessage:${this.message}`; @@ -212,6 +256,9 @@ export class EmailLinkError extends Error { } } +/** + * + */ export function isEmailLinkError(err: Error): err is EmailLinkError { return err.name === 'EmailLinkError'; } @@ -270,6 +317,9 @@ export interface ErrorThrower { throw(message: string): never; } +/** + * + */ export function buildErrorThrower({ packageName, customMessages }: ErrorThrowerOptions): ErrorThrower { let pkg = packageName; @@ -278,6 +328,9 @@ export function buildErrorThrower({ packageName, customMessages }: ErrorThrowerO ...customMessages, }; + /** + * + */ function buildMessage(rawMessage: string, replacements?: Record) { if (!replacements) { return `${pkg}: ${rawMessage}`; diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 80a96be7a51..5e6e0e840df 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -43,3 +43,15 @@ export interface ClerkRuntimeError { code: string; message: string; } + +/** + * Interface representing a Clerk API Response Error. + */ +export interface ClerkAPIResponseError extends Error { + clerkError: true; + status: number; + message: string; + clerkTraceId?: string; + retryAfter?: number; + errors: ClerkAPIError[]; +} From 907d1498ee2373f302406729e187e3962af10cac Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 27 Jun 2025 20:18:37 +0300 Subject: [PATCH 20/36] handle public types --- packages/clerk-js/src/core/clerk.ts | 7 ++- .../checkout/__tests__/manager.spec.ts | 3 +- .../src/core/modules/checkout/instance.ts | 34 ++++------- .../src/core/modules/checkout/manager.ts | 31 ++++------ .../shared/src/react/hooks/useCheckout.ts | 24 ++++---- packages/types/src/clerk.ts | 60 +++++++++++-------- 6 files changed, 76 insertions(+), 83 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index c1fc30d4634..3a1d10f12b7 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -15,6 +15,8 @@ import { import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url'; import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils'; import type { + __experimental_CheckoutInstance, + __experimental_CheckoutOptions, __internal_CheckoutProps, __internal_ComponentNavigationContext, __internal_OAuthConsentProps, @@ -136,7 +138,6 @@ import type { FapiClient, FapiRequestCallback } from './fapiClient'; import { createFapiClient } from './fapiClient'; import { createClientFromJwt } from './jwt-client'; import { APIKeys } from './modules/apiKeys'; -import type { CheckoutFunction, CheckoutInstance, CheckoutOptions } from './modules/checkout/instance'; import { createCheckoutInstance } from './modules/checkout/instance'; import { CommerceBilling } from './modules/commerce'; import { @@ -197,7 +198,7 @@ export class Clerk implements ClerkInterface { }; private static _billing: CommerceBillingNamespace; private static _apiKeys: APIKeysNamespace; - private _checkout: CheckoutFunction | undefined; + private _checkout: ClerkInterface['__experimental_checkout'] | undefined; public client: ClientResource | undefined; public session: SignedInSessionResource | null | undefined; @@ -340,7 +341,7 @@ export class Clerk implements ClerkInterface { return Clerk._apiKeys; } - __experimental_checkout(options: CheckoutOptions): CheckoutInstance { + __experimental_checkout(options: __experimental_CheckoutOptions): __experimental_CheckoutInstance { if (!this._checkout) { this._checkout = params => createCheckoutInstance(this, params); } diff --git a/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts b/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts index 3019063493b..5085014b742 100644 --- a/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts +++ b/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts @@ -1,5 +1,4 @@ -import type { ClerkAPIResponseError } from '@clerk/shared/error'; -import type { CommerceCheckoutResource } from '@clerk/types'; +import type { ClerkAPIResponseError, CommerceCheckoutResource } from '@clerk/types'; import type { MockedFunction } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/packages/clerk-js/src/core/modules/checkout/instance.ts b/packages/clerk-js/src/core/modules/checkout/instance.ts index 49bdf3903f4..1b5285724eb 100644 --- a/packages/clerk-js/src/core/modules/checkout/instance.ts +++ b/packages/clerk-js/src/core/modules/checkout/instance.ts @@ -1,22 +1,13 @@ -import type { CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, ConfirmCheckoutParams } from '@clerk/types'; +import type { + __experimental_CheckoutCacheState, + __experimental_CheckoutInstance, + __experimental_CheckoutOptions, + CommerceCheckoutResource, + ConfirmCheckoutParams, +} from '@clerk/types'; import type { Clerk } from '../../clerk'; -import { type CheckoutCacheState, type CheckoutKey, createCheckoutManager } from './manager'; - -export type CheckoutOptions = { - for?: 'organization'; - planPeriod: CommerceSubscriptionPlanPeriod; - planId: string; -}; - -export type CheckoutInstance = { - confirm: (params: ConfirmCheckoutParams) => Promise; - start: () => Promise; - clear: () => void; - finalize: (params: { redirectUrl?: string }) => void; - subscribe: (listener: (state: CheckoutCacheState) => void) => () => void; - getState: () => CheckoutCacheState; -}; +import { type CheckoutKey, createCheckoutManager } from './manager'; /** * Generate cache key for checkout instance @@ -26,12 +17,13 @@ function cacheKey(options: { userId: string; orgId?: string; planId: string; pla return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}` as CheckoutKey; } -export type CheckoutFunction = (options: CheckoutOptions) => CheckoutInstance; - /** * Create a checkout instance with the given options */ -function createCheckoutInstance(clerk: Clerk, options: CheckoutOptions): CheckoutInstance { +function createCheckoutInstance( + clerk: Clerk, + options: __experimental_CheckoutOptions, +): __experimental_CheckoutInstance { const { for: forOrganization, planId, planPeriod } = options; if (!clerk.user) { @@ -78,7 +70,7 @@ function createCheckoutInstance(clerk: Clerk, options: CheckoutOptions): Checkou const clear = () => manager.clearCheckout(); - const subscribe = (listener: (state: CheckoutCacheState) => void) => { + const subscribe = (listener: (state: __experimental_CheckoutCacheState) => void) => { return manager.subscribe(listener); }; diff --git a/packages/clerk-js/src/core/modules/checkout/manager.ts b/packages/clerk-js/src/core/modules/checkout/manager.ts index b120abbea79..7222d524797 100644 --- a/packages/clerk-js/src/core/modules/checkout/manager.ts +++ b/packages/clerk-js/src/core/modules/checkout/manager.ts @@ -1,18 +1,7 @@ -import type { ClerkAPIResponseError } from '@clerk/shared/error'; -import type { CommerceCheckoutResource } from '@clerk/types'; +import type { __experimental_CheckoutCacheState, ClerkAPIResponseError, CommerceCheckoutResource } from '@clerk/types'; -type CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; type CheckoutKey = string & { readonly __tag: 'CheckoutKey' }; -type CheckoutCacheState = Readonly<{ - isStarting: boolean; - isConfirming: boolean; - error: ClerkAPIResponseError | null; - checkout: CommerceCheckoutResource | null; - fetchStatus: 'idle' | 'fetching' | 'error'; - status: CheckoutStatus; -}>; - const createManagerCache = () => { const cache = new Map(); const listeners = new Map void>>(); @@ -37,7 +26,7 @@ const createManagerCache = () => { }; }; -const managerCache = createManagerCache(); +const managerCache = createManagerCache(); const CHECKOUT_STATUS = { AWAITING_INITIALIZATION: 'awaiting_initialization', @@ -54,7 +43,9 @@ export const FETCH_STATUS = { /** * Derives the checkout state from the base state. */ -function deriveCheckoutState(baseState: Omit): CheckoutCacheState { +function deriveCheckoutState( + baseState: Omit<__experimental_CheckoutCacheState, 'fetchStatus' | 'status'>, +): __experimental_CheckoutCacheState { const fetchStatus = (() => { if (baseState.isStarting || baseState.isConfirming) return FETCH_STATUS.FETCHING; if (baseState.error) return FETCH_STATUS.ERROR; @@ -74,7 +65,7 @@ function deriveCheckoutState(baseState: Omit listener(getCacheState())); }; - const getCacheState = (): CheckoutCacheState => { + const getCacheState = (): __experimental_CheckoutCacheState => { return managerCache.cache.get(cacheKey) || defaultCacheState; }; - const updateCacheState = (updates: Partial>): void => { + const updateCacheState = ( + updates: Partial>, + ): void => { const currentState = getCacheState(); const baseState = { ...currentState, ...updates }; const newState = deriveCheckoutState(baseState); @@ -116,7 +109,7 @@ function createCheckoutManager(cacheKey: CheckoutKey) { }; return { - subscribe(listener: (newState: CheckoutCacheState) => void): () => void { + subscribe(listener: (newState: __experimental_CheckoutCacheState) => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); @@ -181,4 +174,4 @@ function createCheckoutManager(cacheKey: CheckoutKey) { }; } -export { createCheckoutManager, type CheckoutCacheState, type CheckoutKey }; +export { createCheckoutManager, type CheckoutKey }; diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index d7803f405bd..be14dde4b30 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -1,4 +1,9 @@ -import type { CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, ConfirmCheckoutParams } from '@clerk/types'; +import type { + __experimental_CheckoutCacheState, + CommerceCheckoutResource, + CommerceSubscriptionPlanPeriod, + ConfirmCheckoutParams, +} from '@clerk/types'; import { useMemo, useSyncExternalStore } from 'react'; import type { ClerkAPIResponseError } from '../..'; @@ -7,8 +12,6 @@ import { useClerk } from './useClerk'; import { useOrganization } from './useOrganization'; import { useUser } from './useUser'; -type CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; - /** * Utility type that removes function properties from a type. */ @@ -39,26 +42,17 @@ type NullableCheckoutProperties = Nullable< __internal_checkout: null; }; -type CheckoutCacheState = { - isStarting: boolean; - isConfirming: boolean; - error: ClerkAPIResponseError | null; - checkout: CommerceCheckoutResource | null; - fetchStatus: 'idle' | 'fetching' | 'error'; - status: CheckoutStatus; -}; - type UseCheckoutReturn = (CheckoutProperties | NullableCheckoutProperties) & { confirm: (params: ConfirmCheckoutParams) => Promise; start: () => Promise; isStarting: boolean; isConfirming: boolean; error: ClerkAPIResponseError | null; - status: CheckoutStatus; + status: __experimental_CheckoutCacheState['status']; clear: () => void; finalize: (params: { redirectUrl?: string }) => void; fetchStatus: 'idle' | 'fetching' | 'error'; - getState: () => CheckoutCacheState; + getState: () => __experimental_CheckoutCacheState; }; type UseCheckoutOptions = { @@ -122,7 +116,9 @@ export const useCheckout = (options?: UseCheckoutOptions): UseCheckoutReturn => return { ...properties, getState: manager.getState, + // @ts-expect-error - this is a temporary fix to allow the checkout to be null checkout: null, + // @ts-expect-error - this is a temporary fix to allow the checkout to be null __internal_checkout: managerProperties.checkout, start: manager.start, confirm: manager.confirm, diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index a018b365585..7ab740fc85e 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1,3 +1,4 @@ +import type { ClerkAPIResponseError } from './api'; import type { APIKeysNamespace } from './apiKeys'; import type { APIKeysTheme, @@ -20,9 +21,11 @@ import type { import type { ClientResource } from './client'; import type { CommerceBillingNamespace, + CommerceCheckoutResource, CommercePlanResource, CommerceSubscriberType, CommerceSubscriptionPlanPeriod, + ConfirmCheckoutParams, } from './commerce'; import type { CustomMenuItem } from './customMenuItems'; import type { CustomPage } from './customPages'; @@ -55,25 +58,33 @@ import type { UserResource } from './user'; import type { Autocomplete, DeepPartial, DeepSnakeToCamel } from './utils'; import type { WaitlistResource } from './waitlist'; -// type CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; - -// type CheckoutCacheState = { -// isStarting: boolean; -// isConfirming: boolean; -// error: ClerkAPIResponseError | null; -// checkout: CommerceCheckoutResource | null; -// fetchStatus: 'idle' | 'fetching' | 'error'; -// status: CheckoutStatus; -// }; - -// export type CheckoutInstance = { -// confirm: (params: ConfirmCheckoutParams) => Promise; -// start: () => Promise; -// clear: () => void; -// finalize: (params: { redirectUrl?: string }) => void; -// subscribe: (listener: (state: CheckoutInstance) => void) => () => void; -// getState: () => CheckoutCacheState; -// }; +type __experimental_CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; + +export type __experimental_CheckoutCacheState = Readonly<{ + isStarting: boolean; + isConfirming: boolean; + error: ClerkAPIResponseError | null; + checkout: CommerceCheckoutResource | null; + fetchStatus: 'idle' | 'fetching' | 'error'; + status: __experimental_CheckoutStatus; +}>; + +export type __experimental_CheckoutOptions = { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; +}; + +export type __experimental_CheckoutInstance = { + confirm: (params: ConfirmCheckoutParams) => Promise; + start: () => Promise; + clear: () => void; + finalize: (params: { redirectUrl?: string }) => void; + subscribe: (listener: (state: __experimental_CheckoutCacheState) => void) => () => void; + getState: () => __experimental_CheckoutCacheState; +}; + +type __experimental_CheckoutFunction = (options: __experimental_CheckoutOptions) => __experimental_CheckoutInstance; /** * @inline @@ -801,11 +812,12 @@ export interface Clerk { */ apiKeys: APIKeysNamespace; - __experimental_checkout: (options: { - for?: 'organization'; - planPeriod: CommerceSubscriptionPlanPeriod; - planId: string; - }) => any; + /** + * Checkout API + * @experimental + * This API is in early access and may change in future releases. + */ + __experimental_checkout: __experimental_CheckoutFunction; } export type HandleOAuthCallbackParams = TransferableOption & From f501d70a49a902bebbf4c2b0c05d0fcb2a7f4896 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 27 Jun 2025 20:30:15 +0300 Subject: [PATCH 21/36] update snapshot --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 2 ++ packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap | 2 ++ .../src/__tests__/__snapshots__/exports.test.ts.snap | 2 ++ 3 files changed, 6 insertions(+) diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 3a757e9ec5d..88cf376df50 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -32,6 +32,8 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserButton", "UserProfile", "Waitlist", + "__experimental_CheckoutProvider", + "__experimental_useCheckout", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap index e2ae044851f..6cca006a4fb 100644 --- a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap @@ -34,6 +34,8 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserProfile", "Waitlist", "WithClerkState", + "__experimental_CheckoutProvider", + "__experimental_useCheckout", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 296c327d52e..21505a9fe57 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -44,6 +44,8 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserButton", "UserProfile", "Waitlist", + "__experimental_CheckoutProvider", + "__experimental_useCheckout", "useAuth", "useClerk", "useEmailLink", From 9a16e2947aeeeaa8dbb13bbe035d721c95011ad0 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 4 Jul 2025 14:47:09 +0300 Subject: [PATCH 22/36] update snapshots --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 2 ++ packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap | 2 ++ .../src/__tests__/__snapshots__/exports.test.ts.snap | 2 ++ 3 files changed, 6 insertions(+) diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index d257ba26175..8ad881f8b90 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -32,8 +32,10 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserButton", "UserProfile", "Waitlist", + "__experimental_CheckoutProvider", "__experimental_PaymentElement", "__experimental_PaymentElementProvider", + "__experimental_useCheckout", "__experimental_usePaymentElement", "useAuth", "useClerk", diff --git a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap index 98821467884..c642b325a6e 100644 --- a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap @@ -34,8 +34,10 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserProfile", "Waitlist", "WithClerkState", + "__experimental_CheckoutProvider", "__experimental_PaymentElement", "__experimental_PaymentElementProvider", + "__experimental_useCheckout", "__experimental_usePaymentElement", "useAuth", "useClerk", diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index fd0feb2b204..be0a59fc5f9 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -44,8 +44,10 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserButton", "UserProfile", "Waitlist", + "__experimental_CheckoutProvider", "__experimental_PaymentElement", "__experimental_PaymentElementProvider", + "__experimental_useCheckout", "__experimental_usePaymentElement", "useAuth", "useClerk", From 5a1ffa341f0aba84a3d83477c481b9b7f4a55dff Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 7 Jul 2025 10:40:36 +0300 Subject: [PATCH 23/36] remove `__internal_checkout` --- .../components/Checkout/CheckoutComplete.tsx | 27 +++++++------- .../ui/components/Checkout/CheckoutForm.tsx | 37 ++++++++++--------- .../PaymentSources/AddPaymentSource.tsx | 4 +- packages/shared/src/react/commerce.tsx | 5 ++- .../shared/src/react/hooks/useCheckout.ts | 29 ++++----------- 5 files changed, 44 insertions(+), 58 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index eb09c51b776..b77e1e8fd9d 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -19,6 +19,7 @@ export const CheckoutComplete = () => { const { setIsOpen } = useDrawerContext(); const { newSubscriptionRedirectUrl } = useCheckoutContext(); const checkout = useCheckout(); + const { totals, paymentSource, planPeriodStart } = checkout; const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 }); const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 }); @@ -82,7 +83,7 @@ export const CheckoutComplete = () => { } }; - if (!checkout) { + if (!totals) { return null; } @@ -309,7 +310,7 @@ export const CheckoutComplete = () => { as='h2' textVariant='h2' localizationKey={ - checkout.totals.totalDueNow.amount > 0 + totals.totalDueNow.amount > 0 ? localizationKeys('commerce.checkout.title__paymentSuccessful') : localizationKeys('commerce.checkout.title__subscriptionSuccessful') } @@ -364,7 +365,7 @@ export const CheckoutComplete = () => { }), })} localizationKey={ - checkout.totals.totalDueNow.amount > 0 + totals.totalDueNow.amount > 0 ? localizationKeys('commerce.checkout.description__paymentSuccessful') : localizationKeys('commerce.checkout.description__subscriptionSuccessful') } @@ -396,28 +397,26 @@ export const CheckoutComplete = () => { - + 0 + totals.totalDueNow.amount > 0 ? localizationKeys('commerce.checkout.lineItems.title__paymentMethod') : localizationKeys('commerce.checkout.lineItems.title__subscriptionBegins') } /> 0 - ? checkout.paymentSource - ? checkout.paymentSource.paymentMethod !== 'card' - ? `${capitalize(checkout.paymentSource.paymentMethod)}` - : `${capitalize(checkout.paymentSource.cardType)} ⋯ ${checkout.paymentSource.last4}` + totals.totalDueNow.amount > 0 + ? paymentSource + ? paymentSource.paymentMethod !== 'card' + ? `${capitalize(paymentSource.paymentMethod)}` + : `${capitalize(paymentSource.cardType)} ⋯ ${paymentSource.last4}` : '–' - : checkout.planPeriodStart - ? formatDate(new Date(checkout.planPeriodStart)) + : planPeriodStart + ? formatDate(new Date(planPeriodStart)) : '–' } /> diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index eb267cb3573..eebbe53246a 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -1,10 +1,5 @@ import { __experimental_useCheckout as useCheckout, useOrganization } from '@clerk/shared/react'; -import type { - CommerceCheckoutResource, - CommerceMoney, - CommercePaymentSourceResource, - ConfirmCheckoutParams, -} from '@clerk/types'; +import type { CommerceMoney, CommercePaymentSourceResource, ConfirmCheckoutParams } from '@clerk/types'; import { useMemo, useState } from 'react'; import { Card } from '@/ui/elements/Card'; @@ -29,7 +24,8 @@ const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); export const CheckoutForm = withCardStateProvider(() => { const checkout = useCheckout(); - const { id, plan, totals, isImmediatePlanChange, __internal_checkout, planPeriod } = checkout; + + const { id, plan, totals, isImmediatePlanChange, planPeriod } = checkout; if (!id) { return null; @@ -112,7 +108,7 @@ export const CheckoutForm = withCardStateProvider(() => { )} - + ); }); @@ -169,13 +165,18 @@ const useCheckoutMutations = () => { }; }; -const CheckoutFormElements = ({ checkout }: { checkout: CommerceCheckoutResource }) => { +const CheckoutFormElements = () => { + const { id, totals } = useCheckout(); const { data: paymentSources } = usePaymentMethods(); const [paymentMethodSource, setPaymentMethodSource] = useState(() => paymentSources.length > 0 ? 'existing' : 'new', ); + if (!id) { + return null; + } + return ( ({ padding: t.space.$4 })} > {/* only show if there are payment sources and there is a total due now */} - {paymentSources.length > 0 && checkout.totals.totalDueNow.amount > 0 && ( + {paymentSources.length > 0 && totals.totalDueNow.amount > 0 && ( )} @@ -274,7 +274,8 @@ export const PayWithTestPaymentSource = () => { const AddPaymentSourceForCheckout = withCardStateProvider(() => { const { addPaymentSourceAndPay } = useCheckoutMutations(); - const { id, __internal_checkout, totals } = useCheckout(); + const checkout = useCheckout(); + const { id, totals } = checkout; if (!id) { return null; @@ -283,7 +284,7 @@ const AddPaymentSourceForCheckout = withCardStateProvider(() => { return ( @@ -304,18 +305,18 @@ const AddPaymentSourceForCheckout = withCardStateProvider(() => { const ExistingPaymentSourceForm = withCardStateProvider( ({ - checkout, totalDueNow, paymentSources, }: { - checkout: CommerceCheckoutResource; totalDueNow: CommerceMoney; paymentSources: CommercePaymentSourceResource[]; }) => { + const { paymentSource } = useCheckout(); + const { payWithExistingPaymentSource } = useCheckoutMutations(); const card = useCardState(); const [selectedPaymentSource, setSelectedPaymentSource] = useState( - checkout.paymentSource || paymentSources.find(p => p.isDefault), + paymentSource || paymentSources.find(p => p.isDefault), ); const options = useMemo(() => { @@ -341,7 +342,7 @@ const ExistingPaymentSourceForm = withCardStateProvider( rowGap: t.space.$4, })} > - {checkout.totals.totalDueNow.amount > 0 ? ( + {totalDueNow.amount > 0 ? (