From 9998cf04d551784789e46add48433f60f016892c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 1 Mar 2026 14:47:06 +0100 Subject: [PATCH 1/3] Move assignment cache from localStorage to IndexedDB, scoped by clientToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the localStorage-based assignment cache (exposure deduplication) with IndexedDB, consistent with the flags cache. Storage is now scoped by clientToken via buildStorageKeySuffix(), so different apps on the same origin no longer share state. - New IndexedDBAssignmentCache: same dd-flagging DB / configurations store, in-memory Map mirror for fast synchronous set(), fire-and-forget persistence to IndexedDB. - Simplified factory: IndexedDB → memory-only (Chrome storage and localStorage dropped). - Updated provider to pass clientToken instead of hardcoded key. --- .../src/cache/assignment-cache-factory.ts | 26 ++-- .../cache/chrome-storage-assignment-cache.ts | 40 ------ .../src/cache/chrome-storage-async-map.ts | 28 ---- packages/browser/src/cache/helpers.ts | 8 -- .../src/cache/indexeddb-assignment-cache.ts | 120 ++++++++++++++++ packages/browser/src/openfeature/provider.ts | 5 +- .../cache/assignment-cache-factory.spec.ts | 84 ++++------- .../cache/hybrid-assignment-cache.spec.ts | 70 ++++----- .../cache/indexeddb-assignment-cache.spec.ts | 136 ++++++++++++++++++ .../test/openfeature/exposures.spec.ts | 10 +- 10 files changed, 330 insertions(+), 197 deletions(-) delete mode 100644 packages/browser/src/cache/chrome-storage-assignment-cache.ts delete mode 100644 packages/browser/src/cache/chrome-storage-async-map.ts create mode 100644 packages/browser/src/cache/indexeddb-assignment-cache.ts create mode 100644 packages/browser/test/cache/indexeddb-assignment-cache.spec.ts diff --git a/packages/browser/src/cache/assignment-cache-factory.ts b/packages/browser/src/cache/assignment-cache-factory.ts index daa9e02c..add7ecb7 100644 --- a/packages/browser/src/cache/assignment-cache-factory.ts +++ b/packages/browser/src/cache/assignment-cache-factory.ts @@ -1,18 +1,15 @@ import type { AssignmentCache } from '@datadog/flagging-core' -import ChromeStorageAssignmentCache from './chrome-storage-assignment-cache' -import { hasWindowLocalStorage } from './helpers' +import { hasIndexedDB } from './helpers' import HybridAssignmentCache from './hybrid-assignment-cache' -import { LocalStorageAssignmentCache } from './local-storage-assignment-cache' +import { IndexedDBAssignmentCache } from './indexeddb-assignment-cache' import SimpleAssignmentCache from './simple-assignment-cache' export function assignmentCacheFactory({ forceMemoryOnly = false, - chromeStorage, - storageKeySuffix, + clientToken, }: { forceMemoryOnly?: boolean - storageKeySuffix: string - chromeStorage?: chrome.storage.StorageArea + clientToken: string }): AssignmentCache { const simpleCache = new SimpleAssignmentCache() @@ -20,15 +17,10 @@ export function assignmentCacheFactory({ return simpleCache } - if (chromeStorage) { - const chromeStorageCache = new ChromeStorageAssignmentCache(chromeStorage) - return new HybridAssignmentCache(simpleCache, chromeStorageCache) - } else { - if (hasWindowLocalStorage()) { - const localStorageCache = new LocalStorageAssignmentCache(storageKeySuffix) - return new HybridAssignmentCache(simpleCache, localStorageCache) - } else { - return simpleCache - } + if (hasIndexedDB()) { + const indexedDBCache = new IndexedDBAssignmentCache(clientToken) + return new HybridAssignmentCache(simpleCache, indexedDBCache) } + + return simpleCache } diff --git a/packages/browser/src/cache/chrome-storage-assignment-cache.ts b/packages/browser/src/cache/chrome-storage-assignment-cache.ts deleted file mode 100644 index a370c6f1..00000000 --- a/packages/browser/src/cache/chrome-storage-assignment-cache.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - type AssignmentCacheEntry, - assignmentCacheKeyToString, - assignmentCacheValueToString, -} from '@datadog/flagging-core' - -import ChromeStorageAsyncMap from './chrome-storage-async-map' -import type { BulkReadAssignmentCache } from './hybrid-assignment-cache' - -export default class ChromeStorageAssignmentCache implements BulkReadAssignmentCache { - private readonly storage: ChromeStorageAsyncMap - - constructor(chromeStorage: chrome.storage.StorageArea) { - this.storage = new ChromeStorageAsyncMap(chromeStorage) - } - - init(): Promise { - return Promise.resolve() - } - - set(entry: AssignmentCacheEntry): void { - // "fire-and-forget" - we intentionally don't wait for the promise to resolve - // noinspection JSIgnoredPromiseFromCall - this.storage.set(assignmentCacheKeyToString(entry), assignmentCacheValueToString(entry)) - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - has(_entry: AssignmentCacheEntry): boolean { - throw new Error('This should never be called for ChromeStorageAssignmentCache, use getEntries() instead.') - } - - async getEntries(): Promise<[string, string][]> { - const entries = await this.storage.entries() - return Object.entries(entries).map(([key, value]) => [key, value] as [string, string]) - } - - async clear(): Promise { - await this.storage.clear() - } -} diff --git a/packages/browser/src/cache/chrome-storage-async-map.ts b/packages/browser/src/cache/chrome-storage-async-map.ts deleted file mode 100644 index 83a580e3..00000000 --- a/packages/browser/src/cache/chrome-storage-async-map.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { AsyncMap } from '@datadog/flagging-core' - -/** Chrome storage-backed {@link AsyncMap}. */ -export default class ChromeStorageAsyncMap implements AsyncMap { - constructor(private readonly storage: chrome.storage.StorageArea) {} - - async has(key: string): Promise { - const value = await this.get(key) - return !!value - } - - async get(key: string): Promise { - const subset = await this.storage.get(key) - return subset?.[key] ?? undefined - } - - async entries(): Promise<{ [p: string]: T }> { - return await this.storage.get(null) - } - - async set(key: string, value: T) { - await this.storage.set({ [key]: value }) - } - - async clear() { - await this.storage.clear() - } -} diff --git a/packages/browser/src/cache/helpers.ts b/packages/browser/src/cache/helpers.ts index 48ba5d0c..8e2e1442 100644 --- a/packages/browser/src/cache/helpers.ts +++ b/packages/browser/src/cache/helpers.ts @@ -1,11 +1,3 @@ -export function hasChromeStorage(): boolean { - return typeof chrome !== 'undefined' && !!chrome.storage -} - -export function chromeStorageIfAvailable(): chrome.storage.StorageArea | undefined { - return hasChromeStorage() ? chrome.storage.local : undefined -} - /** Returns whether `window.localStorage` is available */ export function hasWindowLocalStorage(): boolean { try { diff --git a/packages/browser/src/cache/indexeddb-assignment-cache.ts b/packages/browser/src/cache/indexeddb-assignment-cache.ts new file mode 100644 index 00000000..4203faa0 --- /dev/null +++ b/packages/browser/src/cache/indexeddb-assignment-cache.ts @@ -0,0 +1,120 @@ +import { + type AssignmentCacheEntry, + assignmentCacheKeyToString, + assignmentCacheValueToString, + buildStorageKeySuffix, +} from '@datadog/flagging-core' + +import type { BulkReadAssignmentCache } from './hybrid-assignment-cache' + +const DB_NAME = 'dd-flagging' +const DB_VERSION = 1 +const STORE_NAME = 'configurations' + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME) + } + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) +} + +export class IndexedDBAssignmentCache implements BulkReadAssignmentCache { + private readonly storageKey: string + private readonly mirror: Map = new Map() + + constructor(clientToken: string) { + this.storageKey = `assignments-${buildStorageKeySuffix(clientToken)}` + } + + /** No-op — IndexedDB entries are loaded lazily via getEntries(). */ + init(): Promise { + return Promise.resolve() + } + + /** Fire-and-forget persist to IndexedDB. Never blocks the caller, never throws. */ + set(entry: AssignmentCacheEntry): void { + const key = assignmentCacheKeyToString(entry) + const value = assignmentCacheValueToString(entry) + this.mirror.set(key, value) + this.persist() + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + has(_entry: AssignmentCacheEntry): boolean { + throw new Error('This should never be called for IndexedDBAssignmentCache, use getEntries() instead.') + } + + /** Read all persisted entries. Returns [] on any error — never throws. */ + async getEntries(): Promise<[string, string][]> { + try { + const db = await openDB() + try { + const entries = await new Promise<[string, string][] | undefined>((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly') + const store = tx.objectStore(STORE_NAME) + const request = store.get(this.storageKey) + request.onsuccess = () => resolve(request.result as [string, string][] | undefined) + request.onerror = () => reject(request.error) + }) + if (Array.isArray(entries)) { + this.mirror.clear() + for (const [k, v] of entries) { + this.mirror.set(k, v) + } + return entries + } + } finally { + db.close() + } + } catch { + // Silently fail — persistence should never break the SDK + } + return [] + } + + /** Remove persisted entries. Never throws. */ + async clear(): Promise { + this.mirror.clear() + try { + const db = await openDB() + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + store.delete(this.storageKey) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + tx.onabort = () => reject(tx.error) + }) + } finally { + db.close() + } + } catch { + // Silently fail + } + } + + /** Fire-and-forget write to IndexedDB. Resolves on tx.oncomplete; silently swallows errors. */ + private persist(): void { + const entries = Array.from(this.mirror.entries()) + openDB() + .then((db) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + store.put(entries, this.storageKey) + tx.oncomplete = () => db.close() + tx.onerror = () => db.close() + tx.onabort = () => db.close() + }) + .catch(() => { + // Silently fail + }) + } +} diff --git a/packages/browser/src/openfeature/provider.ts b/packages/browser/src/openfeature/provider.ts index 50233814..71745282 100644 --- a/packages/browser/src/openfeature/provider.ts +++ b/packages/browser/src/openfeature/provider.ts @@ -16,7 +16,7 @@ import { ProviderStatus, } from '@openfeature/web-sdk' import { assignmentCacheFactory } from '../cache/assignment-cache-factory' -import { chromeStorageIfAvailable, hasIndexedDB } from '../cache/helpers' +import { hasIndexedDB } from '../cache/helpers' import { IndexedDBFlagsCache } from '../cache/indexeddb-flags-cache' import { type FlaggingConfiguration, @@ -73,8 +73,7 @@ export class DatadogProvider implements Provider { const isExposureLoggingEnabled = options.enableExposureLogging ?? true if (isExposureLoggingEnabled && this.configuration) { this.exposureCache = assignmentCacheFactory({ - chromeStorage: chromeStorageIfAvailable(), - storageKeySuffix: 'dd-of-browser', + clientToken: options.clientToken, }) this.hooks.push(createExposureLoggingHook(this.configuration, this.exposureCache)) } diff --git a/packages/browser/test/cache/assignment-cache-factory.spec.ts b/packages/browser/test/cache/assignment-cache-factory.spec.ts index 193adb6a..fa678f86 100644 --- a/packages/browser/test/cache/assignment-cache-factory.spec.ts +++ b/packages/browser/test/cache/assignment-cache-factory.spec.ts @@ -1,76 +1,44 @@ +// structuredClone is required by fake-indexeddb but not available in jsdom +if (typeof globalThis.structuredClone === 'undefined') { + globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) +} +import 'fake-indexeddb/auto' + /** * @jest-environment jsdom */ -import type { ExposureEvent } from '../../../core/src/configuration/exposureEvent.types' import { assignmentCacheFactory } from '../../src/cache/assignment-cache-factory' import HybridAssignmentCache from '../../src/cache/hybrid-assignment-cache' - -import StorageArea = chrome.storage.StorageArea +import SimpleAssignmentCache from '../../src/cache/simple-assignment-cache' describe('AssignmentCacheFactory', () => { - // TODO: Extract test-only function for this - const fakeStore: { [k: string]: string } = {} - - const get = jest.fn((key?: string) => { - return new Promise((resolve) => { - if (!key) { - resolve(fakeStore) - } else { - resolve({ [key]: fakeStore[key] }) - } - }) - }) as jest.Mock - - const set = jest.fn((items: { [key: string]: string }) => { - return new Promise((resolve) => { - Object.assign(fakeStore, items) - resolve(undefined) - }) - }) as jest.Mock - - const mockChromeStorage = { get, set } as unknown as StorageArea - - beforeEach(() => { - window.localStorage.clear() - Object.keys(fakeStore).forEach((key) => { - delete fakeStore[key] - }) - }) - - it('should create a hybrid cache if chrome storage is available', () => { + it('should create a hybrid cache with IndexedDB when IndexedDB is available', () => { const cache = assignmentCacheFactory({ - chromeStorage: mockChromeStorage, - storageKeySuffix: 'foo', + clientToken: 'test-token', }) expect(cache).toBeInstanceOf(HybridAssignmentCache) - expect(Object.keys(fakeStore)).toHaveLength(0) - const exposureEvent: ExposureEvent = { - subject: { id: 'foo', attributes: {} }, - flag: { key: 'bar' }, - allocation: { key: 'baz' }, - variant: { key: 'qux' }, + }) + + it('should create a simple cache when IndexedDB is unavailable', () => { + const originalIndexedDB = globalThis.indexedDB + // @ts-expect-error — simulating unavailable IndexedDB + delete globalThis.indexedDB + try { + const cache = assignmentCacheFactory({ + clientToken: 'test-token', + }) + expect(cache).toBeInstanceOf(SimpleAssignmentCache) + } finally { + globalThis.indexedDB = originalIndexedDB } - cache.set(exposureEvent) - expect(Object.keys(fakeStore)).toHaveLength(1) }) - it('should create a hybrid cache if local storage is available', () => { + it('should create a simple cache when forceMemoryOnly is true', () => { const cache = assignmentCacheFactory({ - storageKeySuffix: 'foo', + clientToken: 'test-token', + forceMemoryOnly: true, }) - expect(cache).toBeInstanceOf(HybridAssignmentCache) - expect(localStorage.length).toEqual(0) - const exposureEvent: ExposureEvent = { - subject: { id: 'foo', attributes: {} }, - flag: { key: 'bar' }, - allocation: { key: 'baz' }, - variant: { key: 'qux' }, - } - cache.set(exposureEvent) - // chrome storage is not being used - expect(Object.keys(fakeStore)).toHaveLength(0) - // local storage is being used - expect(localStorage.length).toEqual(1) + expect(cache).toBeInstanceOf(SimpleAssignmentCache) }) }) diff --git a/packages/browser/test/cache/hybrid-assignment-cache.spec.ts b/packages/browser/test/cache/hybrid-assignment-cache.spec.ts index aaa6c4b6..8d04708c 100644 --- a/packages/browser/test/cache/hybrid-assignment-cache.spec.ts +++ b/packages/browser/test/cache/hybrid-assignment-cache.spec.ts @@ -1,41 +1,24 @@ -/** - * @jest-environment jsdom - */ - +// structuredClone is required by fake-indexeddb but not available in jsdom +if (typeof globalThis.structuredClone === 'undefined') { + globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) +} +import 'fake-indexeddb/auto' +import { IDBFactory } from 'fake-indexeddb' import type { ExposureEvent } from '../../../core/src/configuration/exposureEvent.types' -import ChromeStorageAssignmentCache from '../../src/cache/chrome-storage-assignment-cache' import HybridAssignmentCache from '../../src/cache/hybrid-assignment-cache' -import { LocalStorageAssignmentCache } from '../../src/cache/local-storage-assignment-cache' - -import StorageArea = chrome.storage.StorageArea +import { IndexedDBAssignmentCache } from '../../src/cache/indexeddb-assignment-cache' +import SimpleAssignmentCache from '../../src/cache/simple-assignment-cache' describe('HybridStorageAssignmentCache', () => { - const fakeStore: Record = {} - - const get = jest.fn((key?: string) => { - return new Promise((resolve) => { - if (!key) { - resolve(fakeStore) - } else { - resolve({ [key]: fakeStore[key] }) - } - }) - }) as jest.Mock - - const set = jest.fn((items: { [key: string]: string }) => { - return new Promise((resolve) => { - Object.assign(fakeStore, items) - resolve(undefined) - }) - }) as jest.Mock - - const mockChromeStorage = { get, set } as unknown as StorageArea - const chromeStorageCache = new ChromeStorageAssignmentCache(mockChromeStorage) - const localStorageCache = new LocalStorageAssignmentCache('test') - const hybridCache = new HybridAssignmentCache(localStorageCache, chromeStorageCache) + let servingCache: SimpleAssignmentCache + let persistentCache: IndexedDBAssignmentCache + let hybridCache: HybridAssignmentCache beforeEach(() => { - window.localStorage.clear() + globalThis.indexedDB = new IDBFactory() + servingCache = new SimpleAssignmentCache() + persistentCache = new IndexedDBAssignmentCache('test-token') + hybridCache = new HybridAssignmentCache(servingCache, persistentCache) }) it('has should return false if cache is empty', async () => { @@ -58,13 +41,13 @@ describe('HybridStorageAssignmentCache', () => { } await hybridCache.init() expect(hybridCache.has(exposureEvent)).toBeFalsy() - expect(localStorageCache.has(exposureEvent)).toBeFalsy() + expect(servingCache.has(exposureEvent)).toBeFalsy() hybridCache.set(exposureEvent) expect(hybridCache.has(exposureEvent)).toBeTruthy() - expect(localStorageCache.has(exposureEvent)).toBeTruthy() + expect(servingCache.has(exposureEvent)).toBeTruthy() }) - it('should populate localStorageCache from chromeStorageCache', async () => { + it('should populate serving cache from persistent cache on init', async () => { const exposureEvent1: ExposureEvent = { subject: { id: 'subject-1', attributes: {} }, flag: { key: 'flag-1' }, @@ -83,12 +66,17 @@ describe('HybridStorageAssignmentCache', () => { allocation: { key: 'foo' }, variant: { key: 'control' }, } - expect(localStorageCache.has(exposureEvent1)).toBeFalsy() - chromeStorageCache.set(exposureEvent1) - chromeStorageCache.set(exposureEvent2) + + // Write entries directly to the persistent store + persistentCache.set(exposureEvent1) + persistentCache.set(exposureEvent2) + // Allow fire-and-forget persist to complete + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Init should hydrate the serving cache from the persistent store await hybridCache.init() - expect(localStorageCache.has(exposureEvent1)).toBeTruthy() - expect(localStorageCache.has(exposureEvent2)).toBeTruthy() - expect(localStorageCache.has(exposureEvent3)).toBeFalsy() + expect(servingCache.has(exposureEvent1)).toBeTruthy() + expect(servingCache.has(exposureEvent2)).toBeTruthy() + expect(servingCache.has(exposureEvent3)).toBeFalsy() }) }) diff --git a/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts new file mode 100644 index 00000000..dc303cdf --- /dev/null +++ b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts @@ -0,0 +1,136 @@ +// structuredClone is required by fake-indexeddb but not available in jsdom +if (typeof globalThis.structuredClone === 'undefined') { + globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) +} +import 'fake-indexeddb/auto' +import { IDBFactory } from 'fake-indexeddb' +import type { ExposureEvent } from '../../../core/src/configuration/exposureEvent.types' +import { IndexedDBAssignmentCache } from '../../src/cache/indexeddb-assignment-cache' + +const exposureA: ExposureEvent = { + subject: { id: 'user-1', attributes: {} }, + flag: { key: 'flag-1' }, + allocation: { key: 'alloc-1' }, + variant: { key: 'var-1' }, +} + +const exposureB: ExposureEvent = { + subject: { id: 'user-2', attributes: {} }, + flag: { key: 'flag-2' }, + allocation: { key: 'alloc-2' }, + variant: { key: 'var-2' }, +} + +describe('IndexedDBAssignmentCache', () => { + let cache: IndexedDBAssignmentCache + + beforeEach(() => { + globalThis.indexedDB = new IDBFactory() + cache = new IndexedDBAssignmentCache('test-client-token') + }) + + describe('round-trip set/getEntries', () => { + it('should persist entries and retrieve them', async () => { + cache.set(exposureA) + // Allow fire-and-forget persist to complete + await flushAsync() + + const entries = await cache.getEntries() + expect(entries).toHaveLength(1) + expect(entries[0]).toHaveLength(2) + expect(typeof entries[0][0]).toBe('string') + expect(typeof entries[0][1]).toBe('string') + }) + + it('should persist multiple entries', async () => { + cache.set(exposureA) + cache.set(exposureB) + await flushAsync() + + const entries = await cache.getEntries() + expect(entries).toHaveLength(2) + }) + + it('should return empty array when no entries exist', async () => { + const entries = await cache.getEntries() + expect(entries).toEqual([]) + }) + }) + + describe('client token isolation', () => { + it('should not share data between caches with different client tokens', async () => { + const cacheA = new IndexedDBAssignmentCache('token-aaa') + const cacheB = new IndexedDBAssignmentCache('token-bbb') + + cacheA.set(exposureA) + await flushAsync() + + const entriesA = await cacheA.getEntries() + const entriesB = await cacheB.getEntries() + + expect(entriesA).toHaveLength(1) + expect(entriesB).toEqual([]) + }) + }) + + describe('graceful failure when IndexedDB unavailable', () => { + it('getEntries should return empty array', async () => { + const originalIndexedDB = globalThis.indexedDB + // @ts-expect-error — simulating unavailable IndexedDB + delete globalThis.indexedDB + try { + const entries = await cache.getEntries() + expect(entries).toEqual([]) + } finally { + globalThis.indexedDB = originalIndexedDB + } + }) + + it('set should not throw', () => { + const originalIndexedDB = globalThis.indexedDB + // @ts-expect-error — simulating unavailable IndexedDB + delete globalThis.indexedDB + try { + expect(() => cache.set(exposureA)).not.toThrow() + } finally { + globalThis.indexedDB = originalIndexedDB + } + }) + + it('clear should not throw', async () => { + const originalIndexedDB = globalThis.indexedDB + // @ts-expect-error — simulating unavailable IndexedDB + delete globalThis.indexedDB + try { + await expect(cache.clear()).resolves.toBeUndefined() + } finally { + globalThis.indexedDB = originalIndexedDB + } + }) + }) + + describe('has', () => { + it('should throw because the serving store handles this', () => { + expect(() => cache.has(exposureA)).toThrow() + }) + }) + + describe('clear', () => { + it('should remove all entries', async () => { + cache.set(exposureA) + await flushAsync() + expect(await cache.getEntries()).toHaveLength(1) + + await cache.clear() + expect(await cache.getEntries()).toEqual([]) + }) + + it('should not throw when DB is already empty', async () => { + await expect(cache.clear()).resolves.toBeUndefined() + }) + }) +}) + +function flushAsync(): Promise { + return new Promise((resolve) => setTimeout(resolve, 50)) +} diff --git a/packages/browser/test/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts index 962b93c2..27f59cf2 100644 --- a/packages/browser/test/openfeature/exposures.spec.ts +++ b/packages/browser/test/openfeature/exposures.spec.ts @@ -1,3 +1,8 @@ +// structuredClone is required by fake-indexeddb but not available in jsdom +if (typeof globalThis.structuredClone === 'undefined') { + globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) +} +import 'fake-indexeddb/auto' import { INTAKE_SITE_STAGING } from '@datadog/browser-core' import { OpenFeature } from '@openfeature/web-sdk' import type { FlaggingInitConfiguration } from '../../src/domain/configuration' @@ -43,8 +48,9 @@ describe('Exposures End-to-End', () => { OpenFeature.clearHandlers() OpenFeature.clearHooks() - // Clear localStorage to reset assignment cache between tests - localStorage.clear() + // Reset IndexedDB to clear assignment cache between tests + const { IDBFactory } = require('fake-indexeddb') + globalThis.indexedDB = new IDBFactory() // Mock current time to get deterministic timestamps jest.setSystemTime(new Date('2025-08-04T17:00:00.000Z')) From ed637cb811645dcf7715a19939f6a2801769f361 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 2 Mar 2026 12:17:45 -0500 Subject: [PATCH 2/3] Address review feedback: clean up dead code, deduplicate, batch writes - Delete orphaned localStorage and Chrome storage cache files and helpers. - Extract shared openDB() and IndexedDB constants into indexeddb-store.ts. - Batch persist() writes via queueMicrotask so rapid set() calls coalesce into a single IDB transaction per tick. - Move structuredClone polyfill and fake-indexeddb setup to shared jest setup file. - Replace inline require() with top-level import in exposures spec. - Add cross-instance persistence test for the page-reload scenario. --- packages/browser/jest.config.js | 1 + packages/browser/src/cache/helpers.ts | 14 ---- .../src/cache/indexeddb-assignment-cache.ts | 58 +++++++-------- .../src/cache/indexeddb-flags-cache.ts | 19 +---- packages/browser/src/cache/indexeddb-store.ts | 17 +++++ .../cache/local-storage-assignment-cache.ts | 25 ------- .../cache/local-storage-assignment-shim.ts | 72 ------------------- .../cache/assignment-cache-factory.spec.ts | 6 -- .../cache/hybrid-assignment-cache.spec.ts | 5 -- .../cache/indexeddb-assignment-cache.spec.ts | 18 +++-- .../test/cache/indexeddb-flags-cache.spec.ts | 5 -- .../local-storage-assignment-cache.spec.ts | 70 ------------------ .../test/openfeature/exposures.spec.ts | 7 +- .../openfeature/provider-persistence.spec.ts | 5 -- packages/browser/test/setup-indexeddb.ts | 7 ++ 15 files changed, 66 insertions(+), 263 deletions(-) create mode 100644 packages/browser/src/cache/indexeddb-store.ts delete mode 100644 packages/browser/src/cache/local-storage-assignment-cache.ts delete mode 100644 packages/browser/src/cache/local-storage-assignment-shim.ts delete mode 100644 packages/browser/test/cache/local-storage-assignment-cache.spec.ts create mode 100644 packages/browser/test/setup-indexeddb.ts diff --git a/packages/browser/jest.config.js b/packages/browser/jest.config.js index f26d9190..e5a6fedf 100644 --- a/packages/browser/jest.config.js +++ b/packages/browser/jest.config.js @@ -1,6 +1,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', + setupFiles: ['/test/setup-indexeddb.ts'], setupFilesAfterEnv: ['/src/test-setup.ts'], transform: { '^.+\\.ts$': [ diff --git a/packages/browser/src/cache/helpers.ts b/packages/browser/src/cache/helpers.ts index 8e2e1442..b1b6620f 100644 --- a/packages/browser/src/cache/helpers.ts +++ b/packages/browser/src/cache/helpers.ts @@ -1,17 +1,3 @@ -/** Returns whether `window.localStorage` is available */ -export function hasWindowLocalStorage(): boolean { - try { - return typeof window !== 'undefined' && !!window.localStorage - } catch { - // Chrome throws an error if local storage is disabled, and you try to access it - return false - } -} - -export function localStorageIfAvailable(): Storage | undefined { - return hasWindowLocalStorage() ? window.localStorage : undefined -} - /** Returns whether IndexedDB is available */ export function hasIndexedDB(): boolean { try { diff --git a/packages/browser/src/cache/indexeddb-assignment-cache.ts b/packages/browser/src/cache/indexeddb-assignment-cache.ts index 4203faa0..6d628c94 100644 --- a/packages/browser/src/cache/indexeddb-assignment-cache.ts +++ b/packages/browser/src/cache/indexeddb-assignment-cache.ts @@ -6,28 +6,12 @@ import { } from '@datadog/flagging-core' import type { BulkReadAssignmentCache } from './hybrid-assignment-cache' - -const DB_NAME = 'dd-flagging' -const DB_VERSION = 1 -const STORE_NAME = 'configurations' - -function openDB(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION) - request.onupgradeneeded = () => { - const db = request.result - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME) - } - } - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error) - }) -} +import { openDB, STORE_NAME } from './indexeddb-store' export class IndexedDBAssignmentCache implements BulkReadAssignmentCache { private readonly storageKey: string private readonly mirror: Map = new Map() + private persistScheduled = false constructor(clientToken: string) { this.storageKey = `assignments-${buildStorageKeySuffix(clientToken)}` @@ -101,20 +85,30 @@ export class IndexedDBAssignmentCache implements BulkReadAssignmentCache { } } - /** Fire-and-forget write to IndexedDB. Resolves on tx.oncomplete; silently swallows errors. */ + /** + * Schedule a fire-and-forget write for the next microtask. Coalesces rapid set() calls + * into a single IDB transaction, avoiding O(n^2) write volume and out-of-order races. + */ private persist(): void { - const entries = Array.from(this.mirror.entries()) - openDB() - .then((db) => { - const tx = db.transaction(STORE_NAME, 'readwrite') - const store = tx.objectStore(STORE_NAME) - store.put(entries, this.storageKey) - tx.oncomplete = () => db.close() - tx.onerror = () => db.close() - tx.onabort = () => db.close() - }) - .catch(() => { - // Silently fail - }) + if (this.persistScheduled) { + return + } + this.persistScheduled = true + queueMicrotask(() => { + this.persistScheduled = false + const entries = Array.from(this.mirror.entries()) + openDB() + .then((db) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + store.put(entries, this.storageKey) + tx.oncomplete = () => db.close() + tx.onerror = () => db.close() + tx.onabort = () => db.close() + }) + .catch(() => { + // Silently fail — persistence should never break the SDK + }) + }) } } diff --git a/packages/browser/src/cache/indexeddb-flags-cache.ts b/packages/browser/src/cache/indexeddb-flags-cache.ts index 09f633ff..99ecf857 100644 --- a/packages/browser/src/cache/indexeddb-flags-cache.ts +++ b/packages/browser/src/cache/indexeddb-flags-cache.ts @@ -1,24 +1,7 @@ import type { FlagsConfiguration } from '@datadog/flagging-core' import { buildStorageKeySuffix, getMD5Hash } from '@datadog/flagging-core' import type { EvaluationContext } from '@openfeature/web-sdk' - -const DB_NAME = 'dd-flagging' -const DB_VERSION = 1 -const STORE_NAME = 'configurations' - -function openDB(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION) - request.onupgradeneeded = () => { - const db = request.result - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME) - } - } - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error) - }) -} +import { openDB, STORE_NAME } from './indexeddb-store' function buildConfigKey(clientToken: string, context: EvaluationContext): string { const tokenSuffix = buildStorageKeySuffix(clientToken) diff --git a/packages/browser/src/cache/indexeddb-store.ts b/packages/browser/src/cache/indexeddb-store.ts new file mode 100644 index 00000000..f42485b3 --- /dev/null +++ b/packages/browser/src/cache/indexeddb-store.ts @@ -0,0 +1,17 @@ +export const DB_NAME = 'dd-flagging' +export const DB_VERSION = 1 +export const STORE_NAME = 'configurations' + +export function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME) + } + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) +} diff --git a/packages/browser/src/cache/local-storage-assignment-cache.ts b/packages/browser/src/cache/local-storage-assignment-cache.ts deleted file mode 100644 index 6aca8c34..00000000 --- a/packages/browser/src/cache/local-storage-assignment-cache.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AbstractAssignmentCache } from '@datadog/flagging-core' - -import type { BulkReadAssignmentCache, BulkWriteAssignmentCache } from './hybrid-assignment-cache' -import { LocalStorageAssignmentShim } from './local-storage-assignment-shim' - -export class LocalStorageAssignmentCache - extends AbstractAssignmentCache - implements BulkReadAssignmentCache, BulkWriteAssignmentCache -{ - constructor(storageKeySuffix: string) { - super(new LocalStorageAssignmentShim(storageKeySuffix)) - } - - setEntries(entries: [string, string][]): void { - entries.forEach(([key, value]) => { - if (key && value) { - this.delegate.set(key, value) - } - }) - } - - getEntries(): Promise<[string, string][]> { - return Promise.resolve(Array.from(this.entries())) - } -} diff --git a/packages/browser/src/cache/local-storage-assignment-shim.ts b/packages/browser/src/cache/local-storage-assignment-shim.ts deleted file mode 100644 index ecdf3cd2..00000000 --- a/packages/browser/src/cache/local-storage-assignment-shim.ts +++ /dev/null @@ -1,72 +0,0 @@ -// noinspection JSUnusedGlobalSymbols (methods are used by common repository) -import { hasWindowLocalStorage } from './helpers' - -export class LocalStorageAssignmentShim implements Map { - private readonly localStorageKey: string - - public constructor(storageKeySuffix: string) { - if (!hasWindowLocalStorage()) { - throw new Error('LocalStorage is not available') - } - const keySuffix = storageKeySuffix ? `-${storageKeySuffix}` : '' - this.localStorageKey = `eppo-assignment${keySuffix}` - } - - clear(): void { - this.getCache().clear() - } - - delete(key: string): boolean { - return this.getCache().delete(key) - } - - forEach( - callbackfn: (value: string, key: string, map: Map) => void, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - thisArg?: unknown - ): void { - this.getCache().forEach(callbackfn, thisArg) - } - - size: number = this.getCache().size - - entries(): IterableIterator<[string, string]> { - return this.getCache().entries() - } - - keys(): IterableIterator { - return this.getCache().keys() - } - - values(): IterableIterator { - return this.getCache().values() - } - - [Symbol.iterator](): IterableIterator<[string, string]> { - return this.getCache()[Symbol.iterator]() - } - - [Symbol.toStringTag]: string = this.getCache()[Symbol.toStringTag] - - public has(key: string): boolean { - return this.getCache().has(key) - } - - public get(key: string): string | undefined { - return this.getCache().get(key) ?? undefined - } - - public set(key: string, value: string): this { - return this.setCache(this.getCache().set(key, value)) - } - - private getCache(): Map { - const cache = window.localStorage.getItem(this.localStorageKey) - return cache ? new Map(JSON.parse(cache)) : new Map() - } - - private setCache(cache: Map): this { - window.localStorage.setItem(this.localStorageKey, JSON.stringify(Array.from(cache.entries()))) - return this - } -} diff --git a/packages/browser/test/cache/assignment-cache-factory.spec.ts b/packages/browser/test/cache/assignment-cache-factory.spec.ts index fa678f86..f1966f5c 100644 --- a/packages/browser/test/cache/assignment-cache-factory.spec.ts +++ b/packages/browser/test/cache/assignment-cache-factory.spec.ts @@ -1,9 +1,3 @@ -// structuredClone is required by fake-indexeddb but not available in jsdom -if (typeof globalThis.structuredClone === 'undefined') { - globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) -} -import 'fake-indexeddb/auto' - /** * @jest-environment jsdom */ diff --git a/packages/browser/test/cache/hybrid-assignment-cache.spec.ts b/packages/browser/test/cache/hybrid-assignment-cache.spec.ts index 8d04708c..68a41c65 100644 --- a/packages/browser/test/cache/hybrid-assignment-cache.spec.ts +++ b/packages/browser/test/cache/hybrid-assignment-cache.spec.ts @@ -1,8 +1,3 @@ -// structuredClone is required by fake-indexeddb but not available in jsdom -if (typeof globalThis.structuredClone === 'undefined') { - globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) -} -import 'fake-indexeddb/auto' import { IDBFactory } from 'fake-indexeddb' import type { ExposureEvent } from '../../../core/src/configuration/exposureEvent.types' import HybridAssignmentCache from '../../src/cache/hybrid-assignment-cache' diff --git a/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts index dc303cdf..feba6e97 100644 --- a/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts +++ b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts @@ -1,8 +1,3 @@ -// structuredClone is required by fake-indexeddb but not available in jsdom -if (typeof globalThis.structuredClone === 'undefined') { - globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) -} -import 'fake-indexeddb/auto' import { IDBFactory } from 'fake-indexeddb' import type { ExposureEvent } from '../../../core/src/configuration/exposureEvent.types' import { IndexedDBAssignmentCache } from '../../src/cache/indexeddb-assignment-cache' @@ -115,6 +110,19 @@ describe('IndexedDBAssignmentCache', () => { }) }) + describe('cross-instance persistence (simulates page reload)', () => { + it('should read entries written by a previous instance', async () => { + cache.set(exposureA) + cache.set(exposureB) + await flushAsync() + + // New instance with the same token — simulates a fresh page load + const freshCache = new IndexedDBAssignmentCache('test-client-token') + const entries = await freshCache.getEntries() + expect(entries).toHaveLength(2) + }) + }) + describe('clear', () => { it('should remove all entries', async () => { cache.set(exposureA) diff --git a/packages/browser/test/cache/indexeddb-flags-cache.spec.ts b/packages/browser/test/cache/indexeddb-flags-cache.spec.ts index 48a9e5b2..1450bcb1 100644 --- a/packages/browser/test/cache/indexeddb-flags-cache.spec.ts +++ b/packages/browser/test/cache/indexeddb-flags-cache.spec.ts @@ -1,8 +1,3 @@ -// structuredClone is required by fake-indexeddb but not available in jsdom -if (typeof globalThis.structuredClone === 'undefined') { - globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) -} -import 'fake-indexeddb/auto' import type { FlagsConfiguration } from '@datadog/flagging-core' import { IDBFactory } from 'fake-indexeddb' import { IndexedDBFlagsCache } from '../../src/cache/indexeddb-flags-cache' diff --git a/packages/browser/test/cache/local-storage-assignment-cache.spec.ts b/packages/browser/test/cache/local-storage-assignment-cache.spec.ts deleted file mode 100644 index 14c7e395..00000000 --- a/packages/browser/test/cache/local-storage-assignment-cache.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { ExposureEvent } from '../../../core/src/configuration/exposureEvent.types' -import { LocalStorageAssignmentCache } from '../../src/cache/local-storage-assignment-cache' - -describe('LocalStorageAssignmentCache', () => { - it('typical behavior', () => { - const cache = new LocalStorageAssignmentCache('test') - - const exposureEvent1: ExposureEvent = { - subject: { id: 'subject-1', attributes: {} }, - flag: { key: 'flag-1' }, - allocation: { key: 'allocation-1' }, - variant: { key: 'control' }, - } - - expect(cache.has(exposureEvent1)).toEqual(false) - - cache.set(exposureEvent1) - - expect(cache.has(exposureEvent1)).toEqual(true) // this key has been logged - - // change variation - const exposureEvent2: ExposureEvent = { - subject: { id: 'subject-1', attributes: {} }, - flag: { key: 'flag-1' }, - allocation: { key: 'allocation-1' }, - variant: { key: 'variant' }, - } - - cache.set(exposureEvent2) - - expect(cache.has(exposureEvent1)).toEqual(false) // this key has not been logged - }) - - it('can have independent caches', () => { - const storageKeySuffixA = 'A' - const storageKeySuffixB = 'B' - const cacheA = new LocalStorageAssignmentCache(storageKeySuffixA) - const cacheB = new LocalStorageAssignmentCache(storageKeySuffixB) - - const exposureEventA: ExposureEvent = { - subject: { id: 'subject-1', attributes: {} }, - flag: { key: 'flag-1' }, - allocation: { key: 'allocation-1' }, - variant: { key: 'variation-A' }, - } - - const exposureEventB: ExposureEvent = { - subject: { id: 'subject-1', attributes: {} }, - flag: { key: 'flag-1' }, - allocation: { key: 'allocation-1' }, - variant: { key: 'variation-B' }, - } - - cacheA.set(exposureEventA) - - expect(cacheA.has(exposureEventA)).toEqual(true) - - expect(cacheB.has(exposureEventA)).toEqual(false) - - cacheB.set(exposureEventB) - - expect(cacheA.has(exposureEventA)).toEqual(true) - - expect(cacheB.has(exposureEventA)).toEqual(false) - - expect(cacheA.has(exposureEventB)).toEqual(false) - - expect(cacheB.has(exposureEventB)).toEqual(true) - }) -}) diff --git a/packages/browser/test/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts index 27f59cf2..d3ab3012 100644 --- a/packages/browser/test/openfeature/exposures.spec.ts +++ b/packages/browser/test/openfeature/exposures.spec.ts @@ -1,10 +1,6 @@ -// structuredClone is required by fake-indexeddb but not available in jsdom -if (typeof globalThis.structuredClone === 'undefined') { - globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) -} -import 'fake-indexeddb/auto' import { INTAKE_SITE_STAGING } from '@datadog/browser-core' import { OpenFeature } from '@openfeature/web-sdk' +import { IDBFactory } from 'fake-indexeddb' import type { FlaggingInitConfiguration } from '../../src/domain/configuration' import { DatadogProvider } from '../../src/openfeature/provider' import precomputedServerResponse from '../data/precomputed-v1.json' @@ -49,7 +45,6 @@ describe('Exposures End-to-End', () => { OpenFeature.clearHooks() // Reset IndexedDB to clear assignment cache between tests - const { IDBFactory } = require('fake-indexeddb') globalThis.indexedDB = new IDBFactory() // Mock current time to get deterministic timestamps diff --git a/packages/browser/test/openfeature/provider-persistence.spec.ts b/packages/browser/test/openfeature/provider-persistence.spec.ts index 5ad109be..a9abf187 100644 --- a/packages/browser/test/openfeature/provider-persistence.spec.ts +++ b/packages/browser/test/openfeature/provider-persistence.spec.ts @@ -1,8 +1,3 @@ -// structuredClone is required by fake-indexeddb but not available in jsdom -if (typeof globalThis.structuredClone === 'undefined') { - globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) -} -import 'fake-indexeddb/auto' import { INTAKE_SITE_STAGING } from '@datadog/browser-core' import type { FlagsConfiguration } from '@datadog/flagging-core' import { ProviderStatus } from '@openfeature/web-sdk' diff --git a/packages/browser/test/setup-indexeddb.ts b/packages/browser/test/setup-indexeddb.ts new file mode 100644 index 00000000..db79a6b8 --- /dev/null +++ b/packages/browser/test/setup-indexeddb.ts @@ -0,0 +1,7 @@ +// Polyfill structuredClone for fake-indexeddb (not available in jsdom) +if (typeof globalThis.structuredClone === 'undefined') { + globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) +} + +// Auto-register fake IndexedDB globals before any test code runs +import 'fake-indexeddb/auto' From 9ccf8627fba25519411ef0fd49711e8b2df3b73d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 2 Mar 2026 12:30:21 -0500 Subject: [PATCH 3/3] Fix stale localStorage references in test comments --- packages/browser/test/openfeature/exposures.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/browser/test/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts index d3ab3012..3ee10594 100644 --- a/packages/browser/test/openfeature/exposures.spec.ts +++ b/packages/browser/test/openfeature/exposures.spec.ts @@ -621,7 +621,7 @@ describe('Exposures End-to-End', () => { // Verify first exposure was logged expect(getExposuresCalls()).toHaveLength(1) - // Simulate page reload: clear providers but keep localStorage + // Simulate page reload: clear providers but keep IndexedDB await OpenFeature.clearProviders() fetchMock.mockClear() @@ -630,11 +630,11 @@ describe('Exposures End-to-End', () => { await OpenFeature.setProviderAndWait(provider2) const client2 = OpenFeature.getClient() - // Evaluate same flag - should NOT log because cache persisted from localStorage + // Evaluate same flag - should NOT log because cache persisted from IndexedDB client2.getStringValue('string-flag', 'default') triggerBatch() - // Should have no new exposure calls (cache was loaded from localStorage) + // Should have no new exposure calls (cache was loaded from IndexedDB) expect(getExposuresCalls()).toHaveLength(0) })