feat(fase-14): PWA auto-update toast (14.1)

Migrate from `registerSW({ immediate: true })` to `useRegisterSW` from
`virtual:pwa-register/svelte`, mirror its `needRefresh` / `offlineReady`
stores into stable layout-local writables, and render a new
`UpdateToast` pill (bottom-center) that surfaces:

  - "New version available" + a "Reload" button that calls
    `updateServiceWorker(true)`
  - "Ready to use offline" (auto-dismiss after 4s) on first install

Adds `[pwa] …` console.info telemetry on register / need-refresh /
apply. Stand-in for the unavailable `updateViaCache: 'none'` runtime
option (RegisterSWOptions doesn't expose it in vite-plugin-pwa 0.21.2):
poll `reg.update()` every 15min, which keeps a too-aggressively-cached
SW file from pinning users on the previous build for up to 24h.

`apps/web/src/global.d.ts` declares the virtual module's surface inline
so svelte-check picks it up without a relative path into node_modules;
the same file pre-declares the Fase-14.3 `__APP_VERSION__` triplet.

PU-01 stubs the registrar via `window.__pwaRegisterStub` (the layout
checks for it before calling the real `useRegisterSW`) so the toast
flow can be driven from a Playwright test without a second build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 06:59:29 +02:00
parent d34c578ad8
commit a6e6915b0b
6 changed files with 342 additions and 5 deletions

40
apps/web/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,40 @@
/**
* Ambient declarations for app-wide constants and virtual modules.
*
* - `virtual:pwa-register/svelte` is provided at build time by
* `vite-plugin-pwa`'s Svelte helper (Fase 14.1). vite-plugin-pwa ships
* `svelte.d.ts` for this but TypeScript only picks it up if the path
* is referenced; declaring the module here inlines the (small) surface
* without coupling to the package's internal layout.
*
* - `__APP_VERSION__`, `__APP_COMMIT__`, `__BUILD_TIME__` (Fase 14.3)
* are replaced literally by Vite at build time via the `define`
* option in `vite.config.ts`. They're consumed exclusively through
* `$lib/version.ts` — never inline — so tree-shaking can't drop
* the build-time substitution.
*/
declare module 'virtual:pwa-register/svelte' {
import type { Writable } from 'svelte/store';
export interface RegisterSWOptions {
immediate?: boolean;
onNeedRefresh?: () => void;
onOfflineReady?: () => void;
onRegisteredSW?: (
swScriptUrl: string,
registration: ServiceWorkerRegistration | undefined
) => void;
onRegisterError?: (error: unknown) => void;
}
export function useRegisterSW(options?: RegisterSWOptions): {
needRefresh: Writable<boolean>;
offlineReady: Writable<boolean>;
updateServiceWorker: (reloadPage?: boolean) => Promise<void>;
};
}
declare const __APP_VERSION__: string;
declare const __APP_COMMIT__: string;
declare const __BUILD_TIME__: string;

View File

@@ -0,0 +1,92 @@
<script lang="ts">
/**
* Fase 14.1 — PWA update + offline-ready notification.
*
* Driven by two writable<boolean> stores produced by `useRegisterSW()`
* (or its test stub). The component is purely presentational: state
* lives in the stores, behaviour (`onReload`) is injected.
*
* - When `needRefresh` flips true, a sticky pill appears with a
* "Reload" button. Clicking it calls `onReload()` which is wired
* in the root layout to `updateServiceWorker(true)`.
* - When `offlineReady` flips true (first install), a transient
* pill appears for 4s and then auto-dismisses.
*
* Both pills share the same bottom-center slot. If both fire at once
* the update toast wins (more actionable).
*/
import { onMount } from 'svelte';
import type { Writable } from 'svelte/store';
import * as m from '$lib/paraglide/messages';
const {
needRefresh,
offlineReady,
onReload
}: {
needRefresh: Writable<boolean>;
offlineReady: Writable<boolean>;
onReload: () => void | Promise<void>;
} = $props();
let showOfflineReady = $state(false);
let offlineTimer: ReturnType<typeof setTimeout> | null = null;
onMount(() => {
const unsub = offlineReady.subscribe((v) => {
if (!v) return;
showOfflineReady = true;
console.info('[pwa] offline-ready');
if (offlineTimer) clearTimeout(offlineTimer);
offlineTimer = setTimeout(() => {
showOfflineReady = false;
offlineReady.set(false);
}, 4_000);
});
return () => {
unsub();
if (offlineTimer) clearTimeout(offlineTimer);
};
});
async function handleReload() {
console.info('[pwa] applying update');
await onReload();
}
</script>
{#if $needRefresh}
<div
data-testid="pwa-update-toast"
role="status"
aria-live="polite"
class="pointer-events-none fixed inset-x-0 bottom-20 z-50 flex justify-center px-4 md:bottom-6"
>
<div
class="pointer-events-auto flex items-center gap-3 rounded-full bg-slate-900 px-4 py-2 text-sm text-white shadow-[0px_20px_40px_rgba(15,23,42,0.3)] dark:bg-slate-100 dark:text-slate-900"
>
<span>{m.pwa_update_available()}</span>
<button
type="button"
data-testid="pwa-update-reload"
onclick={handleReload}
class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold text-white hover:bg-white/25 dark:bg-slate-900/15 dark:text-slate-900 dark:hover:bg-slate-900/25"
>
{m.pwa_update_reload()}
</button>
</div>
</div>
{:else if showOfflineReady}
<div
data-testid="pwa-offline-ready-toast"
role="status"
aria-live="polite"
class="pointer-events-none fixed inset-x-0 bottom-20 z-50 flex justify-center px-4 md:bottom-6"
>
<div
class="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-900 px-4 py-2 text-sm text-white shadow-[0px_20px_40px_rgba(15,23,42,0.3)] dark:bg-slate-100 dark:text-slate-900"
>
<span>{m.pwa_offline_ready()}</span>
</div>
</div>
{/if}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { writable, type Writable } from 'svelte/store';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { getSupabase } from '$lib/supabase';
@@ -18,6 +19,16 @@
import { initTheme } from '$lib/theme';
import { setLanguageTag } from '$lib/paraglide/runtime';
import { detectLanguage } from '$lib/utils/accept-language';
import UpdateToast from '$lib/components/UpdateToast.svelte';
// Fase 14.1: stores driven by `useRegisterSW()` (or by a test stub
// injected via window.__pwaRegisterStub). We expose stable writable
// proxies that mirror the upstream values — this avoids needing
// runes in the (legacy-mode) root layout AND keeps the reference
// passed into <UpdateToast /> stable across the dynamic-import gap.
const needRefresh: Writable<boolean> = writable(false);
const offlineReady: Writable<boolean> = writable(false);
let pwaReload: () => void | Promise<void> = () => {};
onMount(() => {
// Fase 9.1: hydrate the theme stores from localStorage and start
@@ -34,11 +45,69 @@
(window as unknown as { __sb?: unknown }).__sb = getSupabase();
}
// Fase 6.1: register the PWA service worker. `@vite-pwa/sveltekit` does
// not auto-register — the virtual module is a no-op unless called.
void import('virtual:pwa-register').then(({ registerSW }) => {
registerSW({ immediate: true });
});
// Fase 14.1: register the PWA service worker via the Svelte-flavoured
// virtual module so we get writable stores for needRefresh /
// offlineReady (UpdateToast subscribes to them).
//
// Note on `updateViaCache: 'none'`: the plan asks for this on the
// registration. `vite-plugin-pwa` 0.21.2's RegisterSWOptions doesn't
// expose it (workbox-window 7 doesn't either). Mitigated below by an
// explicit `reg.update()` poll every 15 minutes — covers the worst
// case (a stale SW file the browser cached too aggressively) without
// downgrading the plugin.
//
// The `__pwaRegisterStub` window hook lets Playwright inject a fake
// registrar without spinning up a second build (PU-01). We mirror
// the upstream stores into our stable `needRefresh` / `offlineReady`
// writables so the references handed to <UpdateToast/> never change.
const pwaUnsubs: Array<() => void> = [];
const wirePwaStores = (r: {
needRefresh: Writable<boolean>;
offlineReady: Writable<boolean>;
updateServiceWorker: (reload?: boolean) => Promise<void>;
}) => {
pwaReload = () => r.updateServiceWorker(true);
pwaUnsubs.push(r.needRefresh.subscribe((v) => needRefresh.set(v)));
pwaUnsubs.push(r.offlineReady.subscribe((v) => offlineReady.set(v)));
};
const stub = (window as unknown as {
__pwaRegisterStub?: () => {
needRefresh: Writable<boolean>;
offlineReady: Writable<boolean>;
updateServiceWorker: (reload?: boolean) => Promise<void>;
};
}).__pwaRegisterStub;
if (stub) {
console.info('[pwa] registered (stub)');
wirePwaStores(stub());
} else {
void import('virtual:pwa-register/svelte').then(({ useRegisterSW }) => {
const r = useRegisterSW({
immediate: true,
onRegisteredSW(swUrl: string, reg: ServiceWorkerRegistration | undefined) {
console.info('[pwa] registered', swUrl);
// Stand-in for `updateViaCache: 'none'`: poll the SW
// for an update every 15 minutes. Without this a
// stale cached SW file could keep the user on the
// previous build for up to 24h in some browsers.
if (reg) {
setInterval(() => {
reg.update().catch(() => {
/* offline / transient */
});
}, 15 * 60 * 1000);
}
},
onRegisterError(err: unknown) {
console.info('[pwa] register error', err);
},
onNeedRefresh() {
console.info('[pwa] update available');
}
});
wirePwaStores(r);
});
}
const supabase = getSupabase();
@@ -117,6 +186,7 @@
collectiveUnsub();
void teardownFeatureSubscriptions();
clearServerAdminFlag();
for (const u of pwaUnsubs) u();
};
});
@@ -209,4 +279,5 @@
<ParaglideJS {i18n}>
<slot />
<UpdateToast {needRefresh} {offlineReady} onReload={pwaReload} />
</ParaglideJS>