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:
@@ -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 />
|
||||
|
||||
+101
@@ -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();
|
||||
});
|
||||
});
|
||||
+26
@@ -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;
|
||||
};
|
||||
|
||||
+42
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user