This commit is contained in:
2026-09-19 17:02:17 +08:00
parent 7e3cd93425
commit 3475cdfe49
7 changed files with 243 additions and 55 deletions
+108 -5
View File
@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import {
clearNavigationRecoveryAttempt,
createNavigationRecoveryHandler,
installAssetRecoveryListeners,
isStaleRouteAssetError,
recoverNavigationError,
reloadNavigationTarget,
type NavigationRecoveryDependencies,
} from './navigationRecovery'
@@ -29,11 +31,30 @@ function dependencies(overrides: Partial<NavigationRecoveryDependencies> = {}) {
}
describe('navigationRecovery', () => {
it.each(['/orders?page=3#details', '/orders?page=3#goods'])(
'forces a full reload for an async component failure on the current document: %s', (target) => {
const location = { href: 'https://admin.example.com/orders?page=3#details', reload: vi.fn(), assign: vi.fn() }
const history = { state: { position: 2 }, replaceState: vi.fn() }
reloadNavigationTarget({ location, history } as unknown as Pick<Window, 'location' | 'history'>, target)
expect(location.reload).toHaveBeenCalledOnce()
expect(location.assign).not.toHaveBeenCalled()
expect(history.replaceState).toHaveBeenCalledWith(history.state, '', `https://admin.example.com${target}`)
},
)
it('loads the intended new route through a document navigation', () => {
const location = { href: 'https://admin.example.com/dashboard', reload: vi.fn(), assign: vi.fn() }
reloadNavigationTarget({ location } as unknown as Pick<Window, 'location' | 'history'>, '/orders?page=3#details')
expect(location.assign).toHaveBeenCalledWith('https://admin.example.com/orders?page=3#details')
expect(location.reload).not.toHaveBeenCalled()
})
it.each([
'Failed to fetch dynamically imported module: /assets/page.js',
'Importing a module script failed',
'ChunkLoadError: Loading chunk 42 failed',
'Loading CSS chunk page failed',
'Unable to preload CSS for /assets/page-123.css',
])('recognizes stale deployed route assets: %s', (message) => {
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
})
@@ -58,7 +79,7 @@ describe('navigationRecovery', () => {
expect(deps.navigate).not.toHaveBeenCalled()
})
it('prevents reload loops and clears the recovery marker after navigation succeeds', async () => {
it('keeps the cooldown across page reloads and redirects until 30 seconds have elapsed', async () => {
const storage = memoryStorage()
const deps = dependencies({ storage })
const error = new Error('ChunkLoadError: Loading chunk page failed')
@@ -66,9 +87,12 @@ describe('navigationRecovery', () => {
await recoverNavigationError(error, '/system/users', deps)
expect(deps.navigate).toHaveBeenCalledOnce()
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
clearNavigationRecoveryAttempt(storage)
await recoverNavigationError(error, '/system/users', deps)
expect(deps.navigate).toHaveBeenCalledTimes(2)
const reloaded = dependencies({ storage })
await recoverNavigationError(error, '/system/redirect', reloaded)
expect(reloaded.navigate).not.toHaveBeenCalled()
reloaded.now = () => 31_000
await recoverNavigationError(error, '/system/users', reloaded)
expect(reloaded.navigate).toHaveBeenCalledOnce()
})
it('reports ordinary router failures without reloading the application', async () => {
@@ -77,4 +101,83 @@ describe('navigationRecovery', () => {
expect(deps.navigate).not.toHaveBeenCalled()
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
})
it.each(['getItem', 'setItem'] as const)('does not refresh or discard work if storage %s fails', async (method) => {
const storage = memoryStorage()
storage[method] = () => { throw new Error('Storage unavailable') }
const deps = dependencies({ storage })
await expect(recoverNavigationError('Importing a module script failed', '/orders', deps)).resolves.toBe(false)
expect(deps.navigate).not.toHaveBeenCalled()
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
expect(deps.showError).toHaveBeenCalledWith('无法记录自动刷新次数,请手动刷新页面')
})
it('preserves the target query and hash after the user confirms discarding work', async () => {
const deps = dependencies({ hasUnsavedWork: () => true })
const target = '/orders?status=NEW&page=3#details'
await recoverNavigationError('Unable to preload CSS for /assets/page.css', target, deps)
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
expect(deps.navigate).toHaveBeenCalledWith(target)
})
it('coalesces errors while confirmation is open and permits retry after cancellation', async () => {
let finishConfirmation!: (value: boolean) => void
const deps = dependencies({
hasUnsavedWork: () => true,
confirmDiscard: vi.fn(() => new Promise<boolean>((resolve) => { finishConfirmation = resolve })),
})
const recover = createNavigationRecoveryHandler(deps)
const cachedError = new Error('Importing a module script failed')
const first = recover(cachedError, '/orders')
const duplicate = recover(new Error('Unable to preload CSS for /assets/page.css'), '/orders')
await Promise.resolve()
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
finishConfirmation(false)
await Promise.all([first, duplicate])
expect(deps.navigate).not.toHaveBeenCalled()
deps.confirmDiscard = vi.fn(async () => true)
await recover(cachedError, '/orders')
expect(deps.navigate).toHaveBeenCalledOnce()
})
it('deduplicates the same error reported by Vite and the router', async () => {
const deps = dependencies()
const recover = createNavigationRecoveryHandler(deps)
const error = new Error('Importing a module script failed')
await recover(error, '/orders')
await recover(error, '/orders')
expect(deps.navigate).toHaveBeenCalledOnce()
expect(deps.showError).not.toHaveBeenCalled()
})
it('recovers Vite JS/CSS and uncaught import failures but leaves ordinary application errors alone', async () => {
const deps = dependencies()
const recover = createNavigationRecoveryHandler(deps)
const removeListeners = installAssetRecoveryListeners(window, () => '/orders?page=2', recover)
try {
const ordinary = Object.assign(new Event('vite:preloadError', { cancelable: true }), {
payload: new Error('Cannot read properties of undefined'),
})
window.dispatchEvent(ordinary)
const apiError = Object.assign(new Event('unhandledrejection', { cancelable: true }), {
reason: new Error('Request failed with status code 500'),
})
window.dispatchEvent(apiError)
await Promise.resolve()
expect(deps.navigate).not.toHaveBeenCalled()
expect(apiError.defaultPrevented).toBe(false)
const error = new Error('Unable to preload CSS for /assets/page.css')
const preload = Object.assign(new Event('vite:preloadError', { cancelable: true }), { payload: error })
window.dispatchEvent(preload)
expect(preload.defaultPrevented).toBe(false)
const rejection = Object.assign(new Event('unhandledrejection', { cancelable: true }), { reason: error })
window.dispatchEvent(rejection)
expect(rejection.defaultPrevented).toBe(true)
await recover(error, '/orders?page=2')
expect(deps.navigate).toHaveBeenCalledExactlyOnceWith('/orders?page=2')
} finally {
removeListeners()
}
})
})
+85 -20
View File
@@ -7,10 +7,11 @@ const staleAssetErrorPatterns = [
/importing a module script failed/i,
/chunkloaderror/i,
/loading (css )?chunk [\w-]+ failed/i,
/unable to preload css for/i,
]
export interface NavigationRecoveryDependencies {
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
storage: Pick<Storage, 'getItem' | 'setItem'>
now: () => number
hasUnsavedWork: () => boolean
confirmDiscard: () => Promise<boolean>
@@ -34,17 +35,6 @@ export function isStaleRouteAssetError(error: unknown) {
return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
}
function recentAttempt(dependencies: NavigationRecoveryDependencies, target: string) {
try {
const parsed = JSON.parse(dependencies.storage.getItem(RECOVERY_STORAGE_KEY) || 'null') as RecoveryAttempt | null
return Boolean(parsed
&& parsed.target === target
&& dependencies.now() - parsed.attemptedAt < RECOVERY_WINDOW_MS)
} catch {
return false
}
}
export async function recoverNavigationError(
error: unknown,
target: string,
@@ -54,21 +44,96 @@ export async function recoverNavigationError(
dependencies.showError('页面切换失败,请重试')
return false
}
if (recentAttempt(dependencies, target)) {
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
// Keep this marker across successful routing and full reloads. A redirect to
// another route must not bypass the cooldown either.
try {
const stored = dependencies.storage.getItem(RECOVERY_STORAGE_KEY)
let previous: RecoveryAttempt | null = null
try { previous = JSON.parse(stored || 'null') } catch { /* Replace malformed records. */ }
if (previous && Number.isFinite(previous.attemptedAt)
&& dependencies.now() - previous.attemptedAt < RECOVERY_WINDOW_MS) {
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
return false
}
} catch {
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
return false
}
if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
target,
attemptedAt: dependencies.now(),
} satisfies RecoveryAttempt))
try {
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
target,
attemptedAt: dependencies.now(),
} satisfies RecoveryAttempt))
} catch {
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
return false
}
dependencies.discardUnsavedWork()
dependencies.navigate(target)
return true
}
export function clearNavigationRecoveryAttempt(storage: Pick<Storage, 'removeItem'>) {
storage.removeItem(RECOVERY_STORAGE_KEY)
export function createNavigationRecoveryHandler(dependencies: NavigationRecoveryDependencies) {
let handledErrors = new WeakSet<object>()
let pending: Promise<boolean> | undefined
const guardedDependencies = {
...dependencies,
confirmDiscard: async () => {
const confirmed = await dependencies.confirmDiscard()
// Some importers cache the rejected promise/error. Cancelling must not
// prevent a later explicit retry from asking again.
if (!confirmed) handledErrors = new WeakSet<object>()
return confirmed
},
}
return (error: unknown, target: string): Promise<boolean> => {
if (typeof error === 'object' && error !== null) {
if (handledErrors.has(error)) return pending ?? Promise.resolve(true)
handledErrors.add(error)
}
if (pending) return pending
// Defer execution so simultaneous router, Vite and Vue errors share one
// confirmation/reload, even when there is no unsaved form.
pending = Promise.resolve().then(() => recoverNavigationError(error, target, guardedDependencies))
.finally(() => { pending = undefined })
return pending
}
}
export function reloadNavigationTarget(browser: Pick<Window, 'location' | 'history'>, target: string) {
const current = new URL(browser.location.href)
const destination = new URL(target, current)
if (destination.pathname === current.pathname && destination.search === current.search) {
// Assigning a URL that only changes the fragment does not reload assets.
browser.history.replaceState(browser.history.state, '', destination.href)
browser.location.reload()
} else {
browser.location.assign(destination.href)
}
}
export function installAssetRecoveryListeners(
browser: Window,
target: () => string,
recover: (error: unknown, target: string) => Promise<boolean>,
) {
const onPreloadError = (event: Event) => {
const error = (event as Event & { payload?: unknown }).payload
if (isStaleRouteAssetError(error)) void recover(error, target())
// Let Vite reject the import: swallowing it would make Vue Router treat
// an undefined module as a successful navigation, including on cancel.
}
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
if (!isStaleRouteAssetError(event.reason)) return
event.preventDefault()
void recover(event.reason, target())
}
browser.addEventListener('vite:preloadError', onPreloadError)
browser.addEventListener('unhandledrejection', onUnhandledRejection)
return () => {
browser.removeEventListener('vite:preloadError', onPreloadError)
browser.removeEventListener('unhandledrejection', onUnhandledRejection)
}
}