This commit is contained in:
2026-09-19 17:02:11 +08:00
parent 4eb17ece4f
commit 514abf6eda
33 changed files with 525 additions and 223 deletions
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const recoverySource = readFileSync(new URL('./staticAssetRecovery.ts', import.meta.url), 'utf8')
const mainSource = readFileSync(new URL('../main.ts', import.meta.url), 'utf8')
test('refreshes once when a deployment invalidates lazy chunks or same-origin scripts and styles', () => {
assert.match(mainSource, /installStaticAssetRecovery\(router\)/)
assert.match(recoverySource, /vite:preloadError/)
assert.match(recoverySource, /router\.onError/)
assert.match(recoverySource, /HTMLScriptElement/)
assert.match(recoverySource, /HTMLLinkElement/)
assert.match(recoverySource, /window\.location\.reload\(\)/)
assert.match(recoverySource, /RECOVERY_COOLDOWN_MS/)
assert.match(recoverySource, /previous\?\.href === window\.location\.href/)
})
test('does not turn ordinary application errors or third-party asset failures into refresh loops', () => {
assert.match(recoverySource, /isStaticAssetLoadError\(error\)/)
assert.match(recoverySource, /origin !== window\.location\.origin/)
assert.match(recoverySource, /sessionStorage/)
assert.match(recoverySource, /setTimeout\(clearRecoveryRecord, RECOVERY_STABLE_MS\)/)
})
+91
View File
@@ -0,0 +1,91 @@
import type { Router } from 'vue-router'
const RECOVERY_KEY = 'client-static-asset-recovery'
const RECOVERY_COOLDOWN_MS = 60_000
const RECOVERY_STABLE_MS = 15_000
interface RecoveryRecord {
href: string
refreshedAt: number
}
function errorMessage(error: unknown) {
if (error instanceof Error) return `${error.name}: ${error.message}`
return typeof error === 'string' ? error : ''
}
export function isStaticAssetLoadError(error: unknown) {
return /ChunkLoadError|Loading chunk [\w-]+ failed|Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS/i.test(errorMessage(error))
}
function readRecoveryRecord(): RecoveryRecord | null {
try {
const raw = window.sessionStorage.getItem(RECOVERY_KEY)
if (!raw) return null
const value = JSON.parse(raw) as Partial<RecoveryRecord>
return typeof value.href === 'string' && typeof value.refreshedAt === 'number'
? { href: value.href, refreshedAt: value.refreshedAt }
: null
} catch {
return null
}
}
function writeRecoveryRecord(record: RecoveryRecord) {
try {
window.sessionStorage.setItem(RECOVERY_KEY, JSON.stringify(record))
return true
} catch {
// Without a persistent guard, reloading could loop while the deployment is still unavailable.
return false
}
}
function clearRecoveryRecord() {
try {
window.sessionStorage.removeItem(RECOVERY_KEY)
} catch {
// Storage availability must not affect application startup.
}
}
function recoverFromStaticAssetFailure(error: unknown, confirmedAssetFailure = false) {
if (!confirmedAssetFailure && !isStaticAssetLoadError(error)) return false
const now = Date.now()
const previous = readRecoveryRecord()
if (previous?.href === window.location.href && now - previous.refreshedAt < RECOVERY_COOLDOWN_MS) return false
if (!writeRecoveryRecord({ href: window.location.href, refreshedAt: now })) return false
window.location.reload()
return true
}
function failedApplicationAsset(event: Event) {
const target = event.target
if (target instanceof HTMLScriptElement) return target.src
if (target instanceof HTMLLinkElement && ['stylesheet', 'modulepreload', 'preload'].includes(target.rel)) return target.href
return ''
}
export function installStaticAssetRecovery(router: Router) {
window.addEventListener('vite:preloadError', (event) => {
const preloadEvent = event as Event & { payload?: unknown }
if (recoverFromStaticAssetFailure(preloadEvent.payload, true)) preloadEvent.preventDefault()
})
window.addEventListener('error', (event) => {
const assetUrl = failedApplicationAsset(event)
if (!assetUrl) return
try {
if (new URL(assetUrl, window.location.href).origin !== window.location.origin) return
} catch {
return
}
recoverFromStaticAssetFailure(assetUrl, true)
}, true)
router.onError((error) => {
recoverFromStaticAssetFailure(error)
})
window.setTimeout(clearRecoveryRecord, RECOVERY_STABLE_MS)
}