fix: PWA install prompt — capture beforeinstallprompt at module load time

The browser fires beforeinstallprompt very early (~1s after page load),
before Svelte's $effects run. Moving the event listener registration to
module level ensures we never miss the event regardless of when init()
is called from the root layout.

init() now only handles dismiss state (localStorage) and standalone
detection (DOM) — both safe to defer until after component mount.

Platforms:
- Chrome / Chromium / Android: native install button via captured prompt
- iOS Safari: manual Share → Add to Home Screen instructions (unchanged)
- Firefox desktop: no beforeinstallprompt support (browser-level limitation);
  Firefox shows its own install button in the address bar automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-20 11:14:17 -04:00
parent 9a43879535
commit 9673cbefe3

View File

@@ -5,11 +5,14 @@
*
* Usage:
* - Call `pwa_install.init()` once in the root +layout.svelte (browser $effect).
* init() handles dismiss state and standalone detection (needs localStorage/DOM).
* - Import `pwa_install` in any component to read state and trigger the prompt.
*
* Platform behaviour:
* - Chrome / Android / Desktop Chrome: intercepts `beforeinstallprompt`,
* lets us show a custom button that calls the native install flow.
* - Chrome / Chromium / Android: intercepts `beforeinstallprompt` at module load time
* (critical — the event fires very early, before $effects run). Shows a custom button.
* - Firefox desktop: does NOT support `beforeinstallprompt`. Firefox provides its own
* install button in the address bar when a valid manifest + service worker are present.
* - iOS Safari: `beforeinstallprompt` never fires; we detect iOS + not-standalone
* and show manual "Share → Add to Home Screen" instructions instead.
* - If already running in standalone mode (i.e. already installed) → hide everything.
@@ -33,6 +36,21 @@ let _is_installed = $state(false);
let _is_dismissed = $state(false);
let _initialized = false; // plain boolean — not reactive, guards against double-init
// --- Early event capture (module load time) ---
// `beforeinstallprompt` fires within ~1 second of page load — before any $effect runs.
// Register here so we never miss it regardless of when init() is called.
if (browser) {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
_deferred_prompt = e as BeforeInstallPromptEvent;
});
window.addEventListener('appinstalled', () => {
_is_installed = true;
_deferred_prompt = null;
});
}
// --- Private helpers (SSR-safe) ---
function _in_standalone(): boolean {
@@ -83,7 +101,10 @@ export const pwa_install = {
},
/**
* Register browser event listeners. Call ONCE from root +layout.svelte.
* Finish initialisation after the layout mounts. Call ONCE from root +layout.svelte.
* Handles dismiss state (needs localStorage) and standalone detection (needs DOM).
* Event listeners for beforeinstallprompt/appinstalled are already registered at
* module load time — this does NOT register them again.
* Safe to call multiple times (idempotent).
*/
init(): void {
@@ -107,18 +128,6 @@ export const pwa_install = {
localStorage.removeItem(DISMISS_KEY);
}
}
// Capture the deferred install prompt (Chrome/Android/Desktop Chrome).
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
_deferred_prompt = e as BeforeInstallPromptEvent;
});
// Fired by the browser after the user accepts the install prompt.
window.addEventListener('appinstalled', () => {
_is_installed = true;
_deferred_prompt = null;
});
},
/** Trigger the native install prompt. No-op if no deferred prompt is available. */