From 3c48e27b2ea91c097f01a51b759a987c29caeeac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:47:58 +0200 Subject: [PATCH] Fix stuck onboarding route on failed chunk preload (#23359) Fixes [Sentry 7604159654](https://sentry.io/issues/7604159654/) (v2.20.0, Mobile Safari). The onboarding router preloads 7 lazy chunks on entry; Vite's CSS preload for SyncEmails rejected and three defects compounded: - `void SomePage.preload()` discarded the promise, so it became an unhandled rejection and the user got a raw `Unable to preload CSS for /assets/...css` snackbar. - `lazyWithPreload` cached the *rejected* promise and rendered via `throw preload()`. React pings on the rejection, re-renders, the component throws the same settled rejected thenable, the ping listener de-dupes, and the route hangs on its loader forever. - `checkIfItsAViteStaleChunkLazyLoadingError` only matched Chrome's message, so `AppErrorBoundary`'s reload recovery never fired for the CSS-preload or Safari variants. `lazyWithPreload` now records the failure in state instead of rethrowing, so the thenable thrown into Suspense always fulfills, `preload()` returns void and can never reject, and the render path throws the real `Error` to the boundary, which reloads. Two things worth knowing for review: `React.lazy` is not a substitute here (its initializer has no synchronous fast path, so it suspends even when the module is already loaded, reintroducing the loader flash #22392 removed), and the failure is deliberately sticky because Vite marks the dep `seen` before attempting it, so an in-document retry loads the JS without its CSS and silently renders an unstyled page. Review in cubic --- .../app/hooks/useCreateWorkspaceAppRouter.tsx | 14 +- ...ItsAViteStaleChunkLazyLoadingError.test.ts | 28 +++ ...eckIfItsAViteStaleChunkLazyLoadingError.ts | 11 +- .../utils/__tests__/lazyWithPreload.test.tsx | 184 ++++++++++++++++++ .../src/utils/lazyWithPreload.tsx | 60 ++++-- 5 files changed, 276 insertions(+), 21 deletions(-) create mode 100644 packages/twenty-front/src/utils/__tests__/lazyWithPreload.test.tsx diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateWorkspaceAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateWorkspaceAppRouter.tsx index 2aa9c1a8a7..8e206da6c1 100644 --- a/packages/twenty-front/src/modules/app/hooks/useCreateWorkspaceAppRouter.tsx +++ b/packages/twenty-front/src/modules/app/hooks/useCreateWorkspaceAppRouter.tsx @@ -124,13 +124,13 @@ const NotFound = lazy(() => ); const preloadOnboardingPages = () => { - void WorkspaceActivation.preload(); - void CreateProfile.preload(); - void SyncEmails.preload(); - void InstallApps.preload(); - void InviteTeam.preload(); - void ChooseYourPlan.preload(); - void WorkspaceSetup.preload(); + WorkspaceActivation.preload(); + CreateProfile.preload(); + SyncEmails.preload(); + InstallApps.preload(); + InviteTeam.preload(); + ChooseYourPlan.preload(); + WorkspaceSetup.preload(); return null; }; diff --git a/packages/twenty-front/src/modules/error-handler/utils/__tests__/checkIfItsAViteStaleChunkLazyLoadingError.test.ts b/packages/twenty-front/src/modules/error-handler/utils/__tests__/checkIfItsAViteStaleChunkLazyLoadingError.test.ts index 13879abee5..5dca4087be 100644 --- a/packages/twenty-front/src/modules/error-handler/utils/__tests__/checkIfItsAViteStaleChunkLazyLoadingError.test.ts +++ b/packages/twenty-front/src/modules/error-handler/utils/__tests__/checkIfItsAViteStaleChunkLazyLoadingError.test.ts @@ -11,6 +11,34 @@ describe('checkIfItsAViteStaleChunkLazyLoadingError', () => { expect(result).toBe(true); }); + it('should return true for the Firefox dynamic import failure message', () => { + const error = new Error( + 'error loading dynamically imported module: /some/module.js', + ); + + const result = checkIfItsAViteStaleChunkLazyLoadingError(error); + + expect(result).toBe(true); + }); + + it('should return true for the Safari dynamic import failure message', () => { + const error = new Error('Importing a module script failed.'); + + const result = checkIfItsAViteStaleChunkLazyLoadingError(error); + + expect(result).toBe(true); + }); + + it('should return true when a CSS chunk fails to preload', () => { + const error = new Error( + 'Unable to preload CSS for /assets/SyncEmails-DKxn4rm-.css', + ); + + const result = checkIfItsAViteStaleChunkLazyLoadingError(error); + + expect(result).toBe(true); + }); + it('should return false when error message does not contain the Vite stale chunk error text', () => { const error = new Error('Some other error message'); diff --git a/packages/twenty-front/src/modules/error-handler/utils/checkIfItsAViteStaleChunkLazyLoadingError.ts b/packages/twenty-front/src/modules/error-handler/utils/checkIfItsAViteStaleChunkLazyLoadingError.ts index 39dd2f8aa8..f392e1360c 100644 --- a/packages/twenty-front/src/modules/error-handler/utils/checkIfItsAViteStaleChunkLazyLoadingError.ts +++ b/packages/twenty-front/src/modules/error-handler/utils/checkIfItsAViteStaleChunkLazyLoadingError.ts @@ -1,3 +1,12 @@ +const VITE_STALE_CHUNK_ERROR_MESSAGES = [ + 'Failed to fetch dynamically imported module', + 'error loading dynamically imported module', + 'Importing a module script failed', + 'Unable to preload CSS for', +]; + export const checkIfItsAViteStaleChunkLazyLoadingError = (error: Error) => { - return error.message.includes('Failed to fetch dynamically imported module'); + return VITE_STALE_CHUNK_ERROR_MESSAGES.some((staleChunkErrorMessage) => + error.message.includes(staleChunkErrorMessage), + ); }; diff --git a/packages/twenty-front/src/utils/__tests__/lazyWithPreload.test.tsx b/packages/twenty-front/src/utils/__tests__/lazyWithPreload.test.tsx new file mode 100644 index 0000000000..d03c9e9e08 --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/lazyWithPreload.test.tsx @@ -0,0 +1,184 @@ +import { render, screen } from '@testing-library/react'; +import { Suspense, type ComponentType } from 'react'; +import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'; + +import { lazyWithPreload } from '~/utils/lazyWithPreload'; + +const PRELOAD_ERROR_MESSAGE = + 'Unable to preload CSS for /assets/SyncEmails-DKxn4rm-.css'; + +const PageContent = () =>
page content
; + +type ErrorFallbackProps = FallbackProps; + +const ErrorFallback = ({ error }: ErrorFallbackProps) => ( +
{error.message}
+); + +const createDeferredLoader = () => { + let resolveModule!: (loadedModule: { default: ComponentType }) => void; + let rejectModule!: (error: Error) => void; + + const modulePromise = new Promise<{ default: ComponentType }>( + (resolve, reject) => { + resolveModule = resolve; + rejectModule = reject; + }, + ); + + return { + loader: jest.fn(() => modulePromise), + resolveModule: () => resolveModule({ default: PageContent }), + rejectModule: () => rejectModule(new Error(PRELOAD_ERROR_MESSAGE)), + }; +}; + +const flushPendingPromises = () => + new Promise((resolve) => setTimeout(resolve, 0)); + +describe('lazyWithPreload', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should not produce an unhandled rejection when the preload fails', async () => { + const onUnhandledRejection = jest.fn(); + process.on('unhandledRejection', onUnhandledRejection); + + try { + const { loader, rejectModule } = createDeferredLoader(); + const Component = lazyWithPreload(loader); + + Component.preload(); + rejectModule(); + await flushPendingPromises(); + } finally { + process.off('unhandledRejection', onUnhandledRejection); + } + + expect(onUnhandledRejection).not.toHaveBeenCalled(); + }); + + it('should not retry the loader once the preload has failed', async () => { + const { loader, rejectModule } = createDeferredLoader(); + const Component = lazyWithPreload(loader); + + Component.preload(); + rejectModule(); + await flushPendingPromises(); + Component.preload(); + + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('should treat a synchronous loader throw as a failed load instead of throwing from preload', async () => { + const loader = jest.fn(() => { + throw new Error(PRELOAD_ERROR_MESSAGE); + }); + const Component = lazyWithPreload(loader); + + expect(() => Component.preload()).not.toThrow(); + await flushPendingPromises(); + + render( + + loading}> + + + , + ); + + expect(screen.getByText(PRELOAD_ERROR_MESSAGE)).toBeInTheDocument(); + }); + + it('should call the loader once across repeated preloads', () => { + const { loader } = createDeferredLoader(); + const Component = lazyWithPreload(loader); + + Component.preload(); + Component.preload(); + + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('should render without ever showing the suspense fallback once preloaded', async () => { + const { loader, resolveModule } = createDeferredLoader(); + const Component = lazyWithPreload(loader); + const Fallback = jest.fn(() =>
loading
); + + Component.preload(); + resolveModule(); + await flushPendingPromises(); + + render( + }> + + , + ); + + expect(screen.getByText('page content')).toBeInTheDocument(); + expect(Fallback).not.toHaveBeenCalled(); + }); + + it('should show the fallback then the component when rendered before the load completes', async () => { + const { loader, resolveModule } = createDeferredLoader(); + const Component = lazyWithPreload(loader); + + render( + loading}> + + , + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + + resolveModule(); + + expect(await screen.findByText('page content')).toBeInTheDocument(); + }); + + it('should throw the load error to the error boundary when rendered after a failed preload', async () => { + const { loader, rejectModule } = createDeferredLoader(); + const Component = lazyWithPreload(loader); + + Component.preload(); + rejectModule(); + await flushPendingPromises(); + + render( + + loading}> + + + , + ); + + expect(screen.getByText(PRELOAD_ERROR_MESSAGE)).toBeInTheDocument(); + }); + + it('should leave the fallback for the error boundary when the load fails while suspended', async () => { + const { loader, rejectModule } = createDeferredLoader(); + const Component = lazyWithPreload(loader); + + render( + + loading}> + + + , + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + + rejectModule(); + + expect(await screen.findByText(PRELOAD_ERROR_MESSAGE)).toBeInTheDocument(); + expect(screen.queryByText('loading')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/twenty-front/src/utils/lazyWithPreload.tsx b/packages/twenty-front/src/utils/lazyWithPreload.tsx index 1b699b9cbe..f6ff66634d 100644 --- a/packages/twenty-front/src/utils/lazyWithPreload.tsx +++ b/packages/twenty-front/src/utils/lazyWithPreload.tsx @@ -1,31 +1,65 @@ import { type ComponentType } from 'react'; +type LoadState = + | { status: 'idle' } + | { status: 'pending'; promise: Promise } + | { status: 'loaded'; component: ComponentType } + | { status: 'failed'; error: unknown }; + type PreloadableComponent = ComponentType & { - preload: () => Promise; + preload: () => void; }; export const lazyWithPreload = ( loader: () => Promise<{ default: ComponentType }>, ): PreloadableComponent => { - let LoadedComponent: ComponentType | null = null; - let loadingPromise: Promise | null = null; + let loadState: LoadState = { status: 'idle' }; + + const startLoading = (): Promise => { + if (loadState.status === 'pending') { + return loadState.promise; + } + + if (loadState.status !== 'idle') { + return Promise.resolve(); + } + + try { + const promise = loader().then( + (loadedModule) => { + loadState = { status: 'loaded', component: loadedModule.default }; + }, + (error) => { + loadState = { status: 'failed', error }; + }, + ); + + loadState = { status: 'pending', promise }; + + return promise; + } catch (error) { + loadState = { status: 'failed', error }; + + return Promise.resolve(); + } + }; const preload = () => { - loadingPromise ??= loader().then((loadedModule) => { - LoadedComponent = loadedModule.default; - }); - - return loadingPromise; + startLoading(); }; const PreloadableComponent = () => { - const Component = LoadedComponent; - - if (Component === null) { - throw preload(); + if (loadState.status === 'failed') { + throw loadState.error; } - return ; + if (loadState.status === 'loaded') { + const Component = loadState.component; + + return ; + } + + throw startLoading(); }; return Object.assign(PreloadableComponent, { preload });