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

@@ -7,11 +7,18 @@ import { pluginsApi } from '../api'
let enabledPromise = null
let enabledSet = null // resolved Set<string>, or null while pending / on failure
// The router AWAITS this, so it must never outlast a navigation. An app-pool
// restart leaves requests hanging (IIS queues them while the worker starts) and
// axios has no timeout, so an unbounded wait here froze navigation outright -
// and because the promise was cached, every later navigation awaited the same
// hung request and stayed frozen after the backend recovered.
const GATE_TIMEOUT_MS = 4000
// Kick off (or reuse) the single fetch. The endpoint is jwt-optional so this
// works on unauthenticated kiosk routes (e.g. /tv) too.
export function loadEnabledPlugins() {
if (!enabledPromise) {
enabledPromise = pluginsApi.enabled()
const fetched = pluginsApi.enabled()
.then(response => {
const names = response?.data?.data
// Only trust a real array; anything else -> fail open (null).
@@ -23,6 +30,19 @@ export function loadEnabledPlugins() {
enabledSet = null
return null
})
// Bounded wait, fail-open on expiry. A timed-out or failed attempt drops the
// cached promise so the NEXT navigation retries instead of inheriting a dead
// one; a request that lands late still populates enabledSet.
let timer
const bounded = new Promise(resolve => {
timer = setTimeout(() => resolve(null), GATE_TIMEOUT_MS)
})
enabledPromise = Promise.race([fetched, bounded]).then(result => {
clearTimeout(timer)
if (result === null) enabledPromise = null
return result
})
}
return enabledPromise
}

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const enabled = vi.fn()
vi.mock('../api', () => ({ pluginsApi: { enabled: (...args) => enabled(...args) } }))
const { loadEnabledPlugins, isPluginEnabled, resetEnabledPlugins } =
await import('./enabledPlugins')
beforeEach(() => {
resetEnabledPlugins()
enabled.mockReset()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('loadEnabledPlugins', () => {
it('resolves the enabled set and gates on it', async () => {
enabled.mockResolvedValue({ data: { data: ['machines', 'printers'] } })
await loadEnabledPlugins()
expect(isPluginEnabled('machines')).toBe(true)
expect(isPluginEnabled('usb')).toBe(false)
})
it('fails open on error', async () => {
enabled.mockRejectedValue(new Error('boom'))
await loadEnabledPlugins()
expect(isPluginEnabled('usb')).toBe(true)
})
it('does not hang the router when the request never settles', async () => {
// What an app-pool restart looks like: IIS queues the request while the
// worker starts, and axios has no timeout.
enabled.mockReturnValue(new Promise(() => {}))
const pending = loadEnabledPlugins()
await vi.advanceTimersByTimeAsync(5000)
await expect(pending).resolves.toBeNull()
expect(isPluginEnabled('usb')).toBe(true)
})
it('retries on the next navigation after a hung request', async () => {
enabled.mockReturnValueOnce(new Promise(() => {}))
const first = loadEnabledPlugins()
await vi.advanceTimersByTimeAsync(5000)
await first
// The dead promise must not be cached, or every later navigation inherits
// it and stays frozen even once the backend is back.
enabled.mockResolvedValueOnce({ data: { data: ['machines'] } })
await loadEnabledPlugins()
expect(enabled).toHaveBeenCalledTimes(2)
expect(isPluginEnabled('machines')).toBe(true)
expect(isPluginEnabled('usb')).toBe(false)
})
it('fetches once while the request is healthy', async () => {
enabled.mockResolvedValue({ data: { data: ['machines'] } })
await loadEnabledPlugins()
await loadEnabledPlugins()
expect(enabled).toHaveBeenCalledTimes(1)
})
})