diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a07831485..d6a295369 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -12,6 +12,32 @@ import { ThemeProvider } from '@/hooks/useTheme'; import { QueryProvider } from '@/api/QueryProvider'; import { router } from '@/routes'; +// A stale tab keeps this entry's old chunk URLs after a deploy. Reload once per +// panel base path and entry URL; the same bundle must not loop on a real outage. +const chunkRecoveryKey = `xui:chunk-recovery:${window.X_UI_BASE_PATH || '/'}:${import.meta.url}`; +let chunkRecoveryCommitted = false; + +window.addEventListener('vite:preloadError', (event) => { + if (chunkRecoveryCommitted) return; + chunkRecoveryCommitted = true; + let shouldReload = false; + try { + if (sessionStorage.getItem(chunkRecoveryKey) == null) { + sessionStorage.setItem(chunkRecoveryKey, '1'); + shouldReload = true; + } + } catch { + chunkRecoveryCommitted = false; + return; + } + if (!shouldReload) { + chunkRecoveryCommitted = false; + return; + } + event.preventDefault(); + location.reload(); +}); + setupHttp(); const messageContainer = document.getElementById('message'); diff --git a/frontend/src/test/chunk-recovery.test.tsx b/frontend/src/test/chunk-recovery.test.tsx new file mode 100644 index 000000000..0c8d13879 --- /dev/null +++ b/frontend/src/test/chunk-recovery.test.tsx @@ -0,0 +1,284 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('react-dom/client', () => ({ + createRoot: () => ({ + render: () => {}, + unmount: () => {}, + }), +})); + +vi.mock('react-router/dom', () => ({ + RouterProvider: () => null, +})); + +vi.mock('antd', () => ({ + message: { config: () => {} }, +})); + +vi.mock('@/api/http-init', () => ({ + setupHttp: () => {}, +})); + +vi.mock('@/i18n/react', () => ({ + readyI18n: () => Promise.resolve(), +})); + +vi.mock('@/hooks/useTheme', () => ({ + ThemeProvider: ({ children }: { children?: unknown }) => children ?? null, +})); + +vi.mock('@/api/QueryProvider', () => ({ + QueryProvider: ({ children }: { children?: unknown }) => children ?? null, +})); + +vi.mock('@/routes', () => ({ + router: { routes: [] }, +})); + +const PANEL_HREF = 'http://panel.example/secret/panel/inbounds?tab=1#row'; + +// Same absolute URL main.tsx sees as import.meta.url. Vite rewrites new URL(..., import.meta.url). +function entryModuleUrl(): string { + return import.meta.url.replace(/\/src\/test\/chunk-recovery\.test\.tsx$/, '/src/main.tsx'); +} + +function preloadEvent(): Event { + return new Event('vite:preloadError', { cancelable: true }); +} + +function createStorage(options?: { + onSet?: (key: string, value: string) => void; + throwOn?: 'getItem' | 'setItem'; +}): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key: string) { + if (options?.throwOn === 'getItem') throw new DOMException('denied'); + return map.has(key) ? (map.get(key) ?? null) : null; + }, + key(index: number) { + return Array.from(map.keys())[index] ?? null; + }, + removeItem(key: string) { + map.delete(key); + }, + setItem(key: string, value: string) { + if (options?.throwOn === 'setItem') throw new DOMException('denied'); + options?.onSet?.(key, value); + map.set(key, String(value)); + }, + }; +} + +function useStorage(storage: Storage) { + Object.defineProperty(window, 'sessionStorage', { + configurable: true, + value: storage, + }); +} + +function storageKeys(): string[] { + const keys: string[] = []; + for (let i = 0; i < sessionStorage.length; i += 1) { + const key = sessionStorage.key(i); + if (key != null) keys.push(key); + } + return keys; +} + +function installLocation(reload: ReturnType, href = PANEL_HREF) { + const url = new URL(href); + const assign = vi.fn(); + const replace = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + href, + origin: url.origin, + pathname: url.pathname, + search: url.search, + hash: url.hash, + reload, + assign, + replace, + }, + }); + return { assign, replace }; +} + +const tracked: Array<{ type: string; listener: EventListenerOrEventListenerObject }> = []; +let restoreAdd: typeof window.addEventListener | undefined; + +function startTracking() { + const current = window.addEventListener.bind(window); + restoreAdd = current; + window.addEventListener = (( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ) => { + if (listener) tracked.push({ type, listener }); + current(type, listener as EventListenerOrEventListenerObject, options); + }) as typeof window.addEventListener; +} + +function stopTracking() { + const remove = window.removeEventListener.bind(window); + for (const entry of tracked) remove(entry.type, entry.listener); + if (restoreAdd) window.addEventListener = restoreAdd; + restoreAdd = undefined; + tracked.length = 0; +} + +async function boot(): Promise { + await import('@/main'); + await Promise.resolve(); +} + +describe('stale chunk recovery', () => { + let reload: ReturnType; + + beforeEach(() => { + vi.resetModules(); + reload = vi.fn(); + useStorage(createStorage()); + installLocation(reload); + window.X_UI_BASE_PATH = '/secret/'; + document.body.innerHTML = '
'; + startTracking(); + }); + + afterEach(() => { + stopTracking(); + delete window.X_UI_BASE_PATH; + vi.clearAllMocks(); + }); + + test('reloads once for a preload failure and ignores a re-entrant one', async () => { + let nested: Event | undefined; + useStorage( + createStorage({ + onSet() { + if (nested) return; + nested = preloadEvent(); + window.dispatchEvent(nested); + }, + }), + ); + const navigation = installLocation(reload); + let duringReload = ''; + reload.mockImplementation(() => { + duringReload = storageKeys().join('\n'); + }); + + await boot(); + const event = preloadEvent(); + window.dispatchEvent(event); + + expect(entryModuleUrl()).toMatch(/^(https?:|file:)/); + expect(duringReload).toContain(entryModuleUrl()); + expect(duringReload).toContain('/secret/'); + expect(reload).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); + expect(nested?.defaultPrevented).toBe(false); + expect(window.location.href).toBe(PANEL_HREF); + expect(navigation.assign).not.toHaveBeenCalled(); + expect(navigation.replace).not.toHaveBeenCalled(); + + const repeat = preloadEvent(); + window.dispatchEvent(repeat); + expect(reload).toHaveBeenCalledTimes(1); + expect(repeat.defaultPrevented).toBe(false); + }); + + test('a second bootstrap of the same entry does not reload or hide the error', async () => { + await boot(); + window.dispatchEvent(preloadEvent()); + expect(reload).toHaveBeenCalledTimes(1); + const saved = storageKeys(); + expect(saved).toHaveLength(1); + expect(saved[0]).toContain(entryModuleUrl()); + + vi.resetModules(); + await boot(); + expect(storageKeys()).toEqual(saved); + + const again = preloadEvent(); + window.dispatchEvent(again); + expect(reload).toHaveBeenCalledTimes(1); + expect(again.defaultPrevented).toBe(false); + expect(storageKeys()).toEqual(saved); + }); + + test('another base path and a later entry bundle each recover once', async () => { + window.X_UI_BASE_PATH = '/alpha/'; + await boot(); + window.dispatchEvent(preloadEvent()); + expect(reload).toHaveBeenCalledTimes(1); + const [alphaKey] = storageKeys(); + expect(alphaKey).toContain('/alpha/'); + expect(alphaKey).toContain(entryModuleUrl()); + + vi.resetModules(); + window.X_UI_BASE_PATH = '/beta/'; + await boot(); + window.dispatchEvent(preloadEvent()); + expect(reload).toHaveBeenCalledTimes(2); + const both = storageKeys(); + expect(both).toContain(alphaKey); + const betaKey = both.find((key) => key !== alphaKey); + expect(betaKey).toContain('/beta/'); + expect(betaKey).toContain(entryModuleUrl()); + + sessionStorage.clear(); + const laterEntry = 'https://panel.example/beta/assets/index-later.js'; + const laterKey = betaKey!.replace(entryModuleUrl(), laterEntry); + expect(laterKey).not.toContain(entryModuleUrl()); + sessionStorage.setItem(laterKey, '1'); + vi.resetModules(); + await boot(); + window.dispatchEvent(preloadEvent()); + expect(reload).toHaveBeenCalledTimes(3); + const recovered = storageKeys(); + expect(recovered).toContain(laterKey); + expect(recovered).toContain(betaKey); + + const spent = preloadEvent(); + window.dispatchEvent(spent); + expect(reload).toHaveBeenCalledTimes(3); + expect(spent.defaultPrevented).toBe(false); + }); + + test.each(['getItem', 'setItem'] as const)( + 'sessionStorage.%s throwing leaves the preload error unsuppressed', + async (method) => { + useStorage(createStorage({ throwOn: method })); + await expect(boot()).resolves.toBeUndefined(); + + const event = preloadEvent(); + expect(() => window.dispatchEvent(event)).not.toThrow(); + expect(reload).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + + const again = preloadEvent(); + window.dispatchEvent(again); + expect(reload).not.toHaveBeenCalled(); + expect(again.defaultPrevented).toBe(false); + }, + ); + + test('runtime errors and navigation do not reload the document', async () => { + await boot(); + window.dispatchEvent(new ErrorEvent('error', { message: 'render failed', cancelable: true })); + window.dispatchEvent(new Event('unhandledrejection', { cancelable: true })); + window.dispatchEvent(new PopStateEvent('popstate', { state: { page: 'hosts' } })); + expect(reload).not.toHaveBeenCalled(); + expect(storageKeys()).toEqual([]); + }); +});