diff --git a/frontend/src/composables/enabledPlugins.js b/frontend/src/composables/enabledPlugins.js index b95e2a3..40026fc 100644 --- a/frontend/src/composables/enabledPlugins.js +++ b/frontend/src/composables/enabledPlugins.js @@ -7,11 +7,18 @@ import { pluginsApi } from '../api' let enabledPromise = null let enabledSet = null // resolved Set, 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 } diff --git a/frontend/src/composables/enabledPlugins.spec.js b/frontend/src/composables/enabledPlugins.spec.js new file mode 100644 index 0000000..074be66 --- /dev/null +++ b/frontend/src/composables/enabledPlugins.spec.js @@ -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) + }) +}) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 2040ca3..03c7cbb 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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') diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 760a250..08e3a1b 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -244,11 +244,23 @@ def register_frontend_routes(app: Flask): # (the probe was a path-traversal risk surface). if path: try: - return send_from_directory(frontend_dist, path) + response = send_from_directory(frontend_dist, path) + # Asset filenames carry a content hash, so a given URL never + # changes - cache them hard. Everything else stays revalidated. + if path.startswith('assets/'): + response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' + else: + response.headers['Cache-Control'] = 'no-cache' + return response except Exception: pass - return send_from_directory(frontend_dist, 'index.html') + # index.html names the hashed chunks, so a stale copy points at files a + # deploy has already deleted and the SPA stops navigating. Always + # revalidate it. + response = send_from_directory(frontend_dist, 'index.html') + response.headers['Cache-Control'] = 'no-cache' + return response def configure_logging(app: Flask):