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.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23359?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-27 16:47:58 +02:00
committed by GitHub
parent 79bf20515d
commit 3c48e27b2e
5 changed files with 276 additions and 21 deletions
@@ -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;
};
@@ -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');
@@ -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),
);
};