Files
skills/docs/foundation/data/pwa/src/install-prompt/install-prompt.md
2026-05-29 08:20:56 +01:00

2.6 KiB

install-prompt/install-prompt.ts

(no submodules)

Purpose

InstallPromptManager captures the browser-fired beforeinstallprompt event and exposes it as a typed state machine the application can drive programmatically. The manager also handles appinstalled for hot installations, persists dismissal across browser sessions (so dismissed users are not re-prompted on every visit), and emits OTel counters partitioned by outcome (accepted / dismissed).

Features

  • State machine: idle → available → (accepted | dismissed).
  • prompt() invokes the captured deferred event and awaits the user's choice.
  • dismiss() clears the captured event without showing it, persisting the decision so future installs require an explicit clearDismissed().
  • Injectable WindowLike + StorageLike seams so tests run without globalThis patching.
  • OTel pwa_install_prompt_total counter with outcome attribute.

Usage: Basic

import { InstallPromptManager } from "@suaveplan/pwa";

const ipm = new InstallPromptManager();
ipm.listen();
ipm.onStateChange((s) => {
    if (s === "available") showInstallBanner();
});

Usage: Advanced

Inject window + storage seams and drive every transition:

import { InstallPromptManager } from "@suaveplan/pwa";

const window = createFakeWindow();
const storage = createMemoryStorage();
const ipm = new InstallPromptManager({ window, storage });
ipm.listen();
window.dispatchBeforeInstallPrompt();
expect(ipm.isAvailable).toBe(true);
await ipm.prompt();
expect(ipm.state).toBe("accepted");

API Reference

  • new InstallPromptManager(options?: InstallPromptManagerOptions).
  • state: InstallPromptState (readonly).
  • isAvailable: boolean.
  • wasDismissed: boolean.
  • listen(): void.
  • unlisten(): void.
  • prompt(): Promise<InstallPromptState>.
  • dismiss(): void.
  • clearDismissed(): void.
  • onStateChange(listener): () => void.
  • reset(): void.

Implementation Notes

The WindowLike seam exposes only addEventListener / removeEventListener so tests can pass a tiny EventTarget subclass without faking the full Window interface. The dismissal-persisting writes use a try/catch because StorageLike implementations can throw (QuotaExceededError under heavy quotas) and the install-prompt should remain functional even when persistence fails — at worst the user will be re-prompted next session. The manager explicitly does NOT trigger any platform-native UI itself: prompt() defers entirely to the browser's deferred event, and dismiss() only updates the internal state machine + storage.