140 lines
5.0 KiB
TypeScript
140 lines
5.0 KiB
TypeScript
const RECOVERY_STORAGE_KEY = 'cc-admin-route-asset-recovery:v1'
|
|
const RECOVERY_WINDOW_MS = 30_000
|
|
|
|
const staleAssetErrorPatterns = [
|
|
/failed to fetch dynamically imported module/i,
|
|
/error loading dynamically imported module/i,
|
|
/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'>
|
|
now: () => number
|
|
hasUnsavedWork: () => boolean
|
|
confirmDiscard: () => Promise<boolean>
|
|
discardUnsavedWork: () => void
|
|
navigate: (target: string) => void
|
|
showError: (message: string) => void
|
|
}
|
|
|
|
interface RecoveryAttempt {
|
|
target: string
|
|
attemptedAt: number
|
|
}
|
|
|
|
function errorMessage(error: unknown) {
|
|
if (error instanceof Error) return error.message
|
|
return typeof error === 'string' ? error : ''
|
|
}
|
|
|
|
export function isStaleRouteAssetError(error: unknown) {
|
|
const message = errorMessage(error)
|
|
return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
|
|
}
|
|
|
|
export async function recoverNavigationError(
|
|
error: unknown,
|
|
target: string,
|
|
dependencies: NavigationRecoveryDependencies,
|
|
) {
|
|
if (!isStaleRouteAssetError(error)) {
|
|
dependencies.showError('页面切换失败,请重试')
|
|
return false
|
|
}
|
|
// 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
|
|
|
|
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 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)
|
|
}
|
|
}
|