fix(braze-web): gate deferUntilIdentified on a fresh identify, not localStorage - #3985
fix(braze-web): gate deferUntilIdentified on a fresh identify, not localStorage#3985itsarijitray wants to merge 4 commits into
Conversation
…calStorage The "Only Track Known Users" (deferUntilIdentified) guard in ready() checked analytics.user().id(), which resolves from the persisted ajs_user_id value in localStorage. That value survives across sessions, so the guard passed on page loads where a userId was cached but no identify() fired this load. Braze then ran initialize() + openSession() without changeUser(), creating permanent, un-mergeable anonymous profiles (device ID, no external ID) that still count against Braze MAU billing. Gate initialization on an identify actually observed in the current page load instead: updateUserProfile (subscribed to identify) records the fresh userId via setDeferredUser before calling ready(), and ready() calls changeUser() before openSession() so the session is attributed to the known user. - Path A (stale localStorage, no identify): SDK never opens a session. - Path B (login race): changeUser runs before openSession; no orphan profile. - Behavior is unchanged when deferUntilIdentified is off. Matches the original DEST-1710 intent from the classic destination. STRATCONN-6967 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Fixes Braze Web (Actions) behavior for deferUntilIdentified so the SDK only initializes after an identify() observed in the current page load, preventing orphan anonymous Braze profiles and ensuring changeUser() happens before openSession().
Changes:
- Track an in-page-load
userIdvia a newclient.setDeferredUser()and gateready()on that value (not persistedajs_user_id). - Ensure
changeUser()is invoked beforeopenSession()when initializing after a fresh identify. - Add unit tests covering the “stale persisted userId” path and call-order guarantee.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/browser-destinations/destinations/braze/src/updateUserProfile/index.ts | Captures the identified userId for the current page load before calling ready(). |
| packages/browser-destinations/destinations/braze/src/index.ts | Adds in-session deferredUserId gating and calls changeUser() before openSession(). |
| packages/browser-destinations/destinations/braze/src/braze-types.ts | Extends the destination client interface with setDeferredUser. |
| packages/browser-destinations/destinations/braze/src/tests/initialization.test.ts | Adds tests for “stale localStorage userId” and changeUser→openSession ordering. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // Attribute the session to the identified user before opening it so Braze | ||
| // does not create a separate, un-mergeable anonymous profile for this device. | ||
| if (deferredUserId !== undefined) { | ||
| client.instance.changeUser(deferredUserId) | ||
| } | ||
|
|
||
| client.instance.openSession() |
| // Records the userId from an identify observed in the current page load so that, | ||
| // when `deferUntilIdentified` is enabled, `ready()` can gate initialization on a | ||
| // fresh identify instead of a value persisted in localStorage. | ||
| setDeferredUser?: (userId: string) => void |
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| // Attribute the session to the identified user before opening it so Braze | ||
| // does not create a separate, un-mergeable anonymous profile for this device. | ||
| if (deferredUserId !== undefined) { | ||
| client.instance.changeUser(deferredUserId) | ||
| } |
| // Records the userId from an identify observed in the current page load so that, | ||
| // when `deferUntilIdentified` is enabled, `ready()` can gate initialization on a | ||
| // fresh identify instead of a value persisted in localStorage. | ||
| setDeferredUser?: (userId: string) => void |
| const { instance: braze } = await initializeSpy.mock.results[0].value | ||
| const openSessionSpy = jest.spyOn(braze, 'openSession') | ||
| const changeUserSpy = jest.spyOn(braze, 'changeUser') |
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| // Records the userId from an identify observed in the current page load so that, | ||
| // when `deferUntilIdentified` is enabled, `ready()` can gate initialization on a | ||
| // fresh identify instead of a value persisted in localStorage. | ||
| setDeferredUser?: (userId: string) => void |
| if (payload.external_id) { | ||
| client.setDeferredUser?.(payload.external_id) | ||
| } |
| await analytics.track?.({ | ||
| type: 'track', | ||
| event: 'UFC', | ||
| properties: { | ||
| goat: 'hasbulla' | ||
| } | ||
| }) |
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| // Records the userId from an identify observed in the current page load so that, | ||
| // when `deferUntilIdentified` is enabled, `ready()` can gate initialization on a | ||
| // fresh identify instead of a value persisted in localStorage. | ||
| setDeferredUser?: (userId: string) => void |
| // Attribute the session to the identified user before opening it so Braze | ||
| // does not create a separate, un-mergeable anonymous profile for this device. | ||
| if (deferredUserId !== undefined) { | ||
| client.instance.changeUser(deferredUserId) | ||
| } | ||
|
|
||
| client.instance.openSession() |
| // This must NOT be enough to open a Braze session on its own. | ||
| jest.spyOn(analytics.user(), 'id').mockReturnValue('stale-user-123') | ||
|
|
||
| const { instance: braze } = await initializeSpy.mock.results[0].value |
|
|
||
| await analytics.register(updateUserProfile, trackEvent) | ||
|
|
||
| const { instance: braze } = await initializeSpy.mock.results[0].value |
Summary
Fixes a bug in Braze Web Mode (Actions) where the "Only Track Known Users" setting (
deferUntilIdentified) still created permanent anonymous Braze profiles.The guard in the
ready()closure checkedanalytics.user().id(), which resolves from the persistedajs_user_idvalue in localStorage. That value survives across sessions, so the guard passed on page loads where a userId was cached but noidentify()fired in the current page load. Braze then raninitialize()+openSession()withoutchangeUser(), producing anonymous profiles (device ID, no external ID) that Braze can never merge and that still count against Braze MAU billing.Fix
Gate initialization on an identify actually observed in the current page load, not on a cached localStorage value:
updateUserProfile(subscribed toidentify) records the freshuserIdvia a newclient.setDeferredUser()before callingready().ready()gates on that in-session value and callschangeUser()beforeopenSession(), so the session is attributed to the known user.This matches the original intent of DEST-1710, the analogous fix in the classic (non-Actions) Braze destination.
Behavior
ajs_user_idin localStorage, no identify this load (Path A)updateUserProfile(Path B)changeUser()beforeopenSession()→ single attributed profile ✅deferUntilIdentifiedoffJira: STRATCONN-6967
Rollout note
Behavior only changes when the setting is ON, but since Braze is high-volume, reviewers may want to gate the rollout behind a feature flag per repo guidance for critical destinations.
Testing
Added two tests in
initialization.test.ts:analytics.user().id()does not open a session when no identify fires.changeUser()is invoked beforeopenSession()(asserted viainvocationCallOrder).Full braze suite: 6 suites / 24 tests pass. No new required fields; no signature or default changes — backward compatible.
Security Review
🤖 Generated with Claude Code