From 3c0aa567813df0bdcebe82f9d8a0947f08b63e01 Mon Sep 17 00:00:00 2001 From: ylhu16 Date: Tue, 28 Jul 2026 15:47:14 +0800 Subject: [PATCH] fix(auth): prevent repeated stale chunk reloads Signed-off-by: ylhu16 --- web/src/app/router.tsx | 7 +- .../lib/dynamic-import-recovery.test.ts | 87 +++++++++++++++++++ web/src/shared/lib/dynamic-import-recovery.ts | 20 +++-- 3 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 web/src/shared/lib/dynamic-import-recovery.test.ts diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 2ef97727..ec9749a5 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -32,6 +32,9 @@ function createLazyRouteComponent>( } throw error }) + // Router resolution can finish before React.lazy imports the route module. Only clear the + // one-time reload guard after the chunk itself has loaded successfully. + clearDynamicImportReloadGuard() return { default: module[exportName] as ComponentType> } }) @@ -483,10 +486,6 @@ export const router = createRouter({ defaultNotFoundComponent: DefaultNotFound, }) -router.subscribe('onResolved', () => { - clearDynamicImportReloadGuard() -}) - declare module '@tanstack/react-router' { interface Register { router: typeof router diff --git a/web/src/shared/lib/dynamic-import-recovery.test.ts b/web/src/shared/lib/dynamic-import-recovery.test.ts new file mode 100644 index 00000000..8cba7774 --- /dev/null +++ b/web/src/shared/lib/dynamic-import-recovery.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearDynamicImportReloadGuard, + isDynamicImportFetchError, + recoverFromDynamicImportError, +} from './dynamic-import-recovery' + +const values = new Map() +const reload = vi.fn() +const sessionStorage = { + get length() { + return values.size + }, + clear: vi.fn(() => values.clear()), + getItem: vi.fn((key: string) => values.get(key) ?? null), + key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null), + removeItem: vi.fn((key: string) => values.delete(key)), + setItem: vi.fn((key: string, value: string) => values.set(key, value)), +} satisfies Storage + +describe('dynamic import recovery', () => { + beforeEach(() => { + values.clear() + reload.mockClear() + vi.stubGlobal('window', { + location: { reload }, + sessionStorage, + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it.each([ + 'Failed to fetch dynamically imported module: /assets/login.js', + 'error loading dynamically imported module: /assets/login.js', + 'Importing a module script failed', + 'ChunkLoadError: Loading chunk 42 failed', + ])('recognizes a stale dynamic import error: %s', (message) => { + expect(isDynamicImportFetchError(new Error(message))).toBe(true) + }) + + it('ignores unrelated errors', () => { + expect(isDynamicImportFetchError(new Error('Request failed with status 500'))).toBe(false) + }) + + it('recognizes errors whose name is ChunkLoadError', () => { + const error = new Error('Loading chunk 42 failed') + error.name = 'ChunkLoadError' + + expect(isDynamicImportFetchError(error)).toBe(true) + }) + + it('reloads only once while the recovery guard is active', () => { + const error = new Error('Failed to fetch dynamically imported module') + + expect(recoverFromDynamicImportError(error)).toBe(true) + expect(recoverFromDynamicImportError(error)).toBe(false) + expect(recoverFromDynamicImportError(error)).toBe(false) + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('allows recovery again after a dynamic import succeeds', () => { + const error = new Error('Failed to fetch dynamically imported module') + + expect(recoverFromDynamicImportError(error)).toBe(true) + clearDynamicImportReloadGuard() + expect(recoverFromDynamicImportError(error)).toBe(true) + expect(reload).toHaveBeenCalledTimes(2) + }) + + it('does not mask the original import error when session storage is unavailable', () => { + vi.stubGlobal('window', { + location: { reload }, + get sessionStorage() { + throw new DOMException('Access denied', 'SecurityError') + }, + }) + + const error = new Error('Failed to fetch dynamically imported module') + + expect(recoverFromDynamicImportError(error)).toBe(false) + expect(() => clearDynamicImportReloadGuard()).not.toThrow() + expect(reload).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/shared/lib/dynamic-import-recovery.ts b/web/src/shared/lib/dynamic-import-recovery.ts index 06d0ef22..27c04264 100644 --- a/web/src/shared/lib/dynamic-import-recovery.ts +++ b/web/src/shared/lib/dynamic-import-recovery.ts @@ -9,7 +9,8 @@ function resolveErrorMessage(error: unknown): string { export function isDynamicImportFetchError(error: unknown): boolean { const message = resolveErrorMessage(error) - return message.includes('Failed to fetch dynamically imported module') + return (error instanceof Error && error.name === 'ChunkLoadError') + || message.includes('Failed to fetch dynamically imported module') || message.includes('error loading dynamically imported module') || message.includes('Importing a module script failed') || message.includes('ChunkLoadError') @@ -20,12 +21,17 @@ export function recoverFromDynamicImportError(error: unknown): boolean { return false } - if (window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1') { - window.sessionStorage.removeItem(RELOAD_GUARD_KEY) + let sessionStorage: Storage + try { + sessionStorage = window.sessionStorage + if (sessionStorage.getItem(RELOAD_GUARD_KEY) === '1') { + return false + } + sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + } catch { return false } - window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') window.location.reload() return true } @@ -34,5 +40,9 @@ export function clearDynamicImportReloadGuard(): void { if (typeof window === 'undefined') { return } - window.sessionStorage.removeItem(RELOAD_GUARD_KEY) + try { + window.sessionStorage.removeItem(RELOAD_GUARD_KEY) + } catch { + // Session storage can be unavailable in restricted browsing contexts. + } }