fix: navigation dying after an app-pool restart

Two independent ways a restart leaves the SPA unable to navigate, both of
which look identical to a user - a click that does nothing.

1. The router awaits loadEnabledPlugins() to gate plugin routes. An app-pool
   restart leaves that request hanging (IIS queues it while the worker starts)
   and axios sets no timeout, so the navigation never resolves. Worse, the
   promise is cached, so every later navigation awaited the same dead request
   and stayed frozen long after the backend recovered. Bound the wait and fail
   open on expiry, and drop the cached promise when an attempt times out or
   fails so the next navigation retries. The setup-state probe in the guard
   gets the same bound (it already fails open, defaulting to "complete").

2. A deploy replaces the content-hashed chunk files, so a tab open across it
   asks for chunks that no longer exist and the dynamic import rejects with
   nothing handling it. Reload once on a chunk-load error, via router.onError
   and Vite's preloadError, guarded by a sessionStorage flag against a reload
   loop and cleared on the next successful navigation.

Also stop index.html being cached: it names the hashed chunks, so a stale copy
points at files the deploy already deleted. It now revalidates while the
hashed assets under assets/ cache for a year.
This commit is contained in:
cproudlock
2026-07-31 10:10:51 -04:00
parent cbf90be7ec
commit a7fe2c8353
4 changed files with 151 additions and 4 deletions

View File

@@ -156,6 +156,42 @@ router.afterEach(async (to) => {
document.title = page && page !== 'Home' ? `${siteTitle} - ${page}` : siteTitle
})
// A deploy replaces the hashed chunk files, so a tab that was open across it
// still asks for chunks that no longer exist. The dynamic import rejects, the
// navigation aborts, and to the user the site simply stops navigating. Reload
// once to pick up the new index.html; the sessionStorage flag stops a reload
// loop if the chunk is missing for any other reason.
const CHUNK_RELOAD_KEY = 'shopdb-chunk-reloaded'
function isChunkLoadError(error) {
const text = `${error?.message || ''} ${error?.name || ''}`
return /dynamically imported module|Importing a module script failed|ChunkLoadError|Loading chunk/i.test(text)
}
function reloadOnceForStaleChunk(error) {
if (!isChunkLoadError(error)) return false
if (sessionStorage.getItem(CHUNK_RELOAD_KEY)) return false
sessionStorage.setItem(CHUNK_RELOAD_KEY, '1')
window.location.reload()
return true
}
router.onError(error => {
reloadOnceForStaleChunk(error)
})
// Vite raises this for a failed module preload, which does not always surface
// as a router error.
window.addEventListener('vite:preloadError', event => {
if (reloadOnceForStaleChunk(event?.payload || event)) event.preventDefault?.()
})
// A navigation that completes proves the current chunks are good, so clear the
// flag - otherwise the one allowed reload is spent for the rest of the session.
router.afterEach(() => {
sessionStorage.removeItem(CHUNK_RELOAD_KEY)
})
// Navigation guard
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
@@ -197,7 +233,12 @@ router.beforeEach(async (to, from, next) => {
// First-run: steer a fresh admin into the setup wizard (once, until finished).
if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') {
if (!isSetupLoaded()) {
await refreshSetupState()
// Bounded like the plugin gate: a hung request during an app-pool restart
// must not hold the navigation open. Its own catch already fails open.
await Promise.race([
refreshSetupState(),
new Promise(resolve => setTimeout(resolve, 4000)),
])
}
if (!setupComplete.value && !setupSkipped.value) {
return next('/setup')