Improve PWA support with service worker-based offline caching (#3985)

* Implement the service worker for cached offline mode

* Improve pre-loaded font menu preview visualization

* Code review fixes

* Simplify Response construction

* Attempt to fix ERR_FAILED when reloading page on CF Pages

* Reject service worker install if any precache fetch fails
This commit is contained in:
Keavon Chambers
2026-04-02 18:14:40 -07:00
committed by GitHub
parent 87bd3d41df
commit 1a06d3ea80
5 changed files with 344 additions and 4 deletions
@@ -47,6 +47,9 @@
let virtualScrollingEntriesStart = 0;
let keydownListenerAdded = false;
let destroyed = false;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- `loadedFonts` reactivity is driven by `loadedFontsGeneration`, not the Set itself
let loadedFonts = new Set<string>();
let loadedFontsGeneration = 0;
// `watchOpen` is called only when `open` is changed from outside this component
$: watchOpen(open);
@@ -459,10 +462,28 @@
{/if}
{#if entry.font}
<link rel="stylesheet" href={entry.font} />
<link
rel="stylesheet"
href={entry.font}
onload={() => {
document.fonts.load(`16px "${entry.value}"`).then(() => {
loadedFonts.add(entry.value);
loadedFontsGeneration += 1; // Modify the dirty trigger
});
}}
/>
{/if}
<TextLabel class="entry-label" styles={entry.font ? { "font-family": entry.value } : {}}>{entry.label}</TextLabel>
<TextLabel
class="entry-label"
classes={{
"font-preview": Boolean(entry.font),
"font-loaded": loadedFontsGeneration >= 0 && loadedFonts.has(entry.value),
}}
styles={entry.font ? { "font-family": `"${entry.value}", "Source Sans Pro"` } : {}}
>
{entry.label}
</TextLabel>
{#if entry.tooltipShortcut?.shortcut.length}
<ShortcutLabel shortcut={entry.tooltipShortcut} />
@@ -548,6 +569,10 @@
margin: 0 4px;
}
.font-preview:not(.font-loaded) {
opacity: 0.5;
}
.entry-icon,
.no-icon {
margin: 0 4px;
+6
View File
@@ -2,6 +2,12 @@
import { mount, unmount } from "svelte";
import App from "/src/App.svelte";
import { registerServiceWorker } from "/src/utility-functions/service-worker";
// Register the service worker, except in dev mode and native (CEF) builds
if (!import.meta.env.DEV && import.meta.env.MODE !== "native" && "serviceWorker" in navigator) {
registerServiceWorker();
}
document.body.setAttribute("data-app-container", "");
+199
View File
@@ -0,0 +1,199 @@
// These placeholders are replaced in `vite.config.ts` at build time
const PRECACHE_MANIFEST = self.__PRECACHE_MANIFEST;
const DEFERRED_CACHE_MANIFEST = self.__DEFERRED_CACHE_MANIFEST;
const SERVICE_WORKER_CONTENT_HASH = self.__SERVICE_WORKER_CONTENT_HASH;
const STATIC_CACHE_NAME = `static-${SERVICE_WORKER_CONTENT_HASH}`;
const RUNTIME_ASSETS = "runtime-assets";
const RUNTIME_FONTS = "runtime-fonts";
const FONT_LIST_API = "https://api.graphite.art/font-list";
// Build a set of precache URLs for quick lookup during fetch
const PRECACHE_URLS = new Set(PRECACHE_MANIFEST.map((entry) => new URL(entry.url, self.location.origin).href));
// Track deferred manifest URLs and revisions for cache invalidation
const DEFERRED_ENTRIES = new Map(DEFERRED_CACHE_MANIFEST.map((entry) => [new URL(entry.url, self.location.origin).href, entry.revision]));
// ==================
// Caching strategies
// ==================
function isCacheable(response) {
// Cache normal successful responses and opaque responses (cross-origin no-cors, e.g. <link> stylesheets)
return response.ok || response.type === "opaque";
}
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (isCacheable(response)) cache.put(request, response.clone());
return response;
}
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const response = await fetch(request);
if (isCacheable(response)) cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
throw new Error(`Network request failed and no cache available for ${request.url}`);
}
}
// ================
// Lifecycle events
// ================
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
// Precache app shell assets
const cache = await caches.open(STATIC_CACHE_NAME);
await Promise.all(
PRECACHE_MANIFEST.map(async (entry) => {
const response = await fetch(entry.url);
if (!response.ok) throw new Error(`Precache fetch failed for ${entry.url}: ${response.status}`);
// Strip the `redirected` flag which causes errors when served via respondWith
const cleaned = response.redirected
? new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
})
: response;
await cache.put(entry.url, cleaned);
}),
);
// Proactively cache the font catalog API
try {
const fontResponse = await fetch(FONT_LIST_API);
if (fontResponse.ok) {
const fontCache = await caches.open(RUNTIME_FONTS);
await fontCache.put(FONT_LIST_API, fontResponse);
}
} catch {
// Font catalog prefetch is best-effort, don't block installation
}
await self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const cacheNames = await caches.keys();
// Delete old precache versions
const deletions = cacheNames.filter((name) => name.startsWith("static-") && name !== STATIC_CACHE_NAME).map((name) => caches.delete(name));
await Promise.all(deletions);
// Prune stale deferred (demo artwork) entries
const assetsCache = await caches.open(RUNTIME_ASSETS);
const assetsKeys = await assetsCache.keys();
await Promise.all(assetsKeys.filter((request) => !DEFERRED_ENTRIES.has(request.url)).map((request) => assetsCache.delete(request)));
await self.clients.claim();
})(),
);
});
// =============
// Fetch routing
// =============
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
// Pass through range requests (e.g. for large file streaming) and non-GET requests
if (request.headers.has("range") || request.method !== "GET") return;
// Pre-cached assets (JS and Wasm bundle files, favicons, index.html)
if (PRECACHE_URLS.has(url.href)) {
event.respondWith(cacheFirst(request, STATIC_CACHE_NAME));
return;
}
// Deferred-cached assets (demo artwork, third-party licenses, etc.)
if (DEFERRED_ENTRIES.has(url.href)) {
event.respondWith(cacheFirst(request, RUNTIME_ASSETS));
return;
}
// Font catalog API: network-first to keep it fresh
if (url.href.startsWith(FONT_LIST_API)) {
event.respondWith(networkFirst(request, RUNTIME_FONTS));
return;
}
// Google Fonts CSS (font preview stylesheets): cache-first since responses are stable for a given query
if (url.hostname === "fonts.googleapis.com") {
event.respondWith(cacheFirst(request, RUNTIME_FONTS));
return;
}
// Google Fonts static files: cache-first since they are immutable CDN URLs
if (url.hostname === "fonts.gstatic.com") {
event.respondWith(cacheFirst(request, RUNTIME_FONTS));
return;
}
// Navigation requests: serve cached index.html for all routes (SPA pattern)
if (request.mode === "navigate") {
event.respondWith(
(async () => {
const cache = await caches.open(STATIC_CACHE_NAME);
const cached = await cache.match("/index.html");
return cached || fetch(request);
})(),
);
return;
}
// Everything else: network-only (no respondWith, let the browser handle it)
});
// ============================
// Deferred caching via message
// ============================
self.addEventListener("message", (event) => {
if (event.data?.type !== "CACHE_DEFERRED") return;
event.waitUntil(
(async () => {
const cache = await caches.open(RUNTIME_ASSETS);
const fetchPromises = DEFERRED_CACHE_MANIFEST.map(async (entry) => {
const fullUrl = new URL(entry.url, self.location.origin).href;
// Skip if already cached with the same revision
const existing = await cache.match(fullUrl);
if (existing?.headers.get("x-sw-revision") === entry.revision) return;
try {
const response = await fetch(fullUrl);
if (response.ok) {
// Store the service worker revision in a custom header so we can check it on future installs
const headers = new Headers(response.headers);
headers.set("x-sw-revision", entry.revision);
const taggedResponse = new Response(response.body, { status: response.status, statusText: response.statusText, headers });
await cache.put(fullUrl, taggedResponse);
}
} catch {
// Best-effort: skip files that fail to fetch
}
});
await Promise.all(fetchPromises);
})(),
);
});
@@ -0,0 +1,31 @@
export async function registerServiceWorker() {
try {
const registration = await navigator.serviceWorker.register("/service-worker.js", { scope: "/" });
// When a new service worker is found, auto-reload once it activates
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
newWorker?.addEventListener("statechange", () => {
// Only reload if there was a previous controller, meaning this is an update, not first install
if (newWorker.state === "activated" && navigator.serviceWorker.controller) window.location.reload();
});
});
const activeWorker = registration.active || registration.waiting || registration.installing;
if (!activeWorker) return;
const scheduleDeferredCaching = () => {
if (activeWorker.state !== "activated") return;
const sendMessage = () => registration.active?.postMessage({ type: "CACHE_DEFERRED" });
if ("requestIdleCallback" in window) window.requestIdleCallback(sendMessage);
else setTimeout(sendMessage, 5000); // Fallback to a delay for Safari which doesn't support `requestIdleCallback`
};
// Once the service worker is active, trigger deferred caching during idle time
if (activeWorker.state === "activated") scheduleDeferredCaching();
else activeWorker.addEventListener("statechange", scheduleDeferredCaching);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Service worker registration failed:", err);
}
}