优化
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearNavigationRecoveryAttempt,
|
||||
isStaleRouteAssetError,
|
||||
recoverNavigationError,
|
||||
type NavigationRecoveryDependencies,
|
||||
} from './navigationRecovery'
|
||||
|
||||
function memoryStorage() {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
}
|
||||
}
|
||||
|
||||
function dependencies(overrides: Partial<NavigationRecoveryDependencies> = {}) {
|
||||
return {
|
||||
storage: memoryStorage(),
|
||||
now: () => 1_000,
|
||||
hasUnsavedWork: () => false,
|
||||
confirmDiscard: vi.fn(async () => true),
|
||||
discardUnsavedWork: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
...overrides,
|
||||
} satisfies NavigationRecoveryDependencies
|
||||
}
|
||||
|
||||
describe('navigationRecovery', () => {
|
||||
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',
|
||||
])('recognizes stale deployed route assets: %s', (message) => {
|
||||
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
|
||||
})
|
||||
|
||||
it('navigates once to the intended route after a stale lazy chunk fails', async () => {
|
||||
const deps = dependencies()
|
||||
await expect(recoverNavigationError(
|
||||
new TypeError('Failed to fetch dynamically imported module'),
|
||||
'/overseas-warehouse/inventory',
|
||||
deps,
|
||||
)).resolves.toBe(true)
|
||||
expect(deps.discardUnsavedWork).toHaveBeenCalledOnce()
|
||||
expect(deps.navigate).toHaveBeenCalledWith('/overseas-warehouse/inventory')
|
||||
expect(deps.showError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asks before discarding dirty work and stays put when the user cancels', async () => {
|
||||
const deps = dependencies({ hasUnsavedWork: () => true, confirmDiscard: vi.fn(async () => false) })
|
||||
await recoverNavigationError(new Error('Importing a module script failed'), '/system/users', deps)
|
||||
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
||||
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents reload loops and clears the recovery marker after navigation succeeds', async () => {
|
||||
const storage = memoryStorage()
|
||||
const deps = dependencies({ storage })
|
||||
const error = new Error('ChunkLoadError: Loading chunk page failed')
|
||||
await recoverNavigationError(error, '/system/users', deps)
|
||||
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)
|
||||
})
|
||||
|
||||
it('reports ordinary router failures without reloading the application', async () => {
|
||||
const deps = dependencies()
|
||||
await expect(recoverNavigationError(new Error('Unexpected guard failure'), '/customers', deps)).resolves.toBe(false)
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
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,
|
||||
]
|
||||
|
||||
export interface NavigationRecoveryDependencies {
|
||||
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
||||
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))
|
||||
}
|
||||
|
||||
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,
|
||||
dependencies: NavigationRecoveryDependencies,
|
||||
) {
|
||||
if (!isStaleRouteAssetError(error)) {
|
||||
dependencies.showError('页面切换失败,请重试')
|
||||
return false
|
||||
}
|
||||
if (recentAttempt(dependencies, target)) {
|
||||
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))
|
||||
dependencies.discardUnsavedWork()
|
||||
dependencies.navigate(target)
|
||||
return true
|
||||
}
|
||||
|
||||
export function clearNavigationRecoveryAttempt(storage: Pick<Storage, 'removeItem'>) {
|
||||
storage.removeItem(RECOVERY_STORAGE_KEY)
|
||||
}
|
||||
Reference in New Issue
Block a user