web: the offer to install is made once, and the answer is kept
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user