feat(pwa): add useInstallPrompt hook with tests

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-02 19:48:13 +02:00
parent c80a6fa94b
commit e23013b0af
No known key found for this signature in database
2 changed files with 110 additions and 0 deletions

View file

@ -0,0 +1,60 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useInstallPrompt } from "./useInstallPrompt";
const DISMISSED_KEY = "pwa-install-dismissed";
describe("useInstallPrompt", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("starts with canInstall false and isStandalone false", () => {
const { result } = renderHook(() => useInstallPrompt());
expect(result.current.canInstall).toBe(false);
expect(result.current.isStandalone).toBe(false);
expect(result.current.isDismissed).toBe(false);
});
it("captures beforeinstallprompt event and sets canInstall to true", () => {
const { result } = renderHook(() => useInstallPrompt());
const event = new Event("beforeinstallprompt");
Object.assign(event, { preventDefault: vi.fn(), prompt: vi.fn() });
act(() => {
window.dispatchEvent(event);
});
expect(result.current.canInstall).toBe(true);
});
it("dismiss persists to localStorage and sets isDismissed", () => {
const { result } = renderHook(() => useInstallPrompt());
act(() => {
result.current.dismiss();
});
expect(result.current.isDismissed).toBe(true);
expect(localStorage.getItem(DISMISSED_KEY)).toBe("true");
});
it("reads dismissed state from localStorage on mount", () => {
localStorage.setItem(DISMISSED_KEY, "true");
const { result } = renderHook(() => useInstallPrompt());
expect(result.current.isDismissed).toBe(true);
});
it("detects iOS Safari", () => {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
);
const { result } = renderHook(() => useInstallPrompt());
expect(result.current.isIOS).toBe(true);
});
});

View file

@ -0,0 +1,50 @@
import { useCallback, useEffect, useRef, useState } from "react";
const DISMISSED_KEY = "pwa-install-dismissed";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<{ outcome: "accepted" | "dismissed" }>;
}
export function useInstallPrompt() {
const deferredPrompt = useRef<BeforeInstallPromptEvent | null>(null);
const [canInstall, setCanInstall] = useState(false);
const [isDismissed, setIsDismissed] = useState(
() => localStorage.getItem(DISMISSED_KEY) === "true"
);
const isStandalone =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
(window.matchMedia("(display-mode: standalone)")?.matches ?? false);
const isIOS =
typeof navigator !== "undefined" &&
/iPad|iPhone|iPod/.test(navigator.userAgent);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
deferredPrompt.current = e as BeforeInstallPromptEvent;
setCanInstall(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const promptInstall = useCallback(async () => {
if (!deferredPrompt.current) return;
const result = await deferredPrompt.current.prompt();
if (result.outcome === "accepted") {
deferredPrompt.current = null;
setCanInstall(false);
}
}, []);
const dismiss = useCallback(() => {
setIsDismissed(true);
localStorage.setItem(DISMISSED_KEY, "true");
}, []);
return { canInstall, isStandalone, isIOS, isDismissed, promptInstall, dismiss };
}