Skip to content

Commit 44bcb40

Browse files
Hotragnclaude
andcommitted
Fix live bug 6: pages network-first even when precached, new service worker takes over at once, next link reloads, build id in the footer; two-build e2e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Entire-Checkpoint: 01M4PX3P6EYWWVXP00NRX73EZW
1 parent 538afb5 commit 44bcb40

15 files changed

Lines changed: 238 additions & 34 deletions

File tree

‎apps/web/src/lib/push/cache-plan.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ export const CACHE_PREFIX = "flare-";
55
export const MAP_PACK = "/maps/fairlane.json";
66
/** Shell pages for SPA routes, in order of preference (adapter-static fallback). */
77
export const SHELLS = ["/200.html", "/404.html", "/"];
8-
export const NAV_TIMEOUT_MS = 4000;
98

109
/** Static files not worth phone storage: splash images (iOS reads them at install time),
1110
* the device-check kit, large or duplicate icons, and source maps. */
@@ -41,8 +40,10 @@ export function routeFor(
4140
const url = new URL(req.url);
4241
if (url.origin !== origin) return "skip";
4342
if (url.pathname.startsWith("/api/") || url.pathname === "/hub" || url.pathname.startsWith("/hub/")) return "skip";
44-
if (precached.has(url.pathname)) return "precache";
43+
// Live bug 6 (Oct 11): pages always go to the network first, even prerendered ones in the
44+
// precache; the cached copy is only for no signal. Before, "/" came from the cache forever.
4545
if (req.mode === "navigate") return "navigate";
46+
if (precached.has(url.pathname)) return "precache";
4647
if (url.pathname === MAP_PACK || url.pathname.startsWith("/_app/") || url.pathname.startsWith("/icons/"))
4748
return "static";
4849
return "skip";

‎apps/web/src/lib/push/update.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,23 @@ export async function applyUpdate(container?: ServiceWorkerContainer): Promise<v
4040
sw.addEventListener("controllerchange", () => globalThis.location?.reload(), { once: true });
4141
reg.waiting.postMessage({ type: "skip-waiting" });
4242
}
43+
44+
/**
45+
* Live bug 6: a new worker takes over at once (service-worker.ts). An open page then holds old
46+
* code, so the next in-app navigation becomes a full load, and a missing old chunk reloads.
47+
* Returns true from `shouldReload()` once a newer worker controls the page.
48+
*/
49+
export function followNewVersion(container?: ServiceWorkerContainer): { shouldReload: () => boolean } {
50+
const sw = container ?? (globalThis.navigator as Navigator | undefined)?.serviceWorker;
51+
let stale = false;
52+
const hadController = !!sw?.controller; // the first install claiming the page is not an update
53+
sw?.addEventListener("controllerchange", () => {
54+
if (hadController) stale = true;
55+
});
56+
globalThis.addEventListener?.("vite:preloadError", () => globalThis.location?.reload());
57+
const onVisible = () => {
58+
if (globalThis.document?.visibilityState === "visible") void sw?.getRegistration().then((r) => r?.update());
59+
};
60+
globalThis.document?.addEventListener("visibilitychange", onVisible);
61+
return { shouldReload: () => stale };
62+
}

‎apps/web/src/routes/(site)/+layout.svelte‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { t } from "#lib/copy/site.ts";
99
import { onDestroy } from "svelte";
1010
import { onNavigate } from "$app/navigation";
11+
import { version } from "$app/environment";
1112
let { children } = $props();
1213
1314
// Website flames use the CSS renderer (Flame.svelte's "forced css"): same look and motion,
@@ -68,10 +69,12 @@
6869
<img src="/brand/flare-mark-maple.svg" alt="" width="36" height="36" />
6970
<p>{t("site.foot.made")} {t("site.foot.osm")}</p>
7071
</div>
72+
<p class="build">{t("site.foot.build", { id: version.slice(0, 12) })}</p>
7173
</footer>
7274
</div>
7375

7476
<style>
7577
/* The nav stays put while the page under it cross-fades. */
7678
.nav { view-transition-name: site-nav; }
79+
.build { margin: 8px 0 0; font-size: 0.75rem; opacity: 0.75; }
7780
</style>

‎apps/web/src/routes/+layout.svelte‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,27 @@
33
import "#lib/zod-csp.ts";
44
import "#lib/styles/app.css";
55
import { onMount } from "svelte";
6-
import { onNavigate } from "$app/navigation";
6+
import { beforeNavigate, onNavigate } from "$app/navigation";
7+
import { followNewVersion } from "#lib/push/update.ts";
78
import { pageTransition } from "#lib/motion/transitions.ts";
89
import { installAudioStart } from "#lib/motion/sound.ts";
910
let { children } = $props();
1011
1112
// Page transitions (docs/design/motion.md 3.10): View Transitions where they exist, else an instant swap.
1213
onNavigate((navigation) => pageTransition(() => navigation.complete));
1314
15+
// Live bug 6: once a newer version controls this page, the next link is a full page load.
16+
let fresh: { shouldReload: () => boolean } | null = null;
17+
beforeNavigate((nav) => {
18+
if (!fresh?.shouldReload() || !nav.to?.url || nav.willUnload) return;
19+
nav.cancel();
20+
location.href = nav.to.url.href;
21+
});
22+
1423
onMount(() => {
1524
// Sound waits for the first tap or key (iOS starts audio only from a gesture).
1625
installAudioStart();
26+
fresh = followNewVersion();
1727
try {
1828
if (localStorage.getItem("flare.motion") === "reduce") document.documentElement.setAttribute("data-reduce-motion", "");
1929
} catch {

‎apps/web/src/service-worker.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import {
1111
CACHE_PREFIX,
1212
MAP_PACK,
13-
NAV_TIMEOUT_MS,
1413
navigationFallbacks,
1514
precacheList,
1615
routeFor,
@@ -47,7 +46,9 @@ sw.addEventListener("install", (event) => {
4746
}
4847
})(),
4948
);
50-
// No skipWaiting here: the page asks first ("New version. Reload?").
49+
// Live bug 6: take over at once. Nothing showed the old "Reload?" prompt, so a waiting worker
50+
// kept old pages on phones for days. Open pages reload on their next navigation (update.ts).
51+
void sw.skipWaiting();
5152
});
5253

5354
sw.addEventListener("activate", (event) => {
@@ -78,10 +79,9 @@ async function navigate(request: Request): Promise<Response> {
7879
const url = new URL(request.url);
7980
const keys = navigationFallbacks(url.pathname);
8081
try {
81-
const res = await Promise.race([
82-
fetch(request),
83-
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), NAV_TIMEOUT_MS)),
84-
]);
82+
// No timeout: a slow server wake (Render free tier) must not hand out an old page. The cache
83+
// answers only when the network fails, which is what no signal looks like.
84+
const res = await fetch(request, { cache: "no-store" });
8585
if (res.ok || res.status === 404 || res.status === 410) return res;
8686
return (await fromCache(keys)) ?? res;
8787
} catch {

‎apps/web/test/pwa/cache-plan.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ describe("PW5 cache plan (ADR-0010, SRS-OFF-4)", () => {
3434
expect(r("/_app/immutable/a.js")).toBe("precache");
3535
expect(r("/maps/fairlane.json")).toBe("precache");
3636
expect(r("/g/123/", "navigate")).toBe("navigate");
37+
// Live bug 6: a precached page is still network-first when it's a navigation.
38+
expect(r("/", "navigate")).toBe("navigate");
3739
expect(r("/_app/immutable/new.js")).toBe("static");
3840
expect(r("/photo.jpg")).toBe("skip");
3941
});

‎apps/web/test/pwa/e2e/offline.ts‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -384,26 +384,19 @@ try {
384384
JSON.stringify(b2b),
385385
);
386386

387-
// G. Update flow: a new worker waits until asked.
387+
// G. Update flow (live bug 6): a new worker takes over at once, with no prompt to wait for.
388388
await page.goto(`${ORIGIN}/`);
389389
swSuffix = `\n// update ${Date.now()}\n`;
390-
const waiting = await page.evaluate(async () => {
391-
const reg = await navigator.serviceWorker.ready;
392-
await reg.update();
393-
for (let i = 0; i < 100 && !reg.waiting; i++) await new Promise((r) => setTimeout(r, 50));
394-
return !!reg.waiting;
395-
});
396-
check("update: the new version waits (no skipWaiting on install)", waiting);
397390
const switched = await page.evaluate(async () => {
398391
const reg = await navigator.serviceWorker.ready;
399392
const changed = new Promise<boolean>((resolve) => {
400393
navigator.serviceWorker.addEventListener("controllerchange", () => resolve(true), { once: true });
401-
setTimeout(() => resolve(false), 5000);
394+
setTimeout(() => resolve(false), 8000);
402395
});
403-
reg.waiting?.postMessage({ type: "skip-waiting" });
396+
await reg.update();
404397
return changed;
405398
});
406-
check("update: the Reload tap activates it (controllerchange)", switched);
399+
check("update: the new version takes over at once (skipWaiting, controllerchange)", switched);
407400
const names = await page.evaluate(async () => (await caches.keys()).filter((n) => n.startsWith("flare-")));
408401
check("versioning: still one flare cache after the update", names.length === 1, names.join(", "));
409402
} catch (e) {

‎apps/web/test/pwa/e2e/sw-update.ts‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Live bug 6 (Oct 11): returning visitors kept the old site and app after a deploy. Two real
2+
// builds, N and N+1, served from one origin: load N until the service worker controls the page,
3+
// switch the server to N+1, reload once, and check N+1's HTML and JS are what runs.
4+
// Usage: node apps/web/test/pwa/e2e/sw-update.ts Writes docs/pwa/evidence/e2e-sw-update.txt.
5+
import { execFileSync } from "node:child_process";
6+
import fs from "node:fs";
7+
import http from "node:http";
8+
import os from "node:os";
9+
import path from "node:path";
10+
import { fileURLToPath } from "node:url";
11+
import { chromium } from "playwright-core";
12+
13+
const HERE = path.dirname(fileURLToPath(import.meta.url));
14+
const ROOT = path.resolve(HERE, "../../../../..");
15+
const WEB = path.join(ROOT, "apps/web");
16+
const OUT = path.join(ROOT, "docs/pwa/evidence/e2e-sw-update.txt");
17+
const PORT = Number(process.env.PWA_E2E_PORT ?? 8874);
18+
const ORIGIN = `http://127.0.0.1:${PORT}`;
19+
const lines: string[] = [];
20+
let failed = 0;
21+
const log = (s: string) => {
22+
lines.push(s);
23+
process.stdout.write(`${s}\n`);
24+
};
25+
const check = (name: string, ok: boolean, detail = "") => {
26+
if (!ok) failed++;
27+
log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? `: ${detail}` : ""}`);
28+
};
29+
30+
// Two builds that differ only in their build id (the SvelteKit version, so new chunk hashes too).
31+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "flare-swup-"));
32+
const builds: Record<string, string> = {};
33+
for (const id of ["buildN000000", "buildN100000"]) {
34+
execFileSync("npx", ["vite", "build", "--logLevel", "error"], {
35+
cwd: WEB,
36+
env: { ...process.env, FLARE_BUILD: id },
37+
stdio: "inherit",
38+
});
39+
const dir = path.join(tmp, id);
40+
fs.cpSync(path.join(WEB, "build"), dir, { recursive: true });
41+
builds[id] = dir;
42+
}
43+
let current = builds.buildN000000 as string;
44+
const TYPES: Record<string, string> = {
45+
".html": "text/html; charset=utf-8",
46+
".js": "text/javascript",
47+
".css": "text/css",
48+
".json": "application/json",
49+
".svg": "image/svg+xml",
50+
".png": "image/png",
51+
".webmanifest": "application/manifest+json",
52+
};
53+
const missing: string[] = [];
54+
const server = http.createServer((req, res) => {
55+
const url = new URL(req.url ?? "/", ORIGIN);
56+
if (url.pathname.startsWith("/api/")) {
57+
res.writeHead(200, { "content-type": "application/json" });
58+
res.end(url.pathname.endsWith("/health") ? '{"ok":true,"version":"x","hub":"down","db":"ok"}' : "{}");
59+
return;
60+
}
61+
let file = path.join(current, decodeURIComponent(url.pathname));
62+
if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html");
63+
if (!fs.existsSync(file)) {
64+
if (url.pathname.startsWith("/_app/")) missing.push(url.pathname);
65+
file = path.join(current, "200.html");
66+
}
67+
const immutable = url.pathname.startsWith("/_app/immutable/");
68+
res.writeHead(fs.existsSync(file) ? 200 : 404, {
69+
"content-type": TYPES[path.extname(file)] ?? "application/octet-stream",
70+
"cache-control": immutable ? "public, max-age=31536000, immutable" : "no-cache",
71+
});
72+
res.end(fs.existsSync(file) ? fs.readFileSync(file) : "");
73+
});
74+
await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", r));
75+
76+
const browser = await chromium.launch({ channel: "chrome", headless: true });
77+
const page = await (await browser.newContext()).newPage();
78+
const errors: string[] = [];
79+
page.on("pageerror", (e) => errors.push(e.message));
80+
const build = async () => (await page.locator(".site .build").textContent())?.trim() ?? "";
81+
const swVersion = () =>
82+
page.evaluate(
83+
() =>
84+
new Promise<string>((resolve) => {
85+
const c = navigator.serviceWorker.controller;
86+
if (!c) return resolve("");
87+
navigator.serviceWorker.addEventListener("message", (e) => resolve(String(e.data?.version ?? "")), {
88+
once: true,
89+
});
90+
c.postMessage({ type: "version" });
91+
setTimeout(() => resolve("timeout"), 3000);
92+
}),
93+
);
94+
try {
95+
await page.goto(`${ORIGIN}/`);
96+
await page.evaluate(() => navigator.serviceWorker.ready);
97+
await page.reload();
98+
await page.waitForFunction(() => !!navigator.serviceWorker.controller);
99+
check("build N is controlled by its service worker", (await swVersion()) === "buildN000000");
100+
check("build N footer", (await build()) === "Build buildN000000", await build());
101+
102+
current = builds.buildN100000 as string; // the deploy
103+
await page.reload();
104+
check("one reload after the deploy shows N+1's HTML", (await build()) === "Build buildN100000", await build());
105+
const scripts = await page.evaluate(() =>
106+
performance
107+
.getEntriesByType("resource")
108+
.map((e) => new URL(e.name).pathname)
109+
.filter((p) => p.startsWith("/_app/immutable/") && p.endsWith(".js")),
110+
);
111+
const fromN1 = scripts.filter((p) => fs.existsSync(path.join(builds.buildN100000 as string, p)));
112+
check(
113+
"the JS that ran is N+1's",
114+
scripts.length > 0 && fromN1.length === scripts.length,
115+
`${fromN1.length}/${scripts.length}`,
116+
);
117+
// The browser checks for a new worker on each navigation; it must take over with no prompt.
118+
let v = "";
119+
const t0 = Date.now();
120+
while (Date.now() - t0 < 15000 && (v = await swVersion()) !== "buildN100000") await page.waitForTimeout(250);
121+
check("the new service worker took over with no waiting step", v === "buildN100000", `${v} in ${Date.now() - t0} ms`);
122+
await page.goto(`${ORIGIN}/how/`);
123+
check("after the takeover, another page is N+1 too", (await build()) === "Build buildN100000");
124+
check(
125+
"no missing chunk and no page error",
126+
missing.length === 0 && errors.length === 0,
127+
[...missing, ...errors].join(", "),
128+
);
129+
130+
await page.context().setOffline(true);
131+
await page.reload();
132+
check("offline: the cached page still opens", (await build()) === "Build buildN100000");
133+
} finally {
134+
await browser.close();
135+
server.close();
136+
fs.rmSync(tmp, { recursive: true, force: true });
137+
}
138+
fs.mkdirSync(path.dirname(OUT), { recursive: true });
139+
fs.writeFileSync(
140+
OUT,
141+
`${lines.join("\n")}\n${failed ? `${failed} FAILED` : "all passed"} (${new Date().toISOString()})\n`,
142+
);
143+
log(failed ? `${failed} FAILED` : "all passed");
144+
process.exit(failed ? 1 : 0);

‎apps/web/vite.config.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
1+
import { execSync } from "node:child_process";
12
import adapter from "@sveltejs/adapter-static";
23
import { sveltekit } from "@sveltejs/kit/vite";
34
import { defineConfig, type Plugin } from "vite";
45

6+
// The build id shown in the site footer and used by the service worker cache (live bug 6):
7+
// the commit, so a phone can be checked against /api/v1/health.
8+
const BUILD = (() => {
9+
const env = process.env.RENDER_GIT_COMMIT ?? process.env.FLARE_BUILD;
10+
if (env) return env.slice(0, 12);
11+
try {
12+
return execSync("git rev-parse --short=12 HEAD", { stdio: ["ignore", "pipe", "ignore"] })
13+
.toString()
14+
.trim();
15+
} catch {
16+
return String(Date.now());
17+
}
18+
})();
19+
520
/**
621
* The copy deck (ICD-12) carries notes for writers: kind, where, owner, sample. Phones need
722
* only the text, the `one` form and the variants, so the bundle gets just those (deck.json
@@ -46,6 +61,7 @@ export default defineConfig({
4661
sveltekit({
4762
adapter: adapter({ fallback: "200.html" }),
4863
prerender: { handleHttpError: "warn" },
64+
version: { name: BUILD },
4965
inlineStyleThreshold: 16384, // website: inline small CSS for first paint (Lighthouse)
5066
}),
5167
],

‎design/prototype/src/lib/copy/deck.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5860,6 +5860,11 @@
58605860
"kind": "prose",
58615861
"where": "site W7"
58625862
},
5863+
"site.foot.build": {
5864+
"text": "Build {id}",
5865+
"kind": "label",
5866+
"where": "Site footer, small: which version this phone runs (live bug 6)"
5867+
},
58635868
"site.foot.osm": {
58645869
"text": "Map data © OpenStreetMap contributors.",
58655870
"kind": "prose",

0 commit comments

Comments
 (0)