Skip to content

refactor: cookie transaction helper method#1569

Merged
daniel-graham-amplitude merged 33 commits intomainfrom
AMP-149016-cookie-transaction-helper-method
Mar 5, 2026
Merged

refactor: cookie transaction helper method#1569
daniel-graham-amplitude merged 33 commits intomainfrom
AMP-149016-cookie-transaction-helper-method

Conversation

@daniel-graham-amplitude
Copy link
Collaborator

@daniel-graham-amplitude daniel-graham-amplitude commented Mar 2, 2026

Summary

Additions to "cookie.ts"

  • adds 3 new methods "getRawSync", "getSync" and "setSync" to cookie do the exact same things as "getRaw", "get", and "set" except that they're done synchronously instead of returning a promise
  • add a function called transaction(keyName, cb) that applies a global lock on keyName and then runs a function callback cb. cb is run with an arg { get: () => {}, set: () => {} } that runs synchronous operations on the cookie with keyName --> this is so that operations that require multiple steps can block all other instances from operating on that cookie while it does it's work
// example usage
cont result = await cookieStorage.transaction('AMP_incrementer_cookie', (cookie) => {
  let val = cookie.get();
  val = val || 0;
  cookie.set(val);
  return true;
});
console.log(result); // true
  • Adds a new helper method "CookieStorage.isDomainWritable" that uses the "transaction" to check if a domain can be written to a cookie

Checklist

  • Does your PR title have the correct title format?
  • Does your PR have a breaking change?: No

Note

Medium Risk
Touches core cookie storage behavior and adds cross-tab synchronization via navigator.locks, which could impact persistence/identity in browsers with differing cookie/locks support; changes are mitigated by fallback paths and expanded tests.

Overview
Introduces a lightweight cookie “transaction” helper in CookieStorage that uses the Web Locks API (when available) plus new sync get/set/getRaw helpers to make multi-step cookie updates atomic per key.

Adds CookieStorage.isDomainWritable() for safely probing whether a given domain can be written to via cookies, and updates browser e2e/test-server fixtures to also stress-test concurrent getTopLevelDomain calls; associated unit tests are expanded and some mocks are tightened with explicit casting. Minor refactor in useBrowserConfig renames the computed cookie domain variable to defaultCookieDomain (no behavior change).

Written by Cursor Bugbot for commit 15d14a7. This will update automatically on new commits. Configure here.

@daniel-graham-amplitude daniel-graham-amplitude marked this pull request as draft March 2, 2026 22:00
@daniel-graham-amplitude daniel-graham-amplitude changed the title Amp 149016 cookie transaction helper method refactor: cookie transaction helper method Mar 2, 2026
@daniel-graham-amplitude daniel-graham-amplitude force-pushed the AMP-149016-cookie-transaction-helper-method branch from d68376f to bc237af Compare March 3, 2026 00:53
@daniel-graham-amplitude
Copy link
Collaborator Author

bugbot run

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Callback parameter shadows outer storage variable
    • Renamed the transaction callback parameter to storageSync so it no longer shadows the outer CookieStorage instance.
  • ✅ Fixed: Static isDomainWritable method has no production callers
    • Updated getTopLevelDomain in browser config to call CookieStorage.isDomainWritable, making the method used in production code.
  • ✅ Fixed: Fixed cookie key risks false positives in concurrent use
    • Changed isDomainWritable to use a UUID-suffixed cookie key per invocation, preventing cross-call cookie key collisions and false positives.

Create PR

Or push these changes by commenting:

@cursor push 4e11085af3
Preview (4e11085af3)
diff --git a/packages/analytics-browser/src/config.ts b/packages/analytics-browser/src/config.ts
--- a/packages/analytics-browser/src/config.ts
+++ b/packages/analytics-browser/src/config.ts
@@ -496,23 +496,13 @@
   const host = url ?? location.hostname;
   const parts = host.split('.');
   const levels = [];
-  const cookieKeyUniqueId = UUID();
-  const storageKey = `AMP_TLDTEST_${cookieKeyUniqueId.substring(0, 8)}`;
 
   for (let i = parts.length - 2; i >= 0; --i) {
     levels.push(parts.slice(i).join('.'));
   }
   for (let i = 0; i < levels.length; i++) {
     const domain = levels[i];
-    const options = {
-      domain: '.' + domain,
-      expirationDays: 0.003, // expire in ~5 minutes
-    };
-    const storage = new CookieStorage<number>(options);
-    await storage.set(storageKey, 1);
-    const value = await storage.get(storageKey);
-    if (value) {
-      await storage.remove(storageKey);
+    if (await CookieStorage.isDomainWritable(domain)) {
       return '.' + domain;
     }
   }

diff --git a/packages/analytics-browser/test/config.test.ts b/packages/analytics-browser/test/config.test.ts
--- a/packages/analytics-browser/test/config.test.ts
+++ b/packages/analytics-browser/test/config.test.ts
@@ -4,7 +4,6 @@
 import * as core from '@amplitude/analytics-core';
 import {
   LogLevel,
-  Storage,
   UserSession,
   MemoryStorage,
   getCookieName,
@@ -400,53 +399,23 @@
     });
 
     test('should return empty string if no access to cookies', async () => {
-      const testCookieStorage: Storage<number> = {
-        isEnabled: () => Promise.resolve(false),
-        get: jest.fn().mockResolvedValueOnce(Promise.resolve(1)),
-        getRaw: jest.fn().mockResolvedValueOnce(Promise.resolve(1)),
-        set: jest.fn().mockResolvedValueOnce(Promise.resolve(undefined)),
-        remove: jest.fn().mockResolvedValueOnce(Promise.resolve(undefined)),
-        reset: jest.fn().mockResolvedValueOnce(Promise.resolve(undefined)),
-      };
-      jest.spyOn(BrowserUtils, 'CookieStorage').mockReturnValueOnce({
-        ...testCookieStorage,
-        options: {},
-        config: {},
-      } as unknown as BrowserUtils.CookieStorage<unknown>);
+      const isEnabledSpy = jest.spyOn(BrowserUtils.CookieStorage.prototype, 'isEnabled').mockResolvedValueOnce(false);
       const domain = await Config.getTopLevelDomain();
+      isEnabledSpy.mockRestore();
       expect(domain).toBe('');
     });
 
     test('should return top level domain', async () => {
-      const testCookieStorage: Storage<number> = {
-        isEnabled: () => Promise.resolve(true),
-        get: jest.fn().mockResolvedValueOnce(Promise.resolve(1)),
-        getRaw: jest.fn().mockResolvedValueOnce(Promise.resolve(1)),
-        set: jest.fn().mockResolvedValueOnce(Promise.resolve(undefined)),
-        remove: jest.fn().mockResolvedValueOnce(Promise.resolve(undefined)),
-        reset: jest.fn().mockResolvedValueOnce(Promise.resolve(undefined)),
-      };
-      const actualCookieStorage: Storage<number> = {
-        isEnabled: () => Promise.resolve(true),
-        get: jest.fn().mockResolvedValueOnce(Promise.resolve(undefined)).mockResolvedValueOnce(Promise.resolve(1)),
-        getRaw: jest.fn().mockResolvedValueOnce(Promise.resolve(undefined)).mockResolvedValueOnce(Promise.resolve(1)),
-        set: jest.fn().mockResolvedValue(Promise.resolve(undefined)),
-        remove: jest.fn().mockResolvedValue(Promise.resolve(undefined)),
-        reset: jest.fn().mockResolvedValue(Promise.resolve(undefined)),
-      };
-      jest
-        .spyOn(BrowserUtils, 'CookieStorage')
-        .mockReturnValueOnce({
-          ...testCookieStorage,
-          options: {},
-          config: {},
-        } as unknown as BrowserUtils.CookieStorage<unknown>)
-        .mockReturnValue({
-          ...actualCookieStorage,
-          options: {},
-          config: {},
-        } as unknown as BrowserUtils.CookieStorage<unknown>);
+      const isEnabledSpy = jest.spyOn(BrowserUtils.CookieStorage.prototype, 'isEnabled').mockResolvedValueOnce(true);
+      const isDomainWritableSpy = jest
+        .spyOn(BrowserUtils.CookieStorage, 'isDomainWritable')
+        .mockResolvedValueOnce(false)
+        .mockResolvedValueOnce(true);
       expect(await Config.getTopLevelDomain('www.legislation.gov.uk')).toBe('.legislation.gov.uk');
+      expect(isDomainWritableSpy).toHaveBeenNthCalledWith(1, 'gov.uk');
+      expect(isDomainWritableSpy).toHaveBeenNthCalledWith(2, 'legislation.gov.uk');
+      isEnabledSpy.mockRestore();
+      isDomainWritableSpy.mockRestore();
     });
 
     test('should not throw an error when location is an empty object', async () => {

diff --git a/packages/analytics-core/src/storage/cookie.ts b/packages/analytics-core/src/storage/cookie.ts
--- a/packages/analytics-core/src/storage/cookie.ts
+++ b/packages/analytics-core/src/storage/cookie.ts
@@ -221,13 +221,14 @@
   static async isDomainWritable(domain: string): Promise<boolean> {
     const options = {
       domain: '.' + domain,
+      expirationDays: 0.003, // expire in ~5 minutes
     };
-    const storageKey = 'AMP_TLDTEST';
+    const storageKey = `AMP_TLDTEST_${UUID().substring(0, 8)}`;
     const storage = new CookieStorage<number>(options);
     try {
-      const res = await storage.transaction(storageKey, (storage) => {
-        storage.set(1);
-        return storage.get();
+      const res = await storage.transaction(storageKey, (storageSync) => {
+        storageSync.set(1);
+        return storageSync.get();
       });
       return !!res;
     } catch (error) {
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Copy link
Contributor

@Mercy811 Mercy811 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @daniel-graham-amplitude, LGTM!

@daniel-graham-amplitude daniel-graham-amplitude merged commit 7cf23aa into main Mar 5, 2026
11 checks passed
@daniel-graham-amplitude daniel-graham-amplitude deleted the AMP-149016-cookie-transaction-helper-method branch March 5, 2026 18:03
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