fix: anonymousId does not resync once localStorage and the shared cookie disagree - #1398
Merged
Merged
Conversation
…kie disagree localStorage is per-origin, but sits ahead of the shared, cross-subdomain cookie in the default store priority ([localStorage, cookie, memory]). Once the two disagree -- for any reason (cookie eviction, a consent tool clearing cookies pre-consent, etc.) -- whichever value is in localStorage wins forever on that origin, with no way for it to resync to the cookie. Each origin can then end up parroting a different anonymousId back into the shared cookie, causing it to visibly ping-pong between subdomains (see #706). Add UniversalStorage#getConsistent and use it for anonymousId: when the underlying stores disagree, the cookie's value wins and every store is resynced to it, instead of the divergence persisting indefinitely. When stores already agree, this returns the exact same value as before. This is split out from the tld()/CookieStorage reliability fixes (see the other PR) since it's the one change here with an actual, if narrowly scoped, behavior change: the tiebreak used when stores disagree on anonymousId.
🦋 Changeset detectedLatest commit: d3209cd The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
4 tasks
Codecov Report❌ Patch coverage is Please upload reports for the commit d3209cd to get more accurate results.
Additional details and impacted files@@ Coverage Diff @@
## master #1398 +/- ##
==========================================
- Coverage 91.59% 91.59% -0.01%
==========================================
Files 127 127
Lines 4142 4163 +21
Branches 1033 1040 +7
==========================================
+ Hits 3794 3813 +19
- Misses 348 350 +2
☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
UniversalStorage#getConsistent adds a few dozen bytes minified+gzipped, tripping the size-limit check (master sits at 29.78 KB against a 29.8 KB budget, so there was ~20 B of headroom before this). Bumped 29.8 KB -> 30.0 KB; confirmed locally (npx size-limit in packages/browser) that actual size is ~29.9 KB.
…al value A cookie that was blanked out rather than deleted (e.g. a third-party consent script running `document.cookie = 'ajs_anonymous_id=;path=/'` with no expiry, which sets an empty-valued cookie instead of removing it) was being read back as `''`, not `null` -- CookieStorage.get() only treats undefined/null as absent. getConsistent then saw that as a "real" disagreeing value and let it win, overwriting a legitimate anonymousId in localStorage with an empty string, and self-healing that empty string into every other store too. Treat an empty string the same as no value, same as undefined/null.
Same behavior (first present cookie value wins; otherwise the first present value in priority order), expressed as: read all stores safely, drop absent values (undefined/null/''), then prefer the first entry backed by a CookieStorage, falling back to the first entry overall.
MichaelGHSeg
previously approved these changes
Sep 11, 2026
didiergarcia
previously approved these changes
Sep 11, 2026
… flag Ships default-off so this reaches everyone on the next release without changing anyone's behavior, and can be staged in with specific customers before flipping the default in a later release. Enabling it is the only thing that switches anonymousId() from getAndSync to getConsistent.
abueide
dismissed stale reviews from didiergarcia and MichaelGHSeg
via
September 11, 2026 13:54
b5a43aa
Reproduces the sequence from the original #706 report against the actual built bundle, across two real (mocked) same-site subdomains -- clearing cookies + site data on one subdomain only, then checking whether the other subdomain's stale localStorage entry causes lasting divergence. Confirms both halves: without resolveAnonymousIdConflicts, the two subdomains diverge and stay diverged; with it enabled, they converge after one round trip and stay converged.
…L string The test's inlined ~40-line raw-HTML template literal made `file` misdetect the whole .ts file as "HTML document text", and confused at least one diff-rendering pipeline into flattening the surrounding TypeScript's indentation. Serve the package's existing standalone.html fixture from disk instead (same pattern standaloneMock already uses for the JS bundle) -- same test behavior, no embedded foreign-language content in the .ts file.
MichaelGHSeg
approved these changes
Sep 11, 2026
abueide
added a commit
that referenced
this pull request
Sep 11, 2026
## Summary Manual version bump, standing in for the `Changeset Release Creator` workflow — that workflow has had a **100% `startup_failure` rate on every trigger since 2026-07-15** (the last successful run was 2026-04-29), so no "Version Packages" PR has been auto-created since then. That's an existing infra issue independent of this change; someone with org Actions-policy access should look into why `release-creator.yml` fails at startup. This is exactly what that bot would have produced: ran `yarn update-versions-and-changelogs` against the two changesets currently pending on `master`: - #1398 (`resolveAnonymousIdConflicts` opt-in fix) - #1388 (is-email dependency bump, merged back in July, also never released because of the same broken bot) `@segment/analytics-next` bumps `1.84.1` -> `1.84.2`. Merging this (commit message starts with `Version Packages`) will make `publish.yml`'s `should-release` check pass on push to `master`, triggering the real npm publish + CDN deploy. ## Test plan - [x] Full `packages/browser` test suite passes (849 passed, 4 pre-existing skips) - [x] `tsc --noEmit` clean - [x] Verified `packages/browser/src/generated/version.ts` and `package.json` both read `1.84.2` - [x] Verified the generated `CHANGELOG.md` entry attributes both PRs correctly
6 tasks
abueide
added a commit
that referenced
this pull request
Sep 11, 2026
## Summary Follow-up to #1398 (released as `1.84.2`). That PR shipped `resolveAnonymousIdConflicts` as an opt-in, default-off flag -- a cautious first step taken before we'd confirmed how staged rollout actually works for this SDK. Since then we traced the real delivery path end-to-end (`ajs-renderer` -> `analytics.js-versions` -> Flagon gates): which build of analytics-next a given source's CDN bundle contains is already controlled per-source, via `v2projects` pins and the `ajs-renderer`/`v2rollout` Flagon gate. That makes an *application-level* opt-in redundant -- exposure to this fix is governed by which code a source receives, not by a flag inside that code. Keeping the flag just adds a second, unnecessary gate: a source could receive the fix and still see the bug because nobody flipped an option in their `load()` call. This PR removes `resolveAnonymousIdConflicts` and makes `UniversalStorage#getConsistent` the sole `anonymousId` resolution path -- no flag, no default to reason about. **Behavior change:** no longer opt-in. Only affects sources where the cookie and localStorage already disagree (the broken case); when they agree, behavior is unchanged. The flag only existed for one release, so there's no meaningful adoption window for anyone depending on its absence. ## Test plan - [x] Updated `User` unit tests to drop the flag parameter -- reconciliation is now the only path (`packages/browser/src/core/user/__tests__/index.test.ts`) - [x] Updated the Playwright e2e reproduction of #706 to drop the flag and assert convergence unconditionally (`packages/browser-integration-tests/src/anonymous-id-subdomain-sync.test.ts`) -- verified locally against a fresh build, including a full instrumented state-trace confirming the cookie wins the disagreement and resyncs localStorage as expected - [x] Full `packages/browser` unit suite passes (unrelated pre-existing flake in `ajs-destination`/Amplitude tests, confirmed by rerun) - [x] Full `browser-integration-tests` e2e suite passes - [x] `tsc --noEmit` and `eslint` clean on all touched files - [x] Bundle size actually decreased slightly (~29.96 kB, under the existing 30.1 kB limit) from removing the flag branch
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Split out from #1397, which addresses one source of the underlying
anonymousId/userIdcross-subdomain divergence issue (see #706). This PR addresses the actual store-reconciliation gap.localStorageis per-origin, but sits ahead of the shared, cross-subdomain cookie in the default store priority ([localStorage, cookie, memory]). Once the two disagree -- for any reason (cookie eviction, a consent tool clearing cookies pre-consent, a pasttld()failure, a user clearing cookies/site data on just one subdomain, etc.) -- whichever value is inlocalStoragewins forever on that origin, with no way for it to resync to the cookie. Each origin can then end up parroting a differentanonymousIdback into the shared cookie, causing it to visibly ping-pong between subdomains.UniversalStorage#getConsistent: when the underlying stores disagree, the cookie's value wins and every store is resynced to it, instead of the divergence persisting indefinitely. When stores already agree (the common case), this returns the exact same valuegetAndSyncwould.document.cookie = 'ajs_anonymous_id=;path=/'with no expiry) can't win a disagreement and wipe out a real id elsewhere.resolveAnonymousIdConflictsoption (user: { resolveAnonymousIdConflicts: true }in load options), off by default.anonymousId()only switches fromgetAndSynctogetConsistentwhen it's enabled. This ships in a release without changing anyone's behavior; it can be staged in with specific customers/accounts before considering flipping the default in a later release. (There's no infra-level staged-rollout mechanism in this repo to hook into for a true percentage rollout -- this opt-in flag is the equivalent we can offer at the application-config level.)A deterministic reproduction (from #706's own report)
The original reporter on #706 already described a concrete, deterministic trigger, independent of any probe flakiness:
Domaincovers both subdomains, so it's visible/clearable from A) and subdomain A's own localStorage entry (localStorage is origin-scoped, so this doesn't touch B).NEW) is generated and written to both.OLD), the cookie now hasNEW. localStorage outranks the cookie in priority order, so B returnsOLD-- andgetAndSyncwrites that back to every store, overwriting the shared cookie withOLD.NEW, the cookie now hasOLD(just clobbered in step 4) -- A's localStorage wins, returnsNEW, overwrites the cookie back toNEW.Steps 4-5 repeat indefinitely -- exactly the reporter's "the cookie value for the TLD seemingly switching between these values as you navigate between the pages," with no probe failure or flakiness required.
With
resolveAnonymousIdConflictsenabled: the next visit to either subdomain reads both stores, sees they disagree, and takes the cookie's value -- resyncing the losing localStorage entry to match. Both subdomains converge on the same id after one round trip and stay converged; the ping-pong stops for good, regardless of which id happened to win.Test plan
UniversalStorage#getConsistent, including the empty-string edge case (packages/browser/src/core/storage/__tests__/universalStorage.test.ts)Usertests confirming the flag defaults off (unchanged behavior) and confirming the reported drift scenario self-heals when enabled (packages/browser/src/core/user/__tests__/index.test.ts)packages/browser-integration-tests/src/anonymous-id-subdomain-sync.test.ts)packages/browsertest suite passestsc --noEmitandeslintclean on all touched files