fix(pwa): prepare beforeinstallprompt listener early to avoid event loss

The previous install flow had a timing race: the
`beforeinstallprompt` event fires at unpredictable times after
page load (often ~30s+ into the session, when Chrome's heuristic
finally decides the user is engaged). If the listener was
attached only inside the dialog's onClick handler, by the time
the user clicked OK the event had already fired into the void
and the deferred prompt was null — so `install()` returned
"unavailable" every time.

The new flow attaches the listener as early as possible:

1. `pwaUtil.prepareInstallPrompt()` — new public method that
   calls the private `attachListener()`. Idempotent (guard via
   `listenersAttached`).

2. `pwaUtil.canPromptInstall()` — companion getter that returns
   whether a deferred event is currently cached. Useful for UI
   to decide whether to even show the install CTA.

3. `pwa-install-overlay.tsx` — calls `prepareInstallPrompt()`
   right after the isSupported / isInstalled guards. The overlay
   mounts when the user enters /chat, so this is the earliest
   practical point in the user flow.

4. `pwa-screen.tsx` — calls `prepareInstallPrompt()` BOTH at
   module load (synchronous, so the listener is attached before
   the splash screen's useEffects run) AND in a useEffect for
   robustness. The splash screen is the very first screen the
   user sees, so attaching here is the earliest possible.

5. `pwa-install-dialog.tsx` — `handleInstall` is now async,
   and `onInstall` accepts `() => void | Promise<void>`. The
   overlay calls `await pwaUtil.install()` so the deferred
   prompt flow can complete before the dialog unmounts.

Together: the listener is now attached from the moment the
splash screen module loads, the deferred prompt is captured
whenever Chrome decides to fire the event, and the dialog's
install button can reliably `prompt()` against the cached
deferred. The full install flow now works end-to-end.
This commit is contained in:
2026-06-17 12:00:24 +08:00
parent c07a10ee80
commit 10e6c69af8
4 changed files with 35 additions and 12 deletions
+17 -6
View File
@@ -2,11 +2,6 @@
/**
* PwaUtil — PWA 工具类
*
* 三个核心能力(与 [src/hooks/use-pwa-install.ts] 对齐,但面向非 React 代码):
* - `isSupported()` 浏览器是否支持 PWA 安装(serviceWorker + manifest
* - `isInstalled()` PWA 是否已安装为 standalone(避免重复提示)
* - `install()` 触发浏览器原生安装流程(`beforeinstallprompt`
*
* 设计要点:
* - SSR 安全:所有方法 `typeof window === "undefined"` 守卫,SSR 全部返 false / "unavailable"
* - 内部缓存 `beforeinstallprompt` 事件对象;install() 时如有缓存直接 prompt,否则等
@@ -80,6 +75,22 @@ export class PwaUtil {
return false;
}
/**
* 提前挂上 `beforeinstallprompt` 监听。
*
* 目的:浏览器事件常常会在页面稳定后自行触发;如果等到用户点"安装"时才挂监听,
* 很容易错过那次事件,导致后续 `install()` 拿不到 deferred prompt。
*/
prepareInstallPrompt(): void {
if (!this.isSupported()) return;
this.attachListener();
}
/** 当前是否已缓存可用的原生安装提示事件。 */
canPromptInstall(): boolean {
return this.deferred !== null;
}
/**
* 触发 PWA 安装(浏览器原生安装提示)。
*
@@ -170,4 +181,4 @@ export class PwaUtil {
}
/** 全局单例(与 `deviceIdentifier` / `SpAsyncUtil` 命名约定一致) */
export const pwaUtil = new PwaUtil();
export const pwaUtil = new PwaUtil();