From dfb0916dec052904df4bb5ec712f431ddaa7ae9f Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 10 Apr 2026 14:55:04 -0700 Subject: [PATCH 1/4] Fix EventTracker silently dormant in real browsers `window.screen` and `window.history` are accessor properties on `Window.prototype`, so `Object.getOwnPropertyDescriptor(window, X)?.value` returned undefined in real browsers, causing `start()` to short-circuit and never capture or send any $page-view / $click events. Read the globals directly instead; the jsdom-based regression test pins the accessor-descriptor shape so this can't silently come back. --- .../implementations/event-tracker.test.ts | 108 ++++++++++++++++++ .../apps/implementations/event-tracker.ts | 14 ++- 2 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts new file mode 100644 index 0000000000..22a519bd22 --- /dev/null +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment jsdom + +import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventTracker } from "./event-tracker"; + +async function advancePastAccessTokenRefresh() { + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); +} + +function getSentEventTypes(sentBodies: string[]) { + const [body] = sentBodies; + if (body == null) { + throw new Error("Expected EventTracker to send an analytics batch."); + } + + const payload = JSON.parse(body); + if (typeof payload !== "object" || payload === null || !("events" in payload) || !Array.isArray(payload.events)) { + throw new Error("Expected analytics batch payload to include an events array."); + } + + return payload.events.map((event) => event.event_type); +} + +describe("EventTracker", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("captures events when browser globals are exposed as accessor descriptors", async () => { + vi.useFakeTimers(); + document.body.innerHTML = ""; + + const screenDescriptor = Object.getOwnPropertyDescriptor(window, "screen"); + const historyDescriptor = Object.getOwnPropertyDescriptor(window, "history"); + expect(screenDescriptor?.value).toBeUndefined(); + expect(historyDescriptor?.value).toBeUndefined(); + expect(screenDescriptor?.get).toBeTypeOf("function"); + expect(historyDescriptor?.get).toBeTypeOf("function"); + + const sentBodies: string[] = []; + const tracker = new EventTracker({ + projectId: "internal", + getAccessToken: async () => "access-token", + sendBatch: async (body) => { + sentBodies.push(body); + return Result.ok(new Response()); + }, + }); + + try { + tracker.start(); + document.querySelector("button")?.dispatchEvent(new MouseEvent("click", { + bubbles: true, + clientX: 12, + clientY: 34, + })); + + await advancePastAccessTokenRefresh(); + + expect(getSentEventTypes(sentBodies)).toMatchInlineSnapshot(` + [ + "$page-view", + "$click", + ] + `); + } finally { + tracker.stop(); + } + }); + + it("captures client-side navigations when history is exposed as an accessor descriptor", async () => { + vi.useFakeTimers(); + + const historyDescriptor = Object.getOwnPropertyDescriptor(window, "history"); + expect(historyDescriptor?.value).toBeUndefined(); + expect(historyDescriptor?.get).toBeTypeOf("function"); + + const sentBodies: string[] = []; + const tracker = new EventTracker({ + projectId: "internal", + getAccessToken: async () => "access-token", + sendBatch: async (body) => { + sentBodies.push(body); + return Result.ok(new Response()); + }, + }); + + try { + tracker.start(); + window.history.pushState({}, "", "/projects/test-project"); + + await advancePastAccessTokenRefresh(); + + expect(getSentEventTypes(sentBodies)).toMatchInlineSnapshot(` + [ + "$page-view", + "$page-view", + ] + `); + } finally { + tracker.stop(); + } + }); +}); diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts index 04cbe403f8..ab688f67cf 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts @@ -62,13 +62,17 @@ export class EventTracker { start() { if (this._started) return; if (!isBrowserLike()) return; - const screenObject = Object.getOwnPropertyDescriptor(window, "screen")?.value; + // Note: we read `window.screen` / `window.history` directly rather than via + // Object.getOwnPropertyDescriptor(...).value. In real browsers these are + // accessor properties on Window.prototype (the descriptor has `.get`, not + // `.value`), so the descriptor-based lookup silently returns undefined and + // this start() would short-circuit, leaving EventTracker dormant. if ( typeof window.addEventListener !== "function" || typeof window.removeEventListener !== "function" || typeof document.addEventListener !== "function" || typeof document.removeEventListener !== "function" - || !hasScreenDimensions(screenObject) + || !hasScreenDimensions(window.screen) ) { return; } @@ -105,7 +109,7 @@ export class EventTracker { } private _capturePageView(entryType: "initial" | "push" | "replace" | "pop") { - const screenObject = Object.getOwnPropertyDescriptor(window, "screen")?.value; + const screenObject = window.screen; if (!hasScreenDimensions(screenObject)) { return; } @@ -134,7 +138,7 @@ export class EventTracker { private _setupPageViewCapture() { // Fire initial page-view this._capturePageView("initial"); - const historyObject = Object.getOwnPropertyDescriptor(window, "history")?.value; + const historyObject = window.history; if (!hasHistoryMethods(historyObject)) { return; } @@ -246,7 +250,7 @@ export class EventTracker { } // Restore history methods - const historyObject = Object.getOwnPropertyDescriptor(window, "history")?.value; + const historyObject = window.history; if (hasHistoryMethods(historyObject)) { if (this._originalPushState) { historyObject.pushState = this._originalPushState; From 839bee3b2651cba723b373e7858b7af27c721c1c Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 10 Apr 2026 14:58:26 -0700 Subject: [PATCH 2/4] fix --- .../src/lib/stack-app/apps/implementations/event-tracker.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts index ab688f67cf..c46a652d29 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts @@ -62,11 +62,6 @@ export class EventTracker { start() { if (this._started) return; if (!isBrowserLike()) return; - // Note: we read `window.screen` / `window.history` directly rather than via - // Object.getOwnPropertyDescriptor(...).value. In real browsers these are - // accessor properties on Window.prototype (the descriptor has `.get`, not - // `.value`), so the descriptor-based lookup silently returns undefined and - // this start() would short-circuit, leaving EventTracker dormant. if ( typeof window.addEventListener !== "function" || typeof window.removeEventListener !== "function" From a17281b78b65fe919483d53e78b28123770250ad Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 10 Apr 2026 16:42:16 -0700 Subject: [PATCH 3/4] even tracker test lint fix --- .../lib/stack-app/apps/implementations/event-tracker.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts index 22a519bd22..a33889177d 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts @@ -13,9 +13,6 @@ async function advancePastAccessTokenRefresh() { function getSentEventTypes(sentBodies: string[]) { const [body] = sentBodies; - if (body == null) { - throw new Error("Expected EventTracker to send an analytics batch."); - } const payload = JSON.parse(body); if (typeof payload !== "object" || payload === null || !("events" in payload) || !Array.isArray(payload.events)) { From fc923a938789afce1966f54c622c4d28d1239ed9 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 10 Apr 2026 17:56:18 -0700 Subject: [PATCH 4/4] Annotate event-tracker test callback to satisfy typecheck `payload.events` comes from `JSON.parse`, so after the narrow it's `any[]` and the `.map` callback parameter trips noImplicitAny in the generated SDK packages. Cast to a concrete element type before mapping. --- .../lib/stack-app/apps/implementations/event-tracker.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts index a33889177d..a69dee67c6 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts @@ -19,7 +19,7 @@ function getSentEventTypes(sentBodies: string[]) { throw new Error("Expected analytics batch payload to include an events array."); } - return payload.events.map((event) => event.event_type); + return (payload.events as { event_type: string }[]).map((event) => event.event_type); } describe("EventTracker", () => {