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:
@@ -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 事件 ready(3s 超时)
|
||||
// - 优先使用已缓存的 deferred prompt
|
||||
// - 如未缓存,再等待 deferred 事件 ready(3s 超时)
|
||||
// - 调浏览器原生 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:
|
||||
// - loginToken(OAuth/email sync 写下的)→ 视为"已登录"跳 /chat
|
||||
|
||||
+17
-6
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user