Fix blank pages and add error recovery in onboarding (#23069)

Onboarding showed a blank page in two places: before the plan/payment
step, and briefly after the welcome animation.

`ChooseYourPlan` returned `null` while its `ListPlans` query loaded,
leaving the step empty during the crossfade. It now renders the step
loader, and the plans query is warmed from an earlier onboarding step so
the content is usually already there.

The post-completion redirect lands transiently on `/`, whose route
element was `<></>`, so the welcome animation could reveal an empty
page. It now renders a skeleton, and the `null` Suspense fallbacks on
payment-success and book-call are replaced too.

A failed `ListPlans` query was worse than a blank frame: `PLAN_REQUIRED`
redirects every route back to itself, so the user was locked out of the
product with no way to retry. That step and the billing settings page
now show a retryable error state.

One related fix found on the way: `BlankLayout` had no error boundary,
so a render-time throw anywhere in sign-in or onboarding took down the
whole app. `DefaultLayout` already had one.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23069?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:
Raphaël Bosi
2026-07-22 14:46:52 +02:00
committed by GitHub
parent 7e79b19f73
commit 0c964aee10
15 changed files with 361 additions and 32 deletions
@@ -8,6 +8,7 @@ import { useSnackBarOnQueryError } from '@/apollo/hooks/useSnackBarOnQueryError'
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { SettingsBillingContentSkeleton } from '@/settings/billing/components/SettingsBillingContentSkeleton';
import { SettingsBillingPlansErrorState } from '@/settings/billing/components/SettingsBillingPlansErrorState';
import { SettingsBillingTabBar } from '@/settings/billing/components/SettingsBillingTabBar';
import { usePlans } from '@/settings/billing/hooks/usePlans';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
@@ -29,6 +30,7 @@ export const SettingsBillingPageLayout = ({
error: plansError,
isPlansLoaded,
loading: arePlansLoading,
refetch: refetchPlans,
} = usePlans({ skip: !isBillingEnabled });
useSnackBarOnQueryError(plansError, t`Failed to load billing plans`);
@@ -56,7 +58,7 @@ export const SettingsBillingPageLayout = ({
) : isPlansLoaded ? (
children
) : (
<></>
<SettingsBillingPlansErrorState onRetry={() => void refetchPlans()} />
)}
</SettingsPageLayout>
);
@@ -0,0 +1,40 @@
import { useLingui } from '@lingui/react/macro';
import {
AnimatedPlaceholder,
AnimatedPlaceholderErrorContainer,
AnimatedPlaceholderErrorSubTitle,
AnimatedPlaceholderErrorTextContainer,
AnimatedPlaceholderErrorTitle,
} from 'twenty-ui/feedback';
import { IconRefresh } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
type SettingsBillingPlansErrorStateProps = {
onRetry: () => void;
};
export const SettingsBillingPlansErrorState = ({
onRetry,
}: SettingsBillingPlansErrorStateProps) => {
const { t } = useLingui();
return (
<AnimatedPlaceholderErrorContainer>
<AnimatedPlaceholder type="errorIndex" />
<AnimatedPlaceholderErrorTextContainer>
<AnimatedPlaceholderErrorTitle>
{t`We couldn't load the plans`}
</AnimatedPlaceholderErrorTitle>
<AnimatedPlaceholderErrorSubTitle>
{t`Something went wrong while contacting our billing service.`}
</AnimatedPlaceholderErrorSubTitle>
</AnimatedPlaceholderErrorTextContainer>
<Button
Icon={IconRefresh}
title={t`Try again`}
variant="secondary"
onClick={onRetry}
/>
</AnimatedPlaceholderErrorContainer>
);
};
@@ -1,7 +0,0 @@
import { usePlans } from '@/settings/billing/hooks/usePlans';
describe('usePlans', () => {
it('should be a function', () => {
expect(typeof usePlans).toBe('function');
});
});
@@ -0,0 +1,96 @@
import { MockedProvider } from '@apollo/client/testing/react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { GraphQLError } from 'graphql';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { usePlans } from '@/settings/billing/hooks/usePlans';
import { ListPlansDocument } from '~/generated-metadata/graphql';
const listPlansSuccessMock = {
request: { query: ListPlansDocument },
result: { data: { listPlans: [] } },
};
const listPlansSlowSuccessMock = {
...listPlansSuccessMock,
delay: 50,
};
const listPlansErrorMock = {
request: { query: ListPlansDocument },
result: { errors: [new GraphQLError('Internal server error')] },
};
const createWrapper =
(mocks: readonly unknown[]) =>
({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks as never}>{children}</MockedProvider>
);
describe('usePlans', () => {
it('should expose the plans once the query resolves', async () => {
const { result } = renderHook(() => usePlans(), {
wrapper: createWrapper([listPlansSuccessMock]),
});
await waitFor(() => expect(result.current.isPlansLoaded).toBe(true));
expect(result.current.error).toBeUndefined();
});
it('should surface the error and leave the plans unloaded when the query fails', async () => {
const { result } = renderHook(() => usePlans(), {
wrapper: createWrapper([listPlansErrorMock]),
});
await waitFor(() => expect(result.current.error).toBeDefined());
expect(result.current.isPlansLoaded).toBe(false);
});
it('should report loading and clear the error while a refetch is in flight', async () => {
const renders: { loading: boolean; hasError: boolean }[] = [];
const { result } = renderHook(
() => {
const plans = usePlans();
renders.push({
loading: plans.loading,
hasError: isDefined(plans.error),
});
return plans;
},
{
wrapper: createWrapper([listPlansErrorMock, listPlansSlowSuccessMock]),
},
);
await waitFor(() => expect(result.current.error).toBeDefined());
renders.length = 0;
let refetching: Promise<unknown> | undefined;
act(() => {
refetching = result.current.refetch();
});
await act(async () => {
await refetching;
});
expect(renders).toContainEqual({ loading: true, hasError: false });
await waitFor(() => expect(result.current.isPlansLoaded).toBe(true));
expect(result.current.loading).toBe(false);
});
it('should not run the query when skipped', () => {
const { result } = renderHook(() => usePlans({ skip: true }), {
wrapper: createWrapper([]),
});
expect(result.current.isPlansLoaded).toBe(false);
expect(result.current.loading).toBe(false);
});
});
@@ -7,7 +7,7 @@ type UsePlansOptions = {
};
export const usePlans = (options?: UsePlansOptions) => {
const { data, loading, error } = useQuery(ListPlansDocument, {
const { data, loading, error, refetch } = useQuery(ListPlansDocument, {
skip: options?.skip,
});
@@ -18,5 +18,5 @@ export const usePlans = (options?: UsePlansOptions) => {
return data.listPlans;
};
return { loading, error, isPlansLoaded, listPlans };
return { loading, error, isPlansLoaded, listPlans, refetch };
};