Skip to content

fix: anonymousId does not resync once localStorage and the shared cookie disagree - #1398

Merged
abueide merged 8 commits into
masterfrom
fix/anonymous-id-store-reconciliation
Sep 11, 2026
Merged

abueide merged 8 commits into
masterfrom
fix/anonymous-id-store-reconciliation

Conversation

@abueide

@abueide abueide commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Split out from #1397, which addresses one source of the underlying anonymousId/userId cross-subdomain divergence issue (see #706). This PR addresses the actual store-reconciliation gap.

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, a past tld() failure, a user clearing cookies/site data on just one subdomain, 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.

  • Added 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 value getAndSync would.
  • An empty string is treated the same as no value, so a cookie that was blanked out rather than deleted (e.g. a third-party script running document.cookie = 'ajs_anonymous_id=;path=/' with no expiry) can't win a disagreement and wipe out a real id elsewhere.
  • Gated behind a new opt-in resolveAnonymousIdConflicts option (user: { resolveAnonymousIdConflicts: true } in load options), off by default. anonymousId() only switches from getAndSync to getConsistent when 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:

  1. User is on subdomain A. They clear cookies + site data for that origin. This deletes the shared cookie entirely (its Domain covers 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).
  2. Subdomain B was never visited during this -- its localStorage still holds the old id, untouched.
  3. Back on A: both localStorage(A) and the cookie are empty, so a fresh id (NEW) is generated and written to both.
  4. User navigates to B: localStorage(B) still has the old id (OLD), the cookie now has NEW. localStorage outranks the cookie in priority order, so B returns OLD -- and getAndSync writes that back to every store, overwriting the shared cookie with OLD.
  5. User navigates back to A: localStorage(A) has NEW, the cookie now has OLD (just clobbered in step 4) -- A's localStorage wins, returns NEW, overwrites the cookie back to NEW.

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 resolveAnonymousIdConflicts enabled: 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

  • Added unit tests for UniversalStorage#getConsistent, including the empty-string edge case (packages/browser/src/core/storage/__tests__/universalStorage.test.ts)
  • Added User tests 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)
  • Added a Playwright e2e test reproducing the exact Cross-domain tracking and clearing of LocalStorage #706 sequence across two real (mocked) same-site subdomains, confirming both the bug (without the flag) and the fix (with it) (packages/browser-integration-tests/src/anonymous-id-subdomain-sync.test.ts)
  • Full packages/browser test suite passes
  • tsc --noEmit and eslint clean on all touched files

…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-bot

changeset-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d3209cd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@segment/analytics-next Patch

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

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.66667% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.59%. Comparing base (0c4fa88) to head (3679ef5).

⚠️ Current head 3679ef5 differs from pull request most recent head d3209cd

Please upload reports for the commit d3209cd to get more accurate results.

Files with missing lines Patch % Lines
...kages/browser/src/core/storage/universalStorage.ts 88.23% 2 Missing ⚠️
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     
Flag Coverage Δ
browser 92.50% <91.66%> (-0.02%) ⬇️
core 90.07% <ø> (ø)
node 89.43% <ø> (ø)

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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
MichaelGHSeg previously approved these changes Sep 11, 2026
didiergarcia
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
abueide dismissed stale reviews from didiergarcia and MichaelGHSeg via b5a43aa September 11, 2026 13:54
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.
@abueide
abueide merged commit e7592fb into master Sep 11, 2026
35 of 37 checks passed
@abueide
abueide deleted the fix/anonymous-id-store-reconciliation branch September 11, 2026 15:36
@abueide abueide mentioned this pull request Sep 11, 2026
4 tasks
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants