web: the offer to install is made once, and the answer is kept

This commit is contained in:
2026-10-01 07:46:10 +07:00
parent 7b6a1a805f
commit 31435aac77
3 changed files with 62 additions and 11 deletions
+33 -3
View File
@@ -1,7 +1,7 @@
// Install support is not visible in the source. It is the manifest the browser
// reads, the worker it registers, and what that worker still holds once the network
// is gone — so unlike the other checks here, which read a module and assert on it,
// this one has to drive a real browser at a real build. Four assertions, one line
// this one has to drive a real browser at a real build. Five assertions, one line
// each, against a preview server:
//
// npx vite build && npx vite preview --port 4183 &
@@ -25,7 +25,7 @@ const browser = await chromium.launch({
});
const context = await browser.newContext();
// Every assertion prints, rather than throwing on the first one: which of the four
// Every assertion prints, rather than throwing on the first one: which of the five
// works and which does not is the whole answer.
let failed = 0;
const check = (ok, label) => {
@@ -78,9 +78,39 @@ try {
}
check(/RecipesCam/.test(brand ?? ''), `offline reload of /app paints the app's own header: ${brand}`);
// (e) The offer is asked once. The browser's sheet is stubbed, because a check cannot
// click the real one: waved away it is a refusal, and a refusal is stored, so the next
// visit renders no dialog at all. The stub goes in an init script so it is there on
// both loads.
await context.setOffline(false);
await page.addInitScript(() => {
window.__offerInstall = () => {
const event = new Event('beforeinstallprompt');
event.prompt = () => Promise.resolve();
event.userChoice = Promise.resolve({ outcome: 'dismissed' });
window.dispatchEvent(event);
window.dispatchEvent(new Event('rc:install-offer'));
};
});
const offer = async () => {
await page.evaluate(() => window.__offerInstall());
await page.waitForTimeout(150);
return page.locator('[data-key="install-yes"]').count();
};
await page.reload();
const first = await offer();
await page.locator('[data-key="install-yes"]').click();
const stored = await page.evaluate(() => localStorage.getItem('rc.install.dismissed.v1'));
await page.reload();
const again = await offer();
check(
first === 1 && stored === '1' && again === 0,
`install dialog shown ${first} time(s), refused (localStorage '${stored}'), shown again after reload: ${again}`,
);
await browser.close();
if (failed) {
console.error(`pwa-check: ${failed} of 4 failed`);
console.error(`pwa-check: ${failed} of 5 failed`);
process.exit(1);
}
console.log('pwa-check ok');
+25 -4
View File
@@ -2,8 +2,8 @@
// whenever it likes, and the event has to be held until a click spends it — so this
// is a module-level slot plus a subscriber list, and no store, hook or context.
//
// The one piece of state the visitor owns is the refusal: remember it, and the offer
// never comes back. A dismissal is an answer, not a snooze.
// The state the visitor owns is the answer: a refusal, or an install. Remember either
// one, and the offer never comes back. A no is an answer, not a snooze.
type InstallEvent = Event & {
prompt: () => Promise<void>;
@@ -11,6 +11,9 @@ type InstallEvent = Event & {
};
const DISMISSED = 'rc.install.dismissed.v1';
// The other answer: installed, and never asked again — including from a plain browser
// tab, which has no `standalone` to tell it so.
const INSTALLED = 'rc.install.installed.v1';
// The window event a control elsewhere in the app (the Library's own button, say)
// fires to pull the offer forward instead of waiting out the delay.
@@ -33,11 +36,15 @@ export function subscribe(listener: () => void): () => void {
}
export function canInstall(): boolean {
return deferred !== null && !dismissed();
return deferred !== null && !dismissed() && !isInstalled();
}
// Spends the browser's prompt and reports the answer. Null means there was nothing
// to spend: the event never arrived, or this is a second call.
//
// Either answer is remembered, because either answer is the visitor's: yes is the app
// being installed, and the browser's own sheet being waved away is a no — the event is
// spent now, and it never fires again for this install.
export async function promptInstall(): Promise<'accepted' | 'dismissed' | null> {
const event = deferred;
if (!event) return null;
@@ -47,6 +54,8 @@ export async function promptInstall(): Promise<'accepted' | 'dismissed' | null>
notify();
await event.prompt();
const { outcome } = await event.userChoice;
if (outcome === 'accepted') markInstalled();
else dismissInstall();
return outcome;
}
@@ -76,6 +85,18 @@ export function dismissInstall(): void {
notify();
}
// True in the installed window itself, and also in any browser tab that watched the
// install happen — that tab keeps running the site, and must not ask for the app it
// just installed.
export function isInstalled(): boolean {
return isStandalone() || localStorage.getItem(INSTALLED) === '1';
}
export function markInstalled(): void {
localStorage.setItem(INSTALLED, '1');
notify();
}
// Asks for the offer now.
export function offerInstall(): void {
window.dispatchEvent(new Event(OFFER_EVENT));
@@ -93,5 +114,5 @@ window.addEventListener('beforeinstallprompt', (event) => {
// and is the only signal that the offer is done.
window.addEventListener('appinstalled', () => {
deferred = null;
notify();
markInstalled();
});
+4 -4
View File
@@ -5,8 +5,8 @@ import {
canInstall,
dismissInstall,
dismissed,
isInstalled,
isIosSafari,
isStandalone,
promptInstall,
subscribe,
} from '../pwa/install';
@@ -30,7 +30,7 @@ export function InstallPrompt() {
useEffect(() => {
// An installed app has nothing to offer, and a refusal is final — the visitor
// said no to this dialog, not to this session.
if (isStandalone() || dismissed()) return;
if (isInstalled() || dismissed()) return;
const manual = isIosSafari();
let waited = false;
@@ -86,8 +86,8 @@ export function InstallPrompt() {
type="button"
className="btn primary"
data-key="install-yes"
// Not a dismissal: taking the offer and waving the browser's own sheet
// away are different answers, and the app's own button can still ask.
// `promptInstall` records the browser's answer itself: accepted means the
// app is installed, waved away means the offer was spent and refused.
onClick={() => {
void promptInstall();
setFace(null);