From 0bd05050f27f113314c783f03951e0ddde57b03a Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Sun, 4 Oct 2026 11:10:03 +0530 Subject: [PATCH] fix(offline): save packs after the page is open --- public/sw.js | 36 +-------------- src/lib/offline/caches.ts | 2 + src/lib/offline/precache.test.ts | 24 +++++++++- src/lib/offline/precache.ts | 17 +++++++ src/lib/offline/saved.ts | 2 +- src/lib/offline/warm.ts | 77 ++++++++++++++++++++++++++++++++ src/main.tsx | 37 ++++----------- 7 files changed, 129 insertions(+), 66 deletions(-) create mode 100644 src/lib/offline/caches.ts create mode 100644 src/lib/offline/warm.ts diff --git a/public/sw.js b/public/sw.js index 4cc2397..c09d147 100644 --- a/public/sw.js +++ b/public/sw.js @@ -2,7 +2,7 @@ const SHELL = "dms-shell-2026-10-04h" const SNAPSHOT = "dms-snapshot-2026-09-30" self.addEventListener("install", (event) => { - event.waitUntil(precacheApp().then(() => self.skipWaiting())) + event.waitUntil(self.skipWaiting()) }) self.addEventListener("activate", (event) => { @@ -36,40 +36,6 @@ self.addEventListener("fetch", (event) => { event.respondWith(networkThenCache(SHELL, request)) }) -async function precacheApp() { - const list = await precacheList() - if (!list) return - await addAll(SNAPSHOT, list.snapshot) - await addAll(SHELL, list.shell) -} - -async function precacheList() { - const response = await fetch("/precache.json") - if (!response.ok) return null - const list = await response.json() - return { - shell: Array.isArray(list.shell) ? list.shell : [], - snapshot: Array.isArray(list.snapshot) ? list.snapshot : [], - } -} - -async function addAll(name, urls) { - const cache = await caches.open(name) - for (let index = 0; index < urls.length; index += 24) { - const batch = urls.slice(index, index + 24) - await Promise.all( - batch.map(async (url) => { - try { - const response = await fetch(url) - if (response.ok) await cache.put(url, response) - } catch { - // One missing file does not block the rest of the shell. - } - }), - ) - } -} - async function navigate(request) { const cache = await caches.open(SHELL) try { diff --git a/src/lib/offline/caches.ts b/src/lib/offline/caches.ts new file mode 100644 index 0000000..74b6092 --- /dev/null +++ b/src/lib/offline/caches.ts @@ -0,0 +1,2 @@ +export const SHELL_CACHE = "dms-shell-2026-10-04h" +export const SNAPSHOT_CACHE = "dms-snapshot-2026-09-30" diff --git a/src/lib/offline/precache.test.ts b/src/lib/offline/precache.test.ts index 2949513..1eeb826 100644 --- a/src/lib/offline/precache.test.ts +++ b/src/lib/offline/precache.test.ts @@ -1,5 +1,7 @@ +import { readFileSync } from "node:fs" import { describe, expect, it } from "vitest" -import { classifyPrecache } from "./precache" +import { SHELL_CACHE, SNAPSHOT_CACHE } from "./caches" +import { backgroundOrder, classifyPrecache } from "./precache" describe("offline precache list", () => { it("keeps the app shell separate from station briefs", () => { @@ -17,4 +19,24 @@ describe("offline precache list", () => { "/snapshot/en/routes/least-distance/RI/KG.json", ]) }) + + it("saves the app, then journeys, then street tiles", () => { + expect( + backgroundOrder({ + shell: ["/", "/sw.js", "/assets/app.js", "/map-tiles/9/1/1.png", "/attractions/metro-people.svg"], + snapshot: ["/snapshot/en/station_brief/RI.json", "/snapshot/en/journeys/RI.json"], + }), + ).toEqual([ + ["/", "/assets/app.js", "/attractions/metro-people.svg"], + ["/snapshot/en/journeys/RI.json"], + ["/snapshot/en/station_brief/RI.json"], + ["/map-tiles/9/1/1.png"], + ]) + }) + + it("uses the same cache names as the service worker", () => { + const source = readFileSync(new URL("../../../public/sw.js", import.meta.url), "utf8") + expect(source).toContain(SHELL_CACHE) + expect(source).toContain(SNAPSHOT_CACHE) + }) }) diff --git a/src/lib/offline/precache.ts b/src/lib/offline/precache.ts index 35d2b89..a93c44c 100644 --- a/src/lib/offline/precache.ts +++ b/src/lib/offline/precache.ts @@ -1,3 +1,20 @@ +export function backgroundOrder(list: { shell: string[]; snapshot: string[] }): string[][] { + const shell: string[] = [] + const tiles: string[] = [] + for (const url of list.shell) { + if (url === "/sw.js") continue + if (url.startsWith("/map-tiles/")) tiles.push(url) + else shell.push(url) + } + const journeys: string[] = [] + const briefs: string[] = [] + for (const url of list.snapshot) { + if (url.startsWith("/snapshot/en/journeys/")) journeys.push(url) + else briefs.push(url) + } + return [shell, journeys, briefs, tiles] +} + export function classifyPrecache(urls: string[]): { shell: string[]; snapshot: string[] } { const shell = ["/"] const snapshot: string[] = [] diff --git a/src/lib/offline/saved.ts b/src/lib/offline/saved.ts index bec93d3..85512d5 100644 --- a/src/lib/offline/saved.ts +++ b/src/lib/offline/saved.ts @@ -1,11 +1,11 @@ import { journeyFromSaved } from "../transit/route" import type { Journey } from "../transit/types" +import { SNAPSHOT_CACHE } from "./caches" import { journeyFromPack, type PackRide } from "./pack" type PackFile = Record const packs = new Map() -const SNAPSHOT_CACHE = "dms-snapshot-2026-09-30" export async function savedJourneys( from: string, diff --git a/src/lib/offline/warm.ts b/src/lib/offline/warm.ts new file mode 100644 index 0000000..ff1531a --- /dev/null +++ b/src/lib/offline/warm.ts @@ -0,0 +1,77 @@ +import { backgroundOrder } from "./precache" +import { SHELL_CACHE, SNAPSHOT_CACHE } from "./caches" + +const WIDTH = 2 + +let started = false + +export function warmOffline(): void { + if (started || !("caches" in window)) return + started = true + const run = () => { + void fill() + } + if ("requestIdleCallback" in window) window.requestIdleCallback(run, { timeout: 2000 }) + else globalThis.setTimeout(run, 1500) +} + +async function fill(): Promise { + if (!navigator.onLine) return + let list: { shell: string[]; snapshot: string[] } + try { + const response = await fetch("/precache.json") + if (!response.ok) return + const payload: unknown = await response.json() + if (!isList(payload)) return + list = payload + } catch { + return + } + const [shell, journeys, briefs, tiles] = backgroundOrder(list) + const groups: Array<[string, string[]]> = [ + [SHELL_CACHE, shell], + [SNAPSHOT_CACHE, journeys], + [SNAPSHOT_CACHE, briefs], + [SHELL_CACHE, tiles], + ] + for (const [name, urls] of groups) { + if (!navigator.onLine) return + await storeMissing(name, urls) + } +} + +async function storeMissing(name: string, urls: string[]): Promise { + const cache = await caches.open(name) + const saved = new Set((await cache.keys()).map((request) => new URL(request.url).pathname)) + const pending = urls.filter((url) => !saved.has(url)) + let next = 0 + async function worker(): Promise { + while (next < pending.length && navigator.onLine) { + const url = pending[next] + next += 1 + await storeOne(cache, url) + } + } + const workers = Math.min(WIDTH, pending.length) + await Promise.all(Array.from({ length: workers }, () => worker())) +} + +async function storeOne(cache: Cache, url: string): Promise { + try { + const response = await fetch(url, { priority: "low" }) + if (!response.ok) return + await cache.put(url, response) + } catch { + // The next visit continues from the files already saved. + } +} + +function isList(value: unknown): value is { shell: string[]; snapshot: string[] } { + if (!value || typeof value !== "object") return false + const list = value as { shell?: unknown; snapshot?: unknown } + return Array.isArray(list.shell) && Array.isArray(list.snapshot) && list.shell.every(isPath) && list.snapshot.every(isPath) +} + +function isPath(value: unknown): value is string { + return typeof value === "string" +} diff --git a/src/main.tsx b/src/main.tsx index c039391..ec5329e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,38 +3,17 @@ import { createRoot } from "react-dom/client" import "@fontsource/ibm-plex-sans/400.css" import "@fontsource/ibm-plex-sans/600.css" import { App } from "./App" +import { warmOffline } from "./lib/offline/warm" import "./index.css" const root = document.getElementById("root") if (!root) throw new Error("Missing root element") -void openWhenReady().then(() => { - createRoot(root).render( - - - , - ) -}) +createRoot(root).render( + + + , +) -async function openWhenReady() { - if (!("serviceWorker" in navigator)) return - let registration: ServiceWorkerRegistration - try { - registration = await navigator.serviceWorker.register("/sw.js") - } catch { - return - } - if (navigator.serviceWorker.controller) return - const worker = registration.installing || registration.waiting - if (!worker) return - await new Promise((resolve) => { - const finish = () => resolve() - const timer = window.setTimeout(finish, 120000) - worker.addEventListener("statechange", () => { - if (worker.state === "activated" || worker.state === "redundant") { - window.clearTimeout(timer) - finish() - } - }) - }) -} +if ("serviceWorker" in navigator) void navigator.serviceWorker.register("/sw.js") +warmOffline()