fix(pwa): wire up manifest link + SW registration so install prompt works

The PWA was configured end-to-end in vite.config.ts and built the
manifest + service worker correctly, but neither was ever loaded by
the browser — no <link rel="manifest"> in the HTML and no script
registering the generated registerSW.js. Chrome therefore never fired
beforeinstallprompt, no install icon appeared in the URL bar, and the
hand-rolled PwaUpdatePrompt hung on navigator.serviceWorker.ready
because no SW had been registered.

Changes:
- Render pwaInfo.webManifest.linkTag into <svelte:head> in the root
  layout so Chrome finds the hashed manifest.
- Replace the hand-rolled SW-update logic in PwaUpdatePrompt with
  useRegisterSW() from virtual:pwa-register/svelte — it registers the
  worker (immediate: true) and exposes reactive needRefresh +
  updateServiceWorker stores that match registerType: 'prompt'.
- Add triple-slash refs to vite-plugin-pwa/info and /svelte in
  app.d.ts for the virtual module types.
- Set manifest.id = startUrl in @mana/shared-pwa so Chrome doesn't
  warn and keeps the install identity stable across start_url edits.
- Keep devEnabled: false and expand the comment: the 2026-04-08
  dreams mic-button bug and the /offline navigateFallback both
  misbehave when Workbox precache doesn't run under vite dev. Test
  the install flow via `pnpm build && pnpm preview` instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 14:05:49 +02:00
parent 4192a4bd9b
commit 4b2007e97c
6 changed files with 55 additions and 55 deletions

View file

@ -4,6 +4,13 @@
* Authentication is handled entirely by Mana Core Auth (@mana/shared-auth).
* No Supabase is needed - all data comes from mana-auth APIs.
*/
// Virtual modules provided by vite-plugin-pwa (wrapped by @vite-pwa/sveltekit):
// - virtual:pwa-info → pwaInfo.webManifest.linkTag for <svelte:head>
// - virtual:pwa-register/svelte → useRegisterSW() Svelte-store hook
/// <reference types="vite-plugin-pwa/info" />
/// <reference types="vite-plugin-pwa/svelte" />
declare global {
const __BUILD_HASH__: string;
const __BUILD_TIME__: string;

View file

@ -1,64 +1,34 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { useRegisterSW } from 'virtual:pwa-register/svelte';
let showPrompt = $state(false);
let registration = $state<ServiceWorkerRegistration | null>(null);
onMount(() => {
if (!('serviceWorker' in navigator)) return;
function onNewSW(reg: ServiceWorkerRegistration) {
registration = reg;
showPrompt = true;
}
navigator.serviceWorker.ready.then((reg) => {
// Already waiting worker present
if (reg.waiting) {
onNewSW(reg);
return;
}
// Watch for new installs
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
onNewSW(reg);
}
});
});
});
// `useRegisterSW` registers the service worker and exposes two Svelte
// stores we actually care about:
// - needRefresh → a new SW is waiting (matches registerType: 'prompt')
// - updateServiceWorker(reload) → skipWaiting + reload page
// `offlineReady` is available too if we ever want a "ready for offline"
// toast; we don't surface it today to keep the UI quiet.
const { needRefresh, updateServiceWorker } = useRegisterSW({
immediate: true,
onRegisterError(error: unknown) {
console.error('[pwa] service worker registration failed', error);
},
});
let reloading = false;
function handleUpdate() {
if (!registration?.waiting || reloading) return;
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
// Reload once the new SW takes over (once only)
navigator.serviceWorker.addEventListener(
'controllerchange',
() => {
if (reloading) return;
reloading = true;
window.location.reload();
},
{ once: true }
);
if (reloading) return;
reloading = true;
void updateServiceWorker(true);
}
function handleDismiss() {
showPrompt = false;
needRefresh.set(false);
}
</script>
{#if showPrompt}
{#if $needRefresh}
<div class="update-banner" role="alert">
<div class="update-content">
<svg class="update-icon" width="18" height="18" viewBox="0 0 256 256" fill="currentColor">

View file

@ -15,9 +15,16 @@
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
import AuthRequiredModal from '$lib/components/auth/AuthRequiredModal.svelte';
import { pwaInfo } from 'virtual:pwa-info';
let { children } = $props();
// vite-plugin-pwa emits the <link rel="manifest"> tag (with the hashed
// manifest filename) via this virtual module. Without rendering it into
// <svelte:head>, Chrome can't read the manifest → no install prompt,
// no install icon in the URL bar, no A2HS on mobile.
const webManifestLink = $derived(pwaInfo?.webManifest.linkTag ?? '');
// Tracks the last user id we pushed into the data layer. Comparing
// against this lets us short-circuit identity-update churn during auth
// initialisation, which previously caused effect_update_depth_exceeded.
@ -112,6 +119,10 @@
});
</script>
<svelte:head>
{@html webManifestLink}
</svelte:head>
{@render children()}
<SyncConflictToast />
<OfflineIndicator />

View file

@ -23,14 +23,15 @@ export default defineConfig({
themeColor: '#6366f1',
registerType: 'prompt',
preset: 'full',
// Disable the service worker in dev. With devEnabled=true (the
// default) vite-plugin-pwa registers a SW that aggressively
// precaches the route chunks — and after the first dev session
// the SW keeps serving the OLD JS even when Vite HMR pushes
// new code, so source edits become invisible until the user
// manually unregisters the worker in DevTools. The 2026-04-08
// dreams mic-button bug took an extra hour to track down for
// exactly this reason. Production still gets the full SW.
// SW disabled in dev. Reasons:
// 1. Workbox precache doesn't run under `vite dev`, so the
// `navigateFallback: '/offline'` rule fires for every
// navigation → users land on the offline page even while
// online. (Observed 2026-04-14.)
// 2. The 2026-04-08 dreams mic-button bug: a stuck dev SW
// kept serving old JS chunks through HMR reloads.
// Test install prompt + offline behavior via `pnpm build &&
// pnpm preview` instead — production SW works correctly.
devEnabled: false,
shortcuts: [
{ name: 'Dashboard', short_name: 'Home', url: '/', description: 'Zum Dashboard' },

View file

@ -62,6 +62,10 @@ export function createPWAConfig(options: PWAConfigOptions): PWAConfig {
// Build manifest
const manifest: ManifestConfig = {
// Pin the app identity to the start URL. Without `id`, Chrome derives
// one from start_url and warns in DevTools; it also refuses to
// re-prompt an install if start_url ever changes.
id: startUrl,
name,
short_name: shortName,
description,

View file

@ -139,6 +139,13 @@ export interface ManifestIcon {
* Full manifest configuration
*/
export interface ManifestConfig {
/**
* Unique manifest identifier. Chrome uses this to correlate the installed
* app with its manifest across `start_url` changes. Strongly recommended
* by the spec since 2023 omitting it triggers a DevTools warning and
* can suppress the install prompt on re-installs.
*/
id: string;
name: string;
short_name: string;
description: string;