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
@@ -13,13 +13,13 @@ import styles from "./pwa-install-dialog.module.css";
export interface PwaInstallDialogProps {
onClose: () => void;
onInstall?: () => void;
onInstall?: () => void | Promise<void>;
}
export function PwaInstallDialog({ onClose, onInstall }: PwaInstallDialogProps) {
const handleInstall = () => {
const handleInstall = async () => {
if (onInstall) {
onInstall();
await onInstall();
} else {
// 提示用户使用浏览器"添加到主屏幕"功能
window.alert(
@@ -32,6 +32,8 @@ export function PwaInstallOverlay() {
// isInstalled() 检查 standalone display-mode(避免给已安装用户再弹)
if (!pwaUtil.isSupported()) return;
if (pwaUtil.isInstalled()) return;
// 提前挂 beforeinstallprompt 监听,避免用户点 OK 时事件已在更早时机丢失。
pwaUtil.prepareInstallPrompt();
const isDev = process.env.NODE_ENV === "development";
@@ -92,13 +94,13 @@ function PwaInstallDialogMount({
}}
onInstall={async () => {
// 走 pwaUtil.install()
// - 挂 beforeinstallprompt 监听(如未挂)
// - 等待 deferred 事件 ready3s 超时)
// - 优先使用已缓存的 deferred prompt
// - 如未缓存,再等待 deferred 事件 ready3s 超时)
// - 调浏览器原生 prompt
// - 返 "accepted" / "dismissed" / "unavailable"
// 当前不消费返回值 —— 用户已经看到 dialog 点了 OK,是否真装上是浏览器的事。
// 后续可在此接 metrics 上报(pwa_accepted / pwa_dismissed / pwa_unavailable)。
void pwaUtil.install();
await pwaUtil.install();
}}
/>
);
@@ -8,6 +8,7 @@ import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch } from "@/stores/user/user-context";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { ROUTES } from "@/router/routes";
import { pwaUtil } from "@/utils/pwa";
import { SplashBackground } from "./splash-background";
import { SplashLogo } from "./splash-logo";
@@ -15,6 +16,11 @@ import { SplashContent } from "./splash-content";
import { SplashButton } from "./splash-button";
import styles from "./splash-screen.module.css";
// 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。
if (typeof window !== "undefined") {
pwaUtil.prepareInstallPrompt();
}
export function SplashScreen() {
const router = useRouter();
const state = useAuthState();
@@ -26,6 +32,10 @@ export function SplashScreen() {
router.replace(ROUTES.chat);
};
useEffect(() => {
pwaUtil.prepareInstallPrompt();
}, []);
// ─────────────────────────────────────────────────────────────
// useEffect ① 启动时检查 localStorage 里的 loginToken
// - loginTokenOAuth/email sync 写下的)→ 视为"已登录"跳 /chat
+16 -5
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 安装(浏览器原生安装提示)。
*