Skip to content

fix: anonymousId/userId cookies reverting to host-only scope - #1397

Open
abueide wants to merge 3 commits into
masterfrom
fix/anonymous-id-cross-subdomain-persistence
Open

abueide wants to merge 3 commits into
masterfrom
fix/anonymous-id-cross-subdomain-persistence

Conversation

@abueide

@abueide abueide commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Part of investigating a support ticket where a customer's anonymousId intermittently changed when moving between subdomains of the same site, with no reset() call involved. That's the same underlying issue as #706 (open since 2023). This PR addresses one contributing cause; a separate, more invasive fix for the actual store-reconciliation behavior is split into #1398 for independent review.

tld() resolves the shared, cross-subdomain cookie domain via a live cookie set/get/remove round-trip, and analytics-next constructs multiple CookieStorage instances per page load (identity + legacy stores, x2 for Group). Each one was re-running that probe from scratch, so a single transient failure (extension interference, cookie-jar pressure, timing) could silently downgrade just that instance to a host-only cookie while the others kept the shared, cross-subdomain one -- a plausible source of the "intermittent, hard to reproduce" nature of these reports.

  • tld() now memoizes the resolved domain per hostname, so the probe only runs once per page load instead of once per store instance.
  • tld() now warns instead of silently falling back to a host-only cookie when the probe genuinely fails (as opposed to the expected undefined for localhost/IP hosts). This failure mode was previously invisible.
  • CookieStorage.remove() / .set(key, null) now also clear a possible host-only duplicate. If a tld() failure ever did leave a stray host-scoped cookie behind, nothing -- not even reset() -- could clean it up, since removal only ever targeted the currently-configured domain.

No behavior change on any currently-working path -- these only do anything in a scenario that was already broken. This alone reduces how often the underlying divergence occurs, but doesn't resync stores that have already diverged for other reasons (cookie eviction, a consent tool clearing cookies pre-consent, etc.) -- that's what #1398 adds.

Test plan

  • Added unit tests for tld() memoization and the new warning (packages/browser/src/core/user/__tests__/tld.test.ts)
  • Added unit tests for the CookieStorage cleanup fallback (packages/browser/src/core/storage/__tests__/cookieStorage.test.ts)
  • Full packages/browser test suite passes (847 passed, 4 pre-existing skips)
  • tsc --noEmit and eslint clean on all touched files

@changeset-bot

changeset-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a23f595

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

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.67%. Comparing base (0c4fa88) to head (5782d0c).

⚠️ Current head 5782d0c differs from pull request most recent head a23f595

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

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1397      +/-   ##
==========================================
+ Coverage   91.59%   91.67%   +0.07%     
==========================================
  Files         127      127              
  Lines        4142     4155      +13     
  Branches     1033     1036       +3     
==========================================
+ Hits         3794     3809      +15     
+ Misses        348      346       -2     
Flag Coverage Δ
browser 92.62% <100.00%> (+0.10%) ⬆️
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.

tld() resolving the shared, cross-subdomain cookie domain requires a live
cookie set/get/remove round-trip, and multiple CookieStorage instances get
constructed per page load (identity + legacy stores, x2 for Group). Each one
was re-running that probe from scratch, so a single transient failure
(extension interference, cookie-jar pressure, timing) could silently
downgrade just that instance to a host-only cookie while others kept the
shared one -- a plausible source of user reports where anonymousId/userId
intermittently loses cross-subdomain persistence (see #706).

- memoize tld()'s resolved domain per hostname instead of re-probing cookies
  on every CookieStorage instantiation
- warn instead of silently falling back to a host-only cookie when tld()
  fails to resolve a domain (as opposed to the expected undefined result for
  localhost/IP hosts)
- let CookieStorage.remove()/set(key, null) also clean up a stray host-only
  duplicate cookie, which was previously unreachable even via reset()

No behavior change on any currently-working path; these only do anything in
a scenario that was already broken.
@abueide
abueide force-pushed the fix/anonymous-id-cross-subdomain-persistence branch from f73db75 to 316335d Compare September 10, 2026 15:36
@abueide abueide changed the title fix: anonymousId drifting between subdomains of the same site fix: anonymousId/userId cookies reverting to host-only scope Sep 10, 2026
The memoization cache, warning, and CookieStorage cleanup fallback add 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.95 KB.
abueide added a commit that referenced this pull request Sep 11, 2026
…kie disagree (#1398)

## 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

- [x] Added unit tests for `UniversalStorage#getConsistent`, including
the empty-string edge case
(`packages/browser/src/core/storage/__tests__/universalStorage.test.ts`)
- [x] 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`)
- [x] Added a Playwright e2e test reproducing the exact #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`)
- [x] Full `packages/browser` test suite passes
- [x] `tsc --noEmit` and `eslint` clean on all touched files
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.

1 participant