From 471d7030fae05396b60e916661f4a6c5260ff093 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 14 Jul 2026 22:18:01 -0700 Subject: [PATCH 1/2] fix(browser): preserve queued event identity --- .../browser-identity-batch-boundaries.md | 5 ++ .../browser/__tests__/unit/tracker.test.ts | 72 +++++++++++++++++++ packages/browser/src/tracker.ts | 41 ++++++++--- 3 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 .changeset/browser-identity-batch-boundaries.md diff --git a/.changeset/browser-identity-batch-boundaries.md b/.changeset/browser-identity-batch-boundaries.md new file mode 100644 index 00000000..a4fcdbdc --- /dev/null +++ b/.changeset/browser-identity-batch-boundaries.md @@ -0,0 +1,5 @@ +--- +"@outlit/browser": patch +--- + +Flush queued browser events before changing or clearing attribution identity so account switches cannot reattribute earlier events. diff --git a/packages/browser/__tests__/unit/tracker.test.ts b/packages/browser/__tests__/unit/tracker.test.ts index b55b0f7d..6e77df35 100644 --- a/packages/browser/__tests__/unit/tracker.test.ts +++ b/packages/browser/__tests__/unit/tracker.test.ts @@ -200,4 +200,76 @@ describe("payload identity", () => { expect(payload.userIdentity).not.toHaveProperty("customerId") expect(payload.customerIdentity).not.toHaveProperty("customerTraits") }) + + it("flushes queued events with the previous identity before an account switch", async () => { + const outlit = new Outlit({ + publicKey: "pk_test", + autoTrack: false, + trackPageviews: false, + trackForms: false, + trackEngagement: false, + }) + outlit.enableTracking() + outlit.identify({ email: "user-a@example.com", customerId: "customer_a" }) + await outlit.flush() + + outlit.track("user_a_event") + outlit.identify({ email: "user-b@example.com", customerId: "customer_b" }) + await outlit.flush() + + const payloads = vi + .mocked(global.fetch) + .mock.calls.map(([, options]) => JSON.parse(String(options?.body))) as Array<{ + userIdentity?: { email?: string } + customerIdentity?: { customerId?: string } + events: Array<{ eventName?: string; type: string }> + }> + + expect(payloads).toHaveLength(3) + expect(payloads[1]).toMatchObject({ + userIdentity: { email: "user-a@example.com" }, + customerIdentity: { customerId: "customer_a" }, + events: [{ eventName: "user_a_event", type: "custom" }], + }) + expect(payloads[2]).toMatchObject({ + userIdentity: { email: "user-b@example.com" }, + customerIdentity: { customerId: "customer_b" }, + events: [{ type: "identify" }], + }) + }) + + it("flushes queued events with the known identity before clearUser", async () => { + const outlit = new Outlit({ + publicKey: "pk_test", + autoTrack: false, + trackPageviews: false, + trackForms: false, + trackEngagement: false, + }) + outlit.enableTracking() + outlit.identify({ email: "user-a@example.com" }) + await outlit.flush() + + outlit.track("known_user_event") + outlit.clearUser() + outlit.track("anonymous_event") + await outlit.flush() + + const payloads = vi + .mocked(global.fetch) + .mock.calls.map(([, options]) => JSON.parse(String(options?.body))) as Array<{ + userIdentity?: { email?: string } + events: Array<{ eventName?: string; type: string }> + }> + + expect(payloads).toHaveLength(3) + expect(payloads[1]).toMatchObject({ + userIdentity: { email: "user-a@example.com" }, + events: [{ eventName: "known_user_event", type: "custom" }], + }) + expect(payloads[2]?.userIdentity).toBeUndefined() + expect(payloads[2]?.events).toEqual([ + expect.objectContaining({ eventName: "anonymous_event", type: "custom" }), + ]) + }) }) diff --git a/packages/browser/src/tracker.ts b/packages/browser/src/tracker.ts index 1be16314..8f735608 100644 --- a/packages/browser/src/tracker.ts +++ b/packages/browser/src/tracker.ts @@ -286,15 +286,21 @@ export class Outlit { return } - if (options.email || options.userId) { - this.currentUser = { - email: options.email, - userId: options.userId, - customerId: options.customerId, - customerTraits: options.customerTraits, - traits: options.traits, - } + const nextUser = { + email: options.email, + userId: options.userId, + customerId: options.customerId, + customerTraits: options.customerTraits, + traits: options.traits, + } + + // sendEvents snapshots currentUser synchronously before its first await. Flush + // before changing attribution so queued events retain the identity they had + // when they were recorded. + if (this.hasAttributionChanged(nextUser)) { + void this.flush() } + this.currentUser = nextUser const event = buildIdentifyEvent({ url: window.location.href, @@ -337,6 +343,9 @@ export class Outlit { * Call this when the user logs out. */ clearUser(): void { + if (this.currentUser) { + void this.flush() + } this.currentUser = null this.pendingUser = null } @@ -345,7 +354,6 @@ export class Outlit { * Apply user identity and send identify event. */ private applyUser(identity: UserIdentity): void { - this.currentUser = identity this.identify({ email: identity.email, userId: identity.userId, @@ -355,6 +363,21 @@ export class Outlit { }) } + private hasAttributionChanged(nextUser: UserIdentity): boolean { + if (!this.currentUser) { + return false + } + + const normalizeEmail = (email: string | undefined) => email?.trim().toLowerCase() + const normalizeId = (id: string | undefined) => id?.trim() + + return ( + normalizeEmail(this.currentUser.email) !== normalizeEmail(nextUser.email) || + normalizeId(this.currentUser.userId) !== normalizeId(nextUser.userId) || + normalizeId(this.currentUser.customerId) !== normalizeId(nextUser.customerId) + ) + } + /** User namespace method for identity. */ readonly user: UserMethods = { identify: (options: BrowserIdentifyOptions) => this.identify(options), From b547c87f6656da47956ef4058d04df2e35213894 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 14 Jul 2026 23:50:38 -0700 Subject: [PATCH 2/2] test(browser): cover normalized identity reuse --- .../browser/__tests__/unit/tracker.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/browser/__tests__/unit/tracker.test.ts b/packages/browser/__tests__/unit/tracker.test.ts index 6e77df35..33a72108 100644 --- a/packages/browser/__tests__/unit/tracker.test.ts +++ b/packages/browser/__tests__/unit/tracker.test.ts @@ -272,4 +272,33 @@ describe("payload identity", () => { expect.objectContaining({ eventName: "anonymous_event", type: "custom" }), ]) }) + + it("does not split a batch when attribution differs only by normalization", async () => { + const outlit = new Outlit({ + publicKey: "pk_test", + autoTrack: false, + trackPageviews: false, + trackForms: false, + trackEngagement: false, + }) + outlit.enableTracking() + outlit.identify({ email: "User-A@Example.com ", userId: " user_a " }) + await outlit.flush() + + outlit.track("same_identity_event") + outlit.identify({ email: " user-a@example.com", userId: "user_a" }) + await outlit.flush() + + const payloads = vi + .mocked(global.fetch) + .mock.calls.map(([, options]) => JSON.parse(String(options?.body))) as Array<{ + events: Array<{ eventName?: string; type: string }> + }> + + expect(payloads).toHaveLength(2) + expect(payloads[1]?.events).toEqual([ + expect.objectContaining({ eventName: "same_identity_event", type: "custom" }), + expect.objectContaining({ type: "identify" }), + ]) + }) })