From 6201d06141b6435e865696bc132330ee1ee8f9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 13 Jul 2026 14:30:38 +0200 Subject: [PATCH] Preload Stripe.js before the onboarding payment step (#22858) ## Context On the plan-required onboarding step, the card form was slow to appear because Stripe.js is loaded lazily (`@stripe/stripe-js/pure`): the script download only started once the payment page rendered, and the PaymentElement iframe could only boot after that. ## What this does - Adds `usePreloadStripeForPlanRequiredStep`, called once from `OnboardingStepLayout` (the shared layout for the authenticated onboarding step routes), so Stripe.js is already loaded by the time the user reaches the payment step. The hook only triggers when billing is enabled, the workspace has no subscription yet, and a publishable key is configured, so self-hosted instances still never contact Stripe. - Moves the memoized loader to `settings/billing/utils/getStripePromise.ts`, shared by `useStripePromise` and the preload hook. - Stops caching failed script loads: previously a rejected `loadStripe` promise stayed in the cache forever, which would have made a failed preload permanently break the payment form. Now a later call retries (stripe-js re-injects the script tag on retry). - Extracts the plan-required predicate into `onboarding/utils/getIsPlanRequired.ts`, now shared with `useSetNextOnboardingStatus`. The in-app add-credit-card modal is intentionally left untouched: it has no preceding step to preload from. ## Tests - `getStripePromise.test.ts`: dedup per publishable key, retry after a failed load. - `usePreloadStripeForPlanRequiredStep.test.ts`: preloads when billing is enabled and no subscription exists; skips when billing is disabled, a subscription exists, or the key is missing. --- _Generated by [Claude Code](https://claude.ai/code/session_01NtUN99tHPZ6bPWwpYKMbpE)_ Review in cubic --- .../components/OnboardingStepLayout.tsx | 3 + ...sePreloadStripeForPlanRequiredStep.test.ts | 101 ++++++++++++++++++ .../usePreloadStripeForPlanRequiredStep.ts | 26 +++++ .../hooks/useSetNextOnboardingStatus.ts | 8 +- .../onboarding/utils/getIsPlanRequired.ts | 11 ++ .../billing/hooks/useStripePromise.ts | 22 +--- .../utils/__tests__/getStripePromise.test.ts | 42 ++++++++ .../billing/utils/getStripePromise.ts | 26 +++++ 8 files changed, 217 insertions(+), 22 deletions(-) create mode 100644 packages/twenty-front/src/modules/onboarding/hooks/__tests__/usePreloadStripeForPlanRequiredStep.test.ts create mode 100644 packages/twenty-front/src/modules/onboarding/hooks/usePreloadStripeForPlanRequiredStep.ts create mode 100644 packages/twenty-front/src/modules/onboarding/utils/getIsPlanRequired.ts create mode 100644 packages/twenty-front/src/modules/settings/billing/utils/__tests__/getStripePromise.test.ts create mode 100644 packages/twenty-front/src/modules/settings/billing/utils/getStripePromise.ts diff --git a/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx b/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx index 1e18008ef6..91c52396f1 100644 --- a/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx +++ b/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx @@ -1,10 +1,13 @@ import { OnboardingLayout } from '@/onboarding/components/OnboardingLayout'; import { OnboardingTransitionOutlet } from '@/onboarding/components/OnboardingTransitionOutlet'; import { useOnboardingFreeCreditsTotal } from '@/onboarding/hooks/useOnboardingFreeCreditsTotal'; +import { usePreloadStripeForPlanRequiredStep } from '@/onboarding/hooks/usePreloadStripeForPlanRequiredStep'; export const OnboardingStepLayout = () => { const freeCredits = useOnboardingFreeCreditsTotal(); + usePreloadStripeForPlanRequiredStep(); + return ( diff --git a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/usePreloadStripeForPlanRequiredStep.test.ts b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/usePreloadStripeForPlanRequiredStep.test.ts new file mode 100644 index 0000000000..9d2364ad69 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/usePreloadStripeForPlanRequiredStep.test.ts @@ -0,0 +1,101 @@ +import { act, renderHook } from '@testing-library/react'; +import { Provider as JotaiProvider } from 'jotai'; +import { createElement } from 'react'; + +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { billingState } from '@/client-config/states/billingState'; +import { usePreloadStripeForPlanRequiredStep } from '@/onboarding/hooks/usePreloadStripeForPlanRequiredStep'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { + jotaiStore, + resetJotaiStore, +} from '@/ui/utilities/state/jotai/jotaiStore'; + +import { loadStripe } from '@stripe/stripe-js/pure'; +import { mockCurrentWorkspace } from '~/testing/mock-data/users'; + +jest.mock('@stripe/stripe-js/pure', () => ({ + loadStripe: jest.fn().mockResolvedValue(null), +})); + +const loadStripeMock = jest.mocked(loadStripe); + +const Wrapper = ({ children }: { children: React.ReactNode }) => + createElement(JotaiProvider, { store: jotaiStore }, children); + +type RenderHooksOptions = { + isBillingEnabled?: boolean; + withSubscription?: boolean; + stripePublishableKey?: string; +}; + +const renderHooks = ({ + isBillingEnabled = true, + withSubscription = false, + stripePublishableKey, +}: RenderHooksOptions = {}) => { + const { result } = renderHook( + () => { + const setCurrentWorkspace = useSetAtomState(currentWorkspaceState); + const setBilling = useSetAtomState(billingState); + usePreloadStripeForPlanRequiredStep(); + return { setCurrentWorkspace, setBilling }; + }, + { + wrapper: Wrapper, + }, + ); + + act(() => { + result.current.setCurrentWorkspace({ + ...mockCurrentWorkspace, + billingSubscriptions: withSubscription + ? mockCurrentWorkspace.billingSubscriptions + : [], + }); + result.current.setBilling({ + __typename: 'Billing', + isBillingEnabled, + trialPeriods: [], + stripePublishableKey, + }); + }); +}; + +describe('usePreloadStripeForPlanRequiredStep', () => { + beforeEach(() => { + resetJotaiStore(); + jest.clearAllMocks(); + }); + + it('should preload Stripe when billing is enabled and the workspace has no subscription', () => { + renderHooks({ stripePublishableKey: 'pk_test_preload' }); + + expect(loadStripeMock).toHaveBeenCalledTimes(1); + expect(loadStripeMock).toHaveBeenCalledWith('pk_test_preload'); + }); + + it('should not preload Stripe when billing is disabled', () => { + renderHooks({ + isBillingEnabled: false, + stripePublishableKey: 'pk_test_billing_disabled', + }); + + expect(loadStripeMock).not.toHaveBeenCalled(); + }); + + it('should not preload Stripe when the workspace already has a subscription', () => { + renderHooks({ + withSubscription: true, + stripePublishableKey: 'pk_test_with_subscription', + }); + + expect(loadStripeMock).not.toHaveBeenCalled(); + }); + + it('should not preload Stripe when the publishable key is missing', () => { + renderHooks(); + + expect(loadStripeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front/src/modules/onboarding/hooks/usePreloadStripeForPlanRequiredStep.ts b/packages/twenty-front/src/modules/onboarding/hooks/usePreloadStripeForPlanRequiredStep.ts new file mode 100644 index 0000000000..a73dec27ff --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/hooks/usePreloadStripeForPlanRequiredStep.ts @@ -0,0 +1,26 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { useEffect } from 'react'; + +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { billingState } from '@/client-config/states/billingState'; +import { getIsPlanRequired } from '@/onboarding/utils/getIsPlanRequired'; +import { getStripePromise } from '@/settings/billing/utils/getStripePromise'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; + +export const usePreloadStripeForPlanRequiredStep = () => { + const billing = useAtomStateValue(billingState); + const currentWorkspace = useAtomStateValue(currentWorkspaceState); + + const isPlanRequired = getIsPlanRequired({ + isBillingEnabled: billing?.isBillingEnabled ?? false, + currentWorkspace, + }); + + const publishableKey = billing?.stripePublishableKey; + + useEffect(() => { + if (isPlanRequired && isNonEmptyString(publishableKey)) { + void getStripePromise(publishableKey); + } + }, [isPlanRequired, publishableKey]); +}; diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts index 385534b65e..0f96a39383 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts @@ -9,6 +9,7 @@ import { currentWorkspaceState, } from '@/auth/states/currentWorkspaceState'; import { billingState } from '@/client-config/states/billingState'; +import { getIsPlanRequired } from '@/onboarding/utils/getIsPlanRequired'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useCallback } from 'react'; @@ -26,9 +27,10 @@ const getNextOnboardingStatus = ({ currentWorkspace, isBillingEnabled, }: GetNextOnboardingStatusArgs) => { - const isPlanRequired = - isBillingEnabled && - (currentWorkspace?.billingSubscriptions?.length ?? 0) === 0; + const isPlanRequired = getIsPlanRequired({ + isBillingEnabled, + currentWorkspace, + }); if (currentUser?.onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION) { return OnboardingStatus.SYNC_EMAIL; diff --git a/packages/twenty-front/src/modules/onboarding/utils/getIsPlanRequired.ts b/packages/twenty-front/src/modules/onboarding/utils/getIsPlanRequired.ts new file mode 100644 index 0000000000..0e7e653117 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/utils/getIsPlanRequired.ts @@ -0,0 +1,11 @@ +import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState'; + +export const getIsPlanRequired = ({ + isBillingEnabled, + currentWorkspace, +}: { + isBillingEnabled: boolean; + currentWorkspace: Pick | null; +}) => + isBillingEnabled && + (currentWorkspace?.billingSubscriptions?.length ?? 0) === 0; diff --git a/packages/twenty-front/src/modules/settings/billing/hooks/useStripePromise.ts b/packages/twenty-front/src/modules/settings/billing/hooks/useStripePromise.ts index e38be9a163..2af348948e 100644 --- a/packages/twenty-front/src/modules/settings/billing/hooks/useStripePromise.ts +++ b/packages/twenty-front/src/modules/settings/billing/hooks/useStripePromise.ts @@ -1,30 +1,14 @@ import { billingState } from '@/client-config/states/billingState'; +import { getStripePromise } from '@/settings/billing/utils/getStripePromise'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isNonEmptyString } from '@sniptt/guards'; import { type Stripe } from '@stripe/stripe-js'; -import { loadStripe } from '@stripe/stripe-js/pure'; -import { isDefined } from 'twenty-shared/utils'; - -const stripePromiseByKey = new Map>(); - -const getStripePromise = (publishableKey: string): Promise => { - const existingPromise = stripePromiseByKey.get(publishableKey); - - if (isDefined(existingPromise)) { - return existingPromise; - } - - const stripePromise = loadStripe(publishableKey); - - stripePromiseByKey.set(publishableKey, stripePromise); - - return stripePromise; -}; export const useStripePromise = (): Promise | null => { const billing = useAtomStateValue(billingState); const publishableKey = billing?.stripePublishableKey; - return isDefined(publishableKey) && publishableKey !== '' + return isNonEmptyString(publishableKey) ? getStripePromise(publishableKey) : null; }; diff --git a/packages/twenty-front/src/modules/settings/billing/utils/__tests__/getStripePromise.test.ts b/packages/twenty-front/src/modules/settings/billing/utils/__tests__/getStripePromise.test.ts new file mode 100644 index 0000000000..083bd8aefc --- /dev/null +++ b/packages/twenty-front/src/modules/settings/billing/utils/__tests__/getStripePromise.test.ts @@ -0,0 +1,42 @@ +import { loadStripe } from '@stripe/stripe-js/pure'; + +import { getStripePromise } from '@/settings/billing/utils/getStripePromise'; + +jest.mock('@stripe/stripe-js/pure', () => ({ + loadStripe: jest.fn(), +})); + +const loadStripeMock = jest.mocked(loadStripe); + +describe('getStripePromise', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should load Stripe once per publishable key', async () => { + loadStripeMock.mockResolvedValue(null); + + const firstPromise = getStripePromise('pk_test_dedup'); + const secondPromise = getStripePromise('pk_test_dedup'); + + expect(secondPromise).toBe(firstPromise); + expect(loadStripeMock).toHaveBeenCalledTimes(1); + + await expect(firstPromise).resolves.toBeNull(); + }); + + it('should retry loading Stripe after a failed load', async () => { + loadStripeMock + .mockRejectedValueOnce(new Error('Failed to load Stripe.js')) + .mockResolvedValueOnce(null); + + await expect(getStripePromise('pk_test_retry')).rejects.toThrow( + 'Failed to load Stripe.js', + ); + + const retriedPromise = getStripePromise('pk_test_retry'); + + expect(loadStripeMock).toHaveBeenCalledTimes(2); + await expect(retriedPromise).resolves.toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/billing/utils/getStripePromise.ts b/packages/twenty-front/src/modules/settings/billing/utils/getStripePromise.ts new file mode 100644 index 0000000000..b47e612bea --- /dev/null +++ b/packages/twenty-front/src/modules/settings/billing/utils/getStripePromise.ts @@ -0,0 +1,26 @@ +import { type Stripe } from '@stripe/stripe-js'; +import { loadStripe } from '@stripe/stripe-js/pure'; +import { isDefined } from 'twenty-shared/utils'; + +const stripePromiseByKey = new Map>(); + +export const getStripePromise = ( + publishableKey: string, +): Promise => { + const existingPromise = stripePromiseByKey.get(publishableKey); + + if (isDefined(existingPromise)) { + return existingPromise; + } + + const stripePromise = loadStripe(publishableKey); + + stripePromiseByKey.set(publishableKey, stripePromise); + + // Drop failed loads so a later call can retry + stripePromise.catch(() => { + stripePromiseByKey.delete(publishableKey); + }); + + return stripePromise; +};