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)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22858?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-13 14:30:38 +02:00
committed by GitHub
parent 9f5d17d1f0
commit 6201d06141
8 changed files with 217 additions and 22 deletions
@@ -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 (
<OnboardingLayout freeCredits={freeCredits}>
<OnboardingTransitionOutlet />
@@ -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();
});
});
@@ -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]);
};
@@ -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;
@@ -0,0 +1,11 @@
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
export const getIsPlanRequired = ({
isBillingEnabled,
currentWorkspace,
}: {
isBillingEnabled: boolean;
currentWorkspace: Pick<CurrentWorkspace, 'billingSubscriptions'> | null;
}) =>
isBillingEnabled &&
(currentWorkspace?.billingSubscriptions?.length ?? 0) === 0;
@@ -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<string, Promise<Stripe | null>>();
const getStripePromise = (publishableKey: string): Promise<Stripe | null> => {
const existingPromise = stripePromiseByKey.get(publishableKey);
if (isDefined(existingPromise)) {
return existingPromise;
}
const stripePromise = loadStripe(publishableKey);
stripePromiseByKey.set(publishableKey, stripePromise);
return stripePromise;
};
export const useStripePromise = (): Promise<Stripe | null> | null => {
const billing = useAtomStateValue(billingState);
const publishableKey = billing?.stripePublishableKey;
return isDefined(publishableKey) && publishableKey !== ''
return isNonEmptyString(publishableKey)
? getStripePromise(publishableKey)
: null;
};
@@ -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();
});
});
@@ -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<string, Promise<Stripe | null>>();
export const getStripePromise = (
publishableKey: string,
): Promise<Stripe | null> => {
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;
};