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 });