Fix intermittent blank screens: cache the SPA shell in the service worker
Root cause (SPA mode, fallback: index.html): the SW precached build+files but
NEVER the shell HTML, so its navigation fallback `caches.match('/')` resolved to
nothing — any failed navigation fetch (transient WAN/CF blip) returned an empty
response → blank white screen.
Fix: precache `/` on install, and on every successful navigation keep the
freshest *real 200 text/html* response as the cached shell; on a failed fetch,
serve that cached shell instead of blank. Also expanded the server-owned path
exclusions (the SW now passes through /docs, /openapi.json, /healthz, /today,
/sitemap.xml in addition to /api/ and /a/) so it never caches non-SPA responses
as the shell.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,30 @@
|
|||||||
/// <reference types="@sveltejs/kit" />
|
/// <reference types="@sveltejs/kit" />
|
||||||
// Minimal, calm service worker: precache the app shell + static assets so the
|
// Calm service worker: precache the app shell + static assets so the site is
|
||||||
// site is installable (PWA) and the shell opens fast / works offline. Live data
|
// installable (PWA), fast, and resilient to transient network blips. Live data
|
||||||
// (the API and server-rendered /a/ pages) is always fetched fresh, never staled.
|
// (the API and server-rendered pages) is always fetched fresh, never cached.
|
||||||
import { build, files, version } from '$service-worker';
|
import { build, files, version } from '$service-worker';
|
||||||
|
|
||||||
const CACHE = `upbeat-${version}`;
|
const CACHE = `upbeat-${version}`;
|
||||||
const SHELL = [...build, ...files];
|
const SHELL = [...build, ...files];
|
||||||
|
|
||||||
|
// Paths the FastAPI server owns — the SW must NOT intercept or cache these
|
||||||
|
// (matches Caddy's @api matcher: docs, server-rendered article/brief pages,
|
||||||
|
// health, sitemap). Everything else is the static SvelteKit SPA.
|
||||||
|
function isServerPath(p) {
|
||||||
|
if (p.startsWith('/api/') || p.startsWith('/a/') || p.startsWith('/docs')) return true;
|
||||||
|
return p === '/openapi.json' || p === '/healthz' || p === '/today' || p === '/sitemap.xml';
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
event.waitUntil(
|
||||||
|
caches.open(CACHE).then(async (c) => {
|
||||||
|
await c.addAll(SHELL);
|
||||||
|
// Cache the SPA shell so a failed navigation has a real page to fall back
|
||||||
|
// to (best-effort; it's also refreshed on every successful navigation).
|
||||||
|
try { await c.add('/'); } catch { /* refreshed on first online navigation */ }
|
||||||
|
await self.skipWaiting();
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
@@ -25,16 +41,25 @@ self.addEventListener('fetch', (event) => {
|
|||||||
if (request.method !== 'GET') return;
|
if (request.method !== 'GET') return;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.origin !== location.origin) return;
|
if (url.origin !== location.origin) return;
|
||||||
|
if (isServerPath(url.pathname)) return; // let the network/server handle these
|
||||||
|
|
||||||
// Always-fresh, never cached: the API and the server-rendered article pages.
|
// Navigations: network-first. On success, keep the freshest *real HTML shell*
|
||||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/a/')) return;
|
// as the offline fallback; on failure, serve that cached shell (never blank).
|
||||||
|
|
||||||
// Navigations: serve the cached app shell when offline (SPA fallback).
|
|
||||||
if (request.mode === 'navigate') {
|
if (request.mode === 'navigate') {
|
||||||
event.respondWith(fetch(request).catch(() => caches.match('/') || caches.match('/index.html')));
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((res) => {
|
||||||
|
if (res && res.ok && (res.headers.get('content-type') || '').includes('text/html')) {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put('/', copy)).catch(() => {});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match('/'))
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static assets (JS/CSS/icons/fonts): cache-first, fall back to network.
|
// Static assets (hashed JS/CSS, fonts, icons, word lists): cache-first.
|
||||||
event.respondWith(caches.match(request).then((cached) => cached || fetch(request)));
|
event.respondWith(caches.match(request).then((cached) => cached || fetch(request)));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user