mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-04 13:12:07 +03:00
fix: recover panel navigation after stale chunk failures (#6679)
Fixes #6673 Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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<string, string>();
|
||||
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<typeof vi.fn>, 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<void> {
|
||||
await import('@/main');
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('stale chunk recovery', () => {
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
reload = vi.fn();
|
||||
useStorage(createStorage());
|
||||
installLocation(reload);
|
||||
window.X_UI_BASE_PATH = '/secret/';
|
||||
document.body.innerHTML = '<div id="message"></div><div id="app"></div>';
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user