diff --git a/packages/twenty-front/src/modules/error-handler/components/AppErrorBoundary.tsx b/packages/twenty-front/src/modules/error-handler/components/AppErrorBoundary.tsx index b6f4a893a8..3c0295ebce 100644 --- a/packages/twenty-front/src/modules/error-handler/components/AppErrorBoundary.tsx +++ b/packages/twenty-front/src/modules/error-handler/components/AppErrorBoundary.tsx @@ -1,8 +1,16 @@ +import type * as SentryReact from '@sentry/react'; + import { AppErrorBoundaryEffect } from '@/error-handler/components/internal/AppErrorBoundaryEffect'; import { checkIfItsAViteStaleChunkLazyLoadingError } from '@/error-handler/utils/checkIfItsAViteStaleChunkLazyLoadingError'; +import { isStaleChunkReloadCooldownActive } from '@/error-handler/utils/isStaleChunkReloadCooldownActive'; +import { storeStaleChunkReloadTimestamp } from '@/error-handler/utils/storeStaleChunkReloadTimestamp'; import { type ErrorInfo, type ReactNode } from 'react'; import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'; import { type CustomError, isDefined } from 'twenty-shared/utils'; +import { reloadWindow } from '~/utils/reloadWindow'; + +const SENTRY_IMPORT_TIMEOUT_BEFORE_RELOAD_MS = 2_000; +const SENTRY_FLUSH_TIMEOUT_BEFORE_RELOAD_MS = 2_000; type AppErrorBoundaryProps = { children: ReactNode; @@ -16,14 +24,13 @@ const hasErrorCode = ( return 'code' in error && isDefined(error.code); }; -export const AppErrorBoundary = ({ - children, - FallbackComponent, - resetOnLocationChange = true, -}: AppErrorBoundaryProps) => { - const handleError = async (error: Error | CustomError, info: ErrorInfo) => { - try { - const { captureException } = await import('@sentry/react'); +const captureAppError = ( + sentryImportPromise: Promise, + error: Error | CustomError, + info: ErrorInfo, +) => + sentryImportPromise + .then(({ captureException, flush }) => { captureException(error, (scope) => { scope.setExtras({ info }); @@ -32,21 +39,75 @@ export const AppErrorBoundary = ({ error.name = error.message; return scope; }); - } catch (sentryError) { + + return flush(SENTRY_FLUSH_TIMEOUT_BEFORE_RELOAD_MS); + }) + .catch((sentryError) => { // oxlint-disable-next-line no-console console.error('Failed to capture exception with Sentry:', sentryError); - } + }); + +export const AppErrorBoundary = ({ + children, + FallbackComponent, + resetOnLocationChange = true, +}: AppErrorBoundaryProps) => { + const handleError = (error: Error | CustomError, info: ErrorInfo) => { + const sentryImportPromise = import('@sentry/react'); + const captureAppErrorPromise = captureAppError( + sentryImportPromise, + error, + info, + ); const isViteStaleChunkLazyLoadingError = checkIfItsAViteStaleChunkLazyLoadingError(error); - if (isViteStaleChunkLazyLoadingError) { - window.location.reload(); + const shouldAttemptStaleChunkReload = + isViteStaleChunkLazyLoadingError && !isStaleChunkReloadCooldownActive(); + + if (!shouldAttemptStaleChunkReload) { + return; } + + const isReloadTimestampStored = storeStaleChunkReloadTimestamp(); + + if (!isReloadTimestampStored) { + return; + } + + const reloadAfterSentryCapture = async () => { + const sentryImportTimeoutPromise = new Promise((resolve) => + setTimeout( + () => resolve(false), + SENTRY_IMPORT_TIMEOUT_BEFORE_RELOAD_MS, + ), + ); + + const isSentryImportSettled = await Promise.race([ + sentryImportPromise.then( + () => true, + () => true, + ), + sentryImportTimeoutPromise, + ]); + + if (isSentryImportSettled) { + const sentryFlushTimeoutPromise = new Promise((resolve) => + setTimeout(resolve, SENTRY_FLUSH_TIMEOUT_BEFORE_RELOAD_MS), + ); + + await Promise.race([captureAppErrorPromise, sentryFlushTimeoutPromise]); + } + + reloadWindow(); + }; + + void reloadAfterSentryCapture(); }; const handleReset = () => { - window.location.reload(); + reloadWindow(); }; return ( diff --git a/packages/twenty-front/src/modules/error-handler/components/__tests__/AppErrorBoundary.test.tsx b/packages/twenty-front/src/modules/error-handler/components/__tests__/AppErrorBoundary.test.tsx new file mode 100644 index 0000000000..f6f9a7cc6f --- /dev/null +++ b/packages/twenty-front/src/modules/error-handler/components/__tests__/AppErrorBoundary.test.tsx @@ -0,0 +1,133 @@ +import { render, screen, waitFor } from '@testing-library/react'; + +import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary'; +import { STALE_CHUNK_RELOAD_TIMESTAMP_KEY } from '@/error-handler/constants/StaleChunkReloadTimestampKey'; + +jest.mock('@sentry/react', () => ({ + captureException: jest.fn(), + flush: jest.fn().mockResolvedValue(true), +})); + +jest.mock('~/utils/reloadWindow', () => ({ + reloadWindow: jest.fn(), +})); + +const { captureException, flush } = jest.requireMock('@sentry/react'); +const { reloadWindow } = jest.requireMock('~/utils/reloadWindow'); + +const STALE_CHUNK_ERROR_MESSAGE = + 'Failed to fetch dynamically imported module: /assets/Page.js'; + +type ThrowerProps = { + error: Error; +}; + +const Thrower = ({ error }: ThrowerProps): never => { + throw error; +}; + +const Fallback = () =>
fallback content
; + +const renderWithBoundary = (error: Error) => + render( + + + , + ); + +describe('AppErrorBoundary', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + window.sessionStorage.clear(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it('should capture with Sentry then reload on a stale chunk error when no reload happened recently', async () => { + renderWithBoundary(new Error(STALE_CHUNK_ERROR_MESSAGE)); + + expect( + window.sessionStorage.getItem(STALE_CHUNK_RELOAD_TIMESTAMP_KEY), + ).not.toBeNull(); + + await waitFor(() => { + expect(reloadWindow).toHaveBeenCalledTimes(1); + }); + + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException.mock.invocationCallOrder[0]).toBeLessThan( + reloadWindow.mock.invocationCallOrder[0], + ); + }); + + it('should still reload after the flush timeout when the Sentry flush hangs', async () => { + jest.useFakeTimers(); + flush.mockImplementationOnce(() => new Promise(() => {})); + + renderWithBoundary(new Error(STALE_CHUNK_ERROR_MESSAGE)); + + await jest.advanceTimersByTimeAsync(0); + + expect(captureException).toHaveBeenCalledTimes(1); + expect(reloadWindow).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(2_000); + + expect(reloadWindow).toHaveBeenCalledTimes(1); + }); + + it('should not reload on a stale chunk error within the reload cooldown', async () => { + window.sessionStorage.setItem( + STALE_CHUNK_RELOAD_TIMESTAMP_KEY, + Date.now().toString(), + ); + + renderWithBoundary(new Error(STALE_CHUNK_ERROR_MESSAGE)); + + expect(screen.getByText('fallback content')).toBeInTheDocument(); + + await waitFor(() => { + expect(captureException).toHaveBeenCalledTimes(1); + }); + + expect(reloadWindow).not.toHaveBeenCalled(); + }); + + it('should not reload on a stale chunk error when the reload timestamp cannot be stored', async () => { + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('sessionStorage access denied'); + }); + + renderWithBoundary(new Error(STALE_CHUNK_ERROR_MESSAGE)); + + expect(screen.getByText('fallback content')).toBeInTheDocument(); + + await waitFor(() => { + expect(captureException).toHaveBeenCalledTimes(1); + }); + + expect(reloadWindow).not.toHaveBeenCalled(); + }); + + it('should not reload on other errors and still capture them', async () => { + renderWithBoundary(new Error('Some unrelated error')); + + expect(screen.getByText('fallback content')).toBeInTheDocument(); + + await waitFor(() => { + expect(captureException).toHaveBeenCalledTimes(1); + }); + + expect(reloadWindow).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front/src/modules/error-handler/constants/StaleChunkReloadCooldownMs.ts b/packages/twenty-front/src/modules/error-handler/constants/StaleChunkReloadCooldownMs.ts new file mode 100644 index 0000000000..d122d1152c --- /dev/null +++ b/packages/twenty-front/src/modules/error-handler/constants/StaleChunkReloadCooldownMs.ts @@ -0,0 +1 @@ +export const STALE_CHUNK_RELOAD_COOLDOWN_MS = 60_000; diff --git a/packages/twenty-front/src/modules/error-handler/constants/StaleChunkReloadTimestampKey.ts b/packages/twenty-front/src/modules/error-handler/constants/StaleChunkReloadTimestampKey.ts new file mode 100644 index 0000000000..af8a50d6b3 --- /dev/null +++ b/packages/twenty-front/src/modules/error-handler/constants/StaleChunkReloadTimestampKey.ts @@ -0,0 +1 @@ +export const STALE_CHUNK_RELOAD_TIMESTAMP_KEY = 'staleChunkReloadTimestamp'; diff --git a/packages/twenty-front/src/modules/error-handler/utils/__tests__/isStaleChunkReloadCooldownActive.test.ts b/packages/twenty-front/src/modules/error-handler/utils/__tests__/isStaleChunkReloadCooldownActive.test.ts new file mode 100644 index 0000000000..8561e4a68a --- /dev/null +++ b/packages/twenty-front/src/modules/error-handler/utils/__tests__/isStaleChunkReloadCooldownActive.test.ts @@ -0,0 +1,66 @@ +import { STALE_CHUNK_RELOAD_TIMESTAMP_KEY } from '@/error-handler/constants/StaleChunkReloadTimestampKey'; +import { isStaleChunkReloadCooldownActive } from '@/error-handler/utils/isStaleChunkReloadCooldownActive'; + +const NOW = 1_700_000_000_000; + +describe('isStaleChunkReloadCooldownActive', () => { + let dateNowSpy: jest.SpyInstance; + + beforeEach(() => { + window.sessionStorage.clear(); + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + dateNowSpy.mockRestore(); + jest.restoreAllMocks(); + }); + + it('should return false when no timestamp is stored', () => { + expect(isStaleChunkReloadCooldownActive()).toBe(false); + }); + + it('should return true when the stored timestamp is within the cooldown', () => { + window.sessionStorage.setItem( + STALE_CHUNK_RELOAD_TIMESTAMP_KEY, + (NOW - 10_000).toString(), + ); + + expect(isStaleChunkReloadCooldownActive()).toBe(true); + }); + + it('should return false when the stored timestamp is older than the cooldown', () => { + window.sessionStorage.setItem( + STALE_CHUNK_RELOAD_TIMESTAMP_KEY, + (NOW - 61_000).toString(), + ); + + expect(isStaleChunkReloadCooldownActive()).toBe(false); + }); + + it('should return false when the stored timestamp is exactly as old as the cooldown', () => { + window.sessionStorage.setItem( + STALE_CHUNK_RELOAD_TIMESTAMP_KEY, + (NOW - 60_000).toString(), + ); + + expect(isStaleChunkReloadCooldownActive()).toBe(false); + }); + + it('should return false when the stored value is not a number', () => { + window.sessionStorage.setItem( + STALE_CHUNK_RELOAD_TIMESTAMP_KEY, + 'not-a-timestamp', + ); + + expect(isStaleChunkReloadCooldownActive()).toBe(false); + }); + + it('should return true without throwing when sessionStorage access throws', () => { + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('sessionStorage access denied'); + }); + + expect(isStaleChunkReloadCooldownActive()).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/error-handler/utils/__tests__/storeStaleChunkReloadTimestamp.test.ts b/packages/twenty-front/src/modules/error-handler/utils/__tests__/storeStaleChunkReloadTimestamp.test.ts new file mode 100644 index 0000000000..dee8c467fa --- /dev/null +++ b/packages/twenty-front/src/modules/error-handler/utils/__tests__/storeStaleChunkReloadTimestamp.test.ts @@ -0,0 +1,35 @@ +import { STALE_CHUNK_RELOAD_TIMESTAMP_KEY } from '@/error-handler/constants/StaleChunkReloadTimestampKey'; +import { storeStaleChunkReloadTimestamp } from '@/error-handler/utils/storeStaleChunkReloadTimestamp'; + +const NOW = 1_700_000_000_000; + +describe('storeStaleChunkReloadTimestamp', () => { + let dateNowSpy: jest.SpyInstance; + + beforeEach(() => { + window.sessionStorage.clear(); + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + dateNowSpy.mockRestore(); + jest.restoreAllMocks(); + }); + + it('should store the current timestamp and return true', () => { + const result = storeStaleChunkReloadTimestamp(); + + expect(result).toBe(true); + expect( + window.sessionStorage.getItem(STALE_CHUNK_RELOAD_TIMESTAMP_KEY), + ).toBe(NOW.toString()); + }); + + it('should return false without throwing when sessionStorage access throws', () => { + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('sessionStorage access denied'); + }); + + expect(storeStaleChunkReloadTimestamp()).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/error-handler/utils/isStaleChunkReloadCooldownActive.ts b/packages/twenty-front/src/modules/error-handler/utils/isStaleChunkReloadCooldownActive.ts new file mode 100644 index 0000000000..5b71a2d581 --- /dev/null +++ b/packages/twenty-front/src/modules/error-handler/utils/isStaleChunkReloadCooldownActive.ts @@ -0,0 +1,24 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { STALE_CHUNK_RELOAD_COOLDOWN_MS } from '@/error-handler/constants/StaleChunkReloadCooldownMs'; +import { STALE_CHUNK_RELOAD_TIMESTAMP_KEY } from '@/error-handler/constants/StaleChunkReloadTimestampKey'; + +export const isStaleChunkReloadCooldownActive = () => { + try { + const storedTimestamp = window.sessionStorage.getItem( + STALE_CHUNK_RELOAD_TIMESTAMP_KEY, + ); + + const lastReloadTimestamp = isNonEmptyString(storedTimestamp) + ? Number(storedTimestamp) + : Number.NaN; + + return ( + Number.isFinite(lastReloadTimestamp) && + Date.now() - lastReloadTimestamp < STALE_CHUNK_RELOAD_COOLDOWN_MS + ); + } catch { + // without storage we cannot rate-limit reloads, so block them to avoid loops + return true; + } +}; diff --git a/packages/twenty-front/src/modules/error-handler/utils/storeStaleChunkReloadTimestamp.ts b/packages/twenty-front/src/modules/error-handler/utils/storeStaleChunkReloadTimestamp.ts new file mode 100644 index 0000000000..f8b1f175f3 --- /dev/null +++ b/packages/twenty-front/src/modules/error-handler/utils/storeStaleChunkReloadTimestamp.ts @@ -0,0 +1,14 @@ +import { STALE_CHUNK_RELOAD_TIMESTAMP_KEY } from '@/error-handler/constants/StaleChunkReloadTimestampKey'; + +export const storeStaleChunkReloadTimestamp = () => { + try { + window.sessionStorage.setItem( + STALE_CHUNK_RELOAD_TIMESTAMP_KEY, + Date.now().toString(), + ); + + return true; + } catch { + return false; + } +}; diff --git a/packages/twenty-front/src/utils/reloadWindow.ts b/packages/twenty-front/src/utils/reloadWindow.ts new file mode 100644 index 0000000000..cc0f815b07 --- /dev/null +++ b/packages/twenty-front/src/utils/reloadWindow.ts @@ -0,0 +1,3 @@ +export const reloadWindow = () => { + window.location.reload(); +};