diff --git a/sdk/src/page.ts b/sdk/src/page.ts index 29ff0bd..2435fff 100644 --- a/sdk/src/page.ts +++ b/sdk/src/page.ts @@ -115,6 +115,16 @@ export class PageSession { onClose(fn: () => void): void { this.closeListeners.push(fn); + // 注册晚于 close(T7 初始化与 pagehide/路由切换竞态):立即补触发一次。 + // closeListeners 不因 revive 清空——bfcache 复活后的下一次 close 会再次触发 + // 全部监听器(周期结算正确性)。 + if (this._closed) { + try { + fn(); + } catch { + /* 静默(spec §4.5) */ + } + } } /** 本段 page_view(初始加载 / SPA 新段);bfcache 复活不调此方法(不产生新 PV)。 */ @@ -164,9 +174,11 @@ export class PageSession { session_id: a.session_id, page_id: a.page_id, seq: a.seq, - purpose: null, - epoch: null, - grant_ref: null, + // V1.1 预留字段从快照展开(非字面量)——将来 attribution() 填真实值时 + // 事件自动携带,不会静默漏 + purpose: a.purpose, + epoch: a.epoch, + grant_ref: a.grant_ref, }); } } diff --git a/sdk/src/transport.ts b/sdk/src/transport.ts index 710c0cb..694a2f2 100644 --- a/sdk/src/transport.ts +++ b/sdk/src/transport.ts @@ -1,5 +1,6 @@ import { uuid } from "./util"; import { getVisitorId, getSessionId } from "./identity"; +import { getPageSession } from "./page"; import type { CtkConfig } from "./config"; export type TransportConfig = Required; @@ -28,6 +29,15 @@ export class Transport { } flush(beacon = false) { + // flush 前 resume 检查点(T8 预埋 + T8 前双路径保险):本批信封仍走 + // getSessionId()(会 touchActivity 刷 ctk_act)——若不先检查,hidden 期间的 + // 定时器 flush 会把空闲判定无限续期,用户回前台后永不 resume(OT01 违规)。 + // PageSession 未启动时为 null → 安全跳过(v1 旧路径照旧)。 + try { + getPageSession()?.maybeResume(); + } catch { + /* 静默(spec §4.5:定时器回调内的异常不得冒泡) */ + } if (this.timer) { clearTimeout(this.timer); this.timer = null; diff --git a/sdk/test/page.test.ts b/sdk/test/page.test.ts index 6525194..2ef6648 100644 --- a/sdk/test/page.test.ts +++ b/sdk/test/page.test.ts @@ -265,4 +265,33 @@ describe("PageSession / startPageSession", () => { t.flush(); expect(flushedEvents("site-close").some((e) => e.event_name === "session_resume")).toBe(false); }); + + it("onClose 晚注册于 close 之后立即补触发;revive 后再 close 再次触发(bfcache 周期结算)", () => { + const t = mkTransport("site-late-hook"); + const ps = startPageSession(t); + ps.close(); // 先关闭(T7 初始化与 pagehide/路由切换竞态) + const hook = vi.fn(); + ps.onClose(hook); // 晚注册:立即触发一次,不丢失本次结算时机 + expect(hook).toHaveBeenCalledTimes(1); + + ps.revive(); // bfcache 复活:监听器不因 revive 清空 + ps.close(); + expect(hook).toHaveBeenCalledTimes(2); // 新周期的 close 再次触发 + }); + + it("flush 前检查点(Transport.flush 预埋):hidden 期间 flush 刷活 ctk_act 后仍能恢复", () => { + const t = mkTransport("site-flushcp"); + const ps = startPageSession(t); + const sid = ps.attribution().session_id; + Object.defineProperty(document, "hidden", { value: true, configurable: true }); + vi.advanceTimersByTime(31 * 60_000); + + t.flush(); // 信封 getSessionId() 会 touchActivity——若无 flush 前检查点,空闲被无限续期 + delete (document as { hidden?: boolean }).hidden; + document.dispatchEvent(new Event("visibilitychange")); // 用户回前台 + expect(ps.attribution().session_id).not.toBe(sid); // 恢复未被抑制(OT01) + + const sr = flushedEvents("site-flushcp").find((e) => e.event_name === "session_resume")!; + expect(sr.properties.prev_session_id).toBe(sid); + }); });