From 25e37f56dc86d22bf5c70cf87a98845b6ebe5f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Fri, 18 Sep 2026 13:45:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(sdk):=20stayIntervalMs=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=E2=80=94=E2=80=940/NaN=E9=92=B3=E5=88=B6?= =?UTF-8?q?=E4=B8=8B=E9=99=90=E9=98=B2=E4=BA=8B=E4=BB=B6=E7=81=8C=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- sdk/src/stay.ts | 4 +++- sdk/test/stay.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/sdk/src/stay.ts b/sdk/src/stay.ts index 6ddb425..c3aefcc 100644 --- a/sdk/src/stay.ts +++ b/sdk/src/stay.ts @@ -24,7 +24,9 @@ export interface StayOptions { } export function startStayTracking(t: Transport, ps: PageSession, opts: StayOptions = {}): void { - const stayIntervalMs = opts.stayIntervalMs ?? 15_000; + // 边界守卫:0/负数等有限小值钳制下限 1s(防 setInterval 高频 cut 灌入事件); + // NaN/Infinity 非有限值——NaN 绕过 ?? 的 nullish 判定直传,须显式回落默认 15s。 + const stayIntervalMs = Math.max(1_000, Number.isFinite(opts.stayIntervalMs) ? opts.stayIntervalMs! : 15_000); let intervalStart: number | null = null; // performance.now 基准;null=无 open 区间 let lastCutEnd: number | null = null; // 最近一次 cut 的 perf 端点——重开回溯的钳制上界 let lastAct = Date.now(); // 墙钟活动戳(沿 events.ts 冻结语义;页面加载即活动) diff --git a/sdk/test/stay.test.ts b/sdk/test/stay.test.ts index 69188e9..bb6fd84 100644 --- a/sdk/test/stay.test.ts +++ b/sdk/test/stay.test.ts @@ -254,4 +254,34 @@ describe("stay / startStayTracking", () => { expect(stays[0].properties.active_ms).toBe(30_000); // 活动窗 [0,30s] 整段 expect(stays[0].properties.flush_reason).toBe("periodic"); // 失活悬置属周期结算 }); + + it("stayIntervalMs 边界守卫:NaN 回落默认 15s;0 钳制下限 ≥1s(防高频 cut 灌入)", () => { + // NaN:非 nullish,绕过 ?? 默认值——须回落 15s 语义(16s 恰一条 [0,15s] periodic) + let t = mkTransport("stay-nan"); + let ps = new PageSession(t); + startStayTracking(t, ps, { stayIntervalMs: NaN }); + advance(16_000); + t.flush(); + let stays = staysOf(fetchedEvents("stay-nan")); + expect(stays).toHaveLength(1); + expect(stays[0].properties.active_ms).toBe(15_000); + expect(stays[0].properties.flush_reason).toBe("periodic"); + + // 0:无守卫时 setInterval(0) 每毫秒 cut 灌入事件;钳到下限后 5s 内切分有界。 + // 首秒 tick/cut 同瞬触发顺序不定——断言保持顺序无关:条数有界、区间 ≥1s、相邻不重叠 + t = mkTransport("stay-zero"); + ps = new PageSession(t); + startStayTracking(t, ps, { stayIntervalMs: 0 }); + advance(5_000); + t.flush(); + stays = staysOf(fetchedEvents("stay-zero")).sort( + (a, b) => a.properties.active_from - b.properties.active_from + ); + expect(stays.length).toBeGreaterThanOrEqual(1); + expect(stays.length).toBeLessThanOrEqual(5); // 下限 1s 一刀 → 至多 5 条 + for (const s of stays) expect(s.properties.active_ms).toBeGreaterThanOrEqual(1_000); + for (let i = 1; i < stays.length; i++) { + expect(stays[i].properties.active_from).toBeGreaterThanOrEqual(stays[i - 1].properties.active_to); // lastCutEnd 钳制不重叠 + } + }); });