Guard stale chunk auto-reload against loops (#23362)
AppErrorBoundary auto-reloads on stale chunk errors, but if the server keeps returning stale assets (cached index.html, bad deploy) the reload lands back on the same failure and loops forever with no user interaction. Follow-up to #23359, which broadens the errors that trigger this reload. - Reload at most once per 60s per tab (sessionStorage timestamp); within the cooldown the error fallback shows instead, with its manual Reload button still unguarded. - The auto-reload waits for the Sentry capture (`captureException` + `flush`), bounded by a 2s timeout so a broken network cannot stall the reload.
This commit is contained in:
@@ -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<typeof SentryReact>,
|
||||
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<boolean>((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<void>((resolve) =>
|
||||
setTimeout(resolve, SENTRY_FLUSH_TIMEOUT_BEFORE_RELOAD_MS),
|
||||
);
|
||||
|
||||
await Promise.race([captureAppErrorPromise, sentryFlushTimeoutPromise]);
|
||||
}
|
||||
|
||||
reloadWindow();
|
||||
};
|
||||
|
||||
void reloadAfterSentryCapture();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
window.location.reload();
|
||||
reloadWindow();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+133
@@ -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 = () => <div>fallback content</div>;
|
||||
|
||||
const renderWithBoundary = (error: Error) =>
|
||||
render(
|
||||
<AppErrorBoundary
|
||||
FallbackComponent={Fallback}
|
||||
resetOnLocationChange={false}
|
||||
>
|
||||
<Thrower error={error} />
|
||||
</AppErrorBoundary>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const STALE_CHUNK_RELOAD_COOLDOWN_MS = 60_000;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const STALE_CHUNK_RELOAD_TIMESTAMP_KEY = 'staleChunkReloadTimestamp';
|
||||
+66
@@ -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);
|
||||
});
|
||||
});
|
||||
+35
@@ -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);
|
||||
});
|
||||
});
|
||||
+24
@@ -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;
|
||||
}
|
||||
};
|
||||
+14
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const reloadWindow = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
Reference in New Issue
Block a user