feat(pwa): add service worker, manifest, and subpath-aware build
The precache list is generated from Bun.build's outputs, so hashed asset names cannot drift. A waiting worker prompts to reload rather than pinning the user to a stale build. Two fixes beyond the brief: naming.entry changed from "[dir]/[name].[ext]" to "[name].[ext]" because [dir] resolved to "src", putting index.html at dist/src/index.html instead of the dist root; and external: ["*.webmanifest", "*.svg"] was added because Bun's HTML bundler otherwise tries to resolve <link> hrefs as module imports, which fails outright for the not-yet-generated manifest.webmanifest and would hash icon.svg, breaking the fixed paths the manifest and service worker depend on. happy-dom is registered per-file in src/ui/registerSw.test.ts only, not as a global test preload, so src/domain/smoke.test.ts still guards the domain layer's DOM-free invariant. PNG icons (192px/512px) are not included — binary image generation isn't available here; a human needs to add them for full Chrome installability. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
31f030fdea
commit
c069ed3a94
10 files changed
+264
-5
No files matched your search
@@ -7,6 +7,7 @@ import { PostMonthRoute } from "./routes/PostMonthRoute.tsx";
|
||||
import { PostYearRoute } from "./routes/PostYearRoute.tsx";
|
||||
import { SummaryRoute } from "./routes/SummaryRoute.tsx";
|
||||
import { SettingsRoute } from "./routes/SettingsRoute.tsx";
|
||||
import { UpdatePrompt } from "./components/UpdatePrompt.tsx";
|
||||
|
||||
export function AppRoutes() {
|
||||
const year = currentMonth.slice(0, 4);
|
||||
@@ -37,6 +38,7 @@ export function AppRoutes() {
|
||||
<Route path="*" element={<Navigate to={`/month/${currentMonth}`} replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<UpdatePrompt />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { applyUpdate, registerServiceWorker } from "../registerSw.ts";
|
||||
|
||||
export function UpdatePrompt() {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
registerServiceWorker(() => setReady(true));
|
||||
}, []);
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 flex items-center gap-3 rounded border bg-background p-3 text-sm shadow-lg">
|
||||
<span>A new version is ready.</span>
|
||||
<Button size="sm" onClick={applyUpdate}>
|
||||
Reload
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setReady(false)}>
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
GlobalRegistrator.register();
|
||||
|
||||
import { test, expect } from "bun:test";
|
||||
import { registerServiceWorker } from "./registerSw.ts";
|
||||
|
||||
test("registration is a no-op when the browser has no service worker support", () => {
|
||||
// happy-dom's navigator has no serviceWorker, which is the case under test.
|
||||
// (happy-dom exposes `navigator` as a getter-only global, so there is
|
||||
// nothing to save/restore here — reassigning it would throw.)
|
||||
expect(() => registerServiceWorker(() => {})).not.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BASE_PATH, withBase } from "./basePath.ts";
|
||||
|
||||
let waiting: ServiceWorker | null = null;
|
||||
|
||||
/**
|
||||
* Registers the worker and calls `onUpdateReady` when a new version is waiting.
|
||||
* Without an explicit update path, a stale worker can pin the user to an old
|
||||
* build indefinitely.
|
||||
*/
|
||||
export function registerServiceWorker(onUpdateReady: () => void): void {
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
|
||||
|
||||
void navigator.serviceWorker
|
||||
.register(withBase("sw.js"), { scope: BASE_PATH })
|
||||
.then((registration) => {
|
||||
if (registration.waiting) {
|
||||
waiting = registration.waiting;
|
||||
onUpdateReady();
|
||||
}
|
||||
registration.addEventListener("updatefound", () => {
|
||||
const installing = registration.installing;
|
||||
installing?.addEventListener("statechange", () => {
|
||||
if (installing.state === "installed" && navigator.serviceWorker.controller) {
|
||||
waiting = installing;
|
||||
onUpdateReady();
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// An unavailable worker must never break the app; it just means no offline.
|
||||
});
|
||||
}
|
||||
|
||||
export function applyUpdate(): void {
|
||||
waiting?.postMessage({ type: "SKIP_WAITING" });
|
||||
waiting = null;
|
||||
window.location.reload();
|
||||
}
|
||||
Reference in new issue
Block a user