From f9557eafe949a056feed3edd17435e352a5f14c1 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Tue, 11 Aug 2026 17:15:10 -0400 Subject: [PATCH 1/8] feat: cap page-view storage by byte budget instead of fixed count Replace the fixed PAGE_VIEWS_MAX_COUNT = 25 cap on persisted page views with a byte budget (PAGE_VIEWS_MAX_BYTES = 100 * 1024, measured as JSON string length). writePageViewsStorage now trims oldest-first to the budget so the kit stays a polite tenant in the shared origin localStorage regardless of free space, and wraps the write in a QuotaExceededError evict-and-retry loop that always keeps at least the newest record. If the write still fails, the error propagates to the existing capturePageView catch and reports PAGE_VIEW_CAPTURE_FAILED at INFO, so nothing is thrown into the host page. The selectPlacements send path (buildPageEvents) is intentionally unchanged; it is now naturally bounded by the storage budget. --- src/Rokt-Kit.ts | 43 ++++++++++++-- test/src/tests.spec.ts | 130 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 154 insertions(+), 19 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 2db1f88..a026fb3 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -263,7 +263,14 @@ const LS_NAMESPACE_KEY = 'mp-rokt-kit'; const LS_PAGE_VIEWS_FIELD = 'pageViews'; // TODO: remove after 2027-02-11 — one-time migration of the legacy key. const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; -const PAGE_VIEWS_MAX_COUNT = 25; +// Self-imposed cap on our footprint in the shared origin localStorage, measured +// as the JSON string length (UTF-16 code units) — the same unit browsers use +// for the ~5MB origin quota, so this is roughly 2% of a typical quota. Oldest +// page views are evicted first to stay under it. localStorage is shared across +// the whole origin (the customer's app, mParticle, other tags), so we cap our +// own usage rather than assuming the space is ours. Code constant, not a kit +// setting — change it here. +const PAGE_VIEWS_MAX_BYTES = 100 * 1024; const PAGE_EVENTS_KEY = 'page_events'; // Bound on how long selectPlacements will wait for an in-flight Workspace @@ -344,8 +351,34 @@ function loadPageViews(loggingService: LoggingService | null): PageEvent[] { return Array.isArray(stored) ? (stored as PageEvent[]) : []; } +// Persists as many recent page views as fit within our own byte budget and the +// browser's actual quota. Evicts oldest first (mutates the passed array): +// 1. Politeness cap — trim to PAGE_VIEWS_MAX_BYTES so we stay a good tenant in +// the shared origin storage regardless of how much free space exists. The +// budget measures the pageViews array only, not the whole namespace blob, +// so it caps our page-view footprint specifically. +// 2. Quota safety net — if the namespaced write fails, evict oldest and retry +// until it succeeds or only the newest record remains. writeNamespacedField +// swallows the underlying error and returns false, so this retries on any +// write failure (typically a full quota; eviction can't help a disabled or +// broken localStorage, in which case we fall through to returning false). +// Always keeps at least the newest page view. Returns false if even that cannot +// be persisted, so the caller reports at INFO and nothing is thrown into the +// host page. function writePageViewsStorage(pageViews: PageEvent[]): boolean { - return writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews); + while (pageViews.length > 1 && JSON.stringify(pageViews).length > PAGE_VIEWS_MAX_BYTES) { + pageViews.shift(); + } + + for (;;) { + if (writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews)) { + return true; + } + if (pageViews.length <= 1) { + return false; + } + pageViews.shift(); + } } function clearPageViewsStorage(): void { @@ -943,10 +976,8 @@ class RoktKit implements KitInterface { pageViews.push(pageView); - while (pageViews.length > PAGE_VIEWS_MAX_COUNT) { - pageViews.shift(); - } - + // writePageViewsStorage trims to our byte budget (oldest first) and + // handles write failures, so no count-based cap is needed here. if (!writePageViewsStorage(pageViews)) { this.loggingService?.log({ message: `Rokt Kit: Failed to persist page view for ${pageUrl}`, diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 76ecbee..72c1628 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5752,7 +5752,12 @@ describe('Rokt Forwarder', () => { expect(readStoredPageViews()).toBeNull(); }); - it('caps the stored history at 25 records, evicting oldest first', async () => { + // The byte budget is a code constant (PAGE_VIEWS_MAX_BYTES = 100 * 1024), + // measured as JSON string length. Not exported, so tests reference the + // literal value. + const PAGE_VIEWS_MAX_BYTES = 100 * 1024; + + it('caps the stored history by byte budget, evicting oldest first', async () => { await (window as any).mParticle.forwarder.init( { accountId: '123456', @@ -5765,22 +5770,64 @@ describe('Rokt Forwarder', () => { await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + // Pre-seed a history that already exceeds the byte budget, using long + // synthetic URLs so a handful of records is enough (~5KB each × 30 ≈ + // 150KB). Seeding directly avoids ~1000 process() calls to reach 100KB. + const bigUrl = 'https://example.com/' + 'a'.repeat(5000); + const seed = []; for (let i = 0; i < 30; i++) { - (window as any).mParticle.forwarder.process({ - EventName: 'Page ' + i, - EventCategory: EventType.Unknown, - EventDataType: MessageType.PageView, - SourceMessageId: 'source-message-id-' + i, - Timestamp: 1712345678000 + i, - ActiveTimeOnSite: i, - }); + seed.push({ pageUrl: bigUrl, sourceMessageId: 'seed-' + i, timestamp: 1712345678000 + i }); } + seedStoredPageViews(seed); + + // One more capture triggers byte-budget eviction on write. + (window as any).mParticle.forwarder.process({ + EventName: 'Newest', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'newest', + Timestamp: 1712345678999, + ActiveTimeOnSite: 1, + }); const stored = readStoredPageViews(); - // 30 written, capped at 25 — the 5 oldest evicted, newest always retained. - expect(stored.length).toBe(25); - expect(stored[0].sourceMessageId).toBe('source-message-id-5'); - expect(stored[stored.length - 1].sourceMessageId).toBe('source-message-id-29'); + // Trimmed under budget, oldest evicted, newest always retained. + expect(JSON.stringify(stored).length).toBeLessThanOrEqual(PAGE_VIEWS_MAX_BYTES); + expect(stored.length).toBeLessThan(31); + expect(stored[stored.length - 1].sourceMessageId).toBe('newest'); + expect(stored[0].sourceMessageId).not.toBe('seed-0'); + }); + + it('retains at least the newest page view even if an older record alone exceeds the budget', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + // A single seed record larger than the entire budget. + const hugeUrl = 'https://example.com/' + 'a'.repeat(PAGE_VIEWS_MAX_BYTES + 1); + seedStoredPageViews([{ pageUrl: hugeUrl, sourceMessageId: 'seed-huge', timestamp: 1712345678000 }]); + + (window as any).mParticle.forwarder.process({ + EventName: 'Newest', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'newest', + Timestamp: 1712345678999, + ActiveTimeOnSite: 1, + }); + + const stored = readStoredPageViews(); + // The oversized older record is evicted; the newest is always kept. + expect(stored.length).toBe(1); + expect(stored[0].sourceMessageId).toBe('newest'); }); it('clears the stored page-view history on a SessionEnd event', async () => { @@ -6062,6 +6109,63 @@ describe('Rokt Forwarder', () => { reportSpy.mockRestore(); }); + it('evicts oldest and retries when the browser quota is exceeded, then persists', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + // Seed a small history so there are older records to evict. + const seed = []; + for (let i = 0; i < 5; i++) { + seed.push({ pageUrl: 'https://example.com/', sourceMessageId: 'seed-' + i, timestamp: 1712345678000 + i }); + } + seedStoredPageViews(seed); + + // Fail the first 3 namespaced writes with a QuotaExceededError; the + // storage layer catches it and reports failure, driving writePageViews- + // Storage's evict-and-retry until the write succeeds. + let calls = 0; + const realSetItem = Storage.prototype.setItem; + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function ( + this: Storage, + key: string, + value: string, + ) { + calls += 1; + if (calls <= 3) { + throw new DOMException('quota', 'QuotaExceededError'); + } + return realSetItem.call(this, key, value); + }); + + try { + (window as any).mParticle.forwarder.process({ + EventName: 'Newest', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'newest', + Timestamp: 1712345678999, + ActiveTimeOnSite: 1, + }); + } finally { + setItemSpy.mockRestore(); + } + + const stored = readStoredPageViews(); + // 5 seed + 1 newest = 6, minus 3 evicted across the 3 failed retries = 3. + expect(stored.length).toBe(3); + expect(stored[stored.length - 1].sourceMessageId).toBe('newest'); + expect(stored[0].sourceMessageId).toBe('seed-3'); + }); + it('captures page views independently of setLocalSessionAttribute availability', async () => { await (window as any).mParticle.forwarder.init( { From 2c6a42dc14d652eb6d365cfba500517d32b7dd2c Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 15:44:15 -0400 Subject: [PATCH 2/8] refactor: extract page-view storage into dedicated module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move page-view persistence (load/write/clear + legacy-key migration) out of Rokt-Kit.ts into src/pageViewStorage.ts, and generalize the byte-budget eviction loop into a reusable storage helper. Keeps Rokt-Kit.ts focused on kit lifecycle. Also drop the hand-maintained file manifest from AGENTS.md — it drifts as modules are added; the source tree is the source of truth. --- AGENTS.md | 26 ------- src/Rokt-Kit.ts | 108 +++----------------------- src/pageViewStorage.ts | 62 +++++++++++++++ src/storage.ts | 20 +++++ test/src/pageViewStorage.spec.ts | 117 +++++++++++++++++++++++++++++ test/src/storage.spec.ts | 125 +++++++++++++++++++++++-------- test/src/tests.spec.ts | 9 +-- 7 files changed, 305 insertions(+), 162 deletions(-) create mode 100644 src/pageViewStorage.ts create mode 100644 test/src/pageViewStorage.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 7317c26..9feb2b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,32 +17,6 @@ The Rokt web kit (`@mparticle/web-rokt-kit`) is an mParticle integration kit (fo - **Code Quality**: ESLint v9 flat config + `@typescript-eslint/recommended` - **Formatting**: Prettier (120 chars, single quotes, trailing commas) -## Project Structure - -``` -/ - src/ - Rokt-Kit.ts # Main kit source (forwarder class + registration) - storage.ts # Key-agnostic localStorage helpers (readJSON/writeJSON/removeKey) - selectPlacementsAttributePersistence.ts # Attribute persistence deny-list - dist/ - Rokt-Kit.iife.js # Browser bundle (IIFE) - Rokt-Kit.common.js # npm bundle (CommonJS) - Rokt-Kit.d.ts # Type definitions - test/ - src/ - tests.spec.ts # Main Vitest test suite (Rokt-Kit.ts) - storage.spec.ts # Unit tests for storage.ts helpers - vitest.setup.ts # Global test setup / mParticle mock - lib/ # Test utilities - end-to-end-testapp/ # E2E test app - vite.config.ts # Build + test configuration - tsconfig.json # TypeScript config (src only) - tsconfig.test.json # TypeScript config (src + test) - eslint.config.mjs # ESLint v9 flat config - package.json -``` - ## Key Commands ```bash diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index a026fb3..43fc7f2 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -24,7 +24,13 @@ import { removeSelectPlacementsAttributePersistenceDeniedAttributes, } from './selectPlacementsAttributePersistence'; -import { readJSON, removeKey, readNamespacedField, writeNamespacedField, removeNamespacedField } from './storage'; +import { + PageEvent, + migrateLegacyPageViewStorage, + loadPageViews, + writePageViews, + clearPageViews, +} from './pageViewStorage'; interface RoktKitSettings { accountId: string; @@ -63,17 +69,6 @@ interface RoktExtensionEntry { value: string; } -interface PageEvent { - pageUrl: string; - sourceMessageId: string; - timestamp: number; - activeTimeOnSite?: number; - // Derived at transmission not at capture based - // on the next page view's activeTimeOnSite, - // so it is absent on stored records. - activeTimeOnPage?: number; -} - interface RoktSelection { context?: { sessionId?: Promise; @@ -259,18 +254,6 @@ const USER_IDENTIFIED_IN_WORKSPACE_KEY = 'userIdentifiedInWorkspace'; const MESSAGE_TYPE_PAGE_VIEW = 3; // mParticle MessageType.PageView const MESSAGE_TYPE_SESSION_END = 2; // mParticle MessageType.SessionEnd -const LS_NAMESPACE_KEY = 'mp-rokt-kit'; -const LS_PAGE_VIEWS_FIELD = 'pageViews'; -// TODO: remove after 2027-02-11 — one-time migration of the legacy key. -const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; -// Self-imposed cap on our footprint in the shared origin localStorage, measured -// as the JSON string length (UTF-16 code units) — the same unit browsers use -// for the ~5MB origin quota, so this is roughly 2% of a typical quota. Oldest -// page views are evicted first to stay under it. localStorage is shared across -// the whole origin (the customer's app, mParticle, other tags), so we cap our -// own usage rather than assuming the space is ours. Code constant, not a kit -// setting — change it here. -const PAGE_VIEWS_MAX_BYTES = 100 * 1024; const PAGE_EVENTS_KEY = 'page_events'; // Bound on how long selectPlacements will wait for an in-flight Workspace @@ -316,75 +299,6 @@ function mp(): MParticleExtended { // Module-level utility functions // ============================================================ -// TODO: remove after 2027-02-11 — one-time migration of the legacy 'mpPageViews' -// key into the namespaced storage object's pageViews field. Everything -// migration-related is confined to this function + LEGACY_PAGE_VIEWS_KEY so it -// can be deleted as a single unit. -// Unconditional (no freshness gate): staleness is mParticle's job — a timed-out -// prior session fires SessionEnd (→ clear) before selectPlacements runs. -function migrateLegacyPageViewStorage(loggingService: LoggingService | null): void { - const legacyViews = readJSON(LEGACY_PAGE_VIEWS_KEY); - if (legacyViews === null) { - return; - } - - const alreadyMigrated = readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD) !== undefined; - const needsMigration = !alreadyMigrated && Array.isArray(legacyViews); - - if (needsMigration) { - const migrated = writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, legacyViews); - if (!migrated) { - loggingService?.log({ - message: 'Rokt Kit: Failed to migrate legacy page-view storage; retaining legacy key for retry', - code: 'PAGE_VIEW_CAPTURE_FAILED', - }); - return; - } - } - - removeKey(LEGACY_PAGE_VIEWS_KEY); -} - -function loadPageViews(loggingService: LoggingService | null): PageEvent[] { - migrateLegacyPageViewStorage(loggingService); - const stored = readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD); - return Array.isArray(stored) ? (stored as PageEvent[]) : []; -} - -// Persists as many recent page views as fit within our own byte budget and the -// browser's actual quota. Evicts oldest first (mutates the passed array): -// 1. Politeness cap — trim to PAGE_VIEWS_MAX_BYTES so we stay a good tenant in -// the shared origin storage regardless of how much free space exists. The -// budget measures the pageViews array only, not the whole namespace blob, -// so it caps our page-view footprint specifically. -// 2. Quota safety net — if the namespaced write fails, evict oldest and retry -// until it succeeds or only the newest record remains. writeNamespacedField -// swallows the underlying error and returns false, so this retries on any -// write failure (typically a full quota; eviction can't help a disabled or -// broken localStorage, in which case we fall through to returning false). -// Always keeps at least the newest page view. Returns false if even that cannot -// be persisted, so the caller reports at INFO and nothing is thrown into the -// host page. -function writePageViewsStorage(pageViews: PageEvent[]): boolean { - while (pageViews.length > 1 && JSON.stringify(pageViews).length > PAGE_VIEWS_MAX_BYTES) { - pageViews.shift(); - } - - for (;;) { - if (writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews)) { - return true; - } - if (pageViews.length <= 1) { - return false; - } - pageViews.shift(); - } -} - -function clearPageViewsStorage(): void { - removeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD); -} - function generateLauncherScript(domain: string | undefined, extensions: string[]): string { const launcherPath = '/wsdk/integrations/launcher.js'; const baseUrl = [generateBaseUrl(domain), launcherPath].join(''); @@ -976,9 +890,7 @@ class RoktKit implements KitInterface { pageViews.push(pageView); - // writePageViewsStorage trims to our byte budget (oldest first) and - // handles write failures, so no count-based cap is needed here. - if (!writePageViewsStorage(pageViews)) { + if (!writePageViews(pageViews)) { this.loggingService?.log({ message: `Rokt Kit: Failed to persist page view for ${pageUrl}`, code: 'PAGE_VIEW_CAPTURE_FAILED', @@ -1273,7 +1185,7 @@ class RoktKit implements KitInterface { if (this.isTargetingDisabled()) { try { - clearPageViewsStorage(); + clearPageViews(); } catch (err) { this.errorReportingService?.report({ message: 'Rokt Kit: Failed to clear page views when targeting is disabled', @@ -1362,7 +1274,7 @@ class RoktKit implements KitInterface { if (event.EventDataType === MESSAGE_TYPE_SESSION_END) { migrateLegacyPageViewStorage(this.loggingService); - clearPageViewsStorage(); + clearPageViews(); } } diff --git a/src/pageViewStorage.ts b/src/pageViewStorage.ts new file mode 100644 index 0000000..94cd23d --- /dev/null +++ b/src/pageViewStorage.ts @@ -0,0 +1,62 @@ +import { + readJSON, + removeKey, + readNamespacedField, + writeNamespacedField, + removeNamespacedField, + writeNamespacedFieldWithinBudget, +} from './storage'; + +const LS_NAMESPACE_KEY = 'mp-rokt-kit'; +const LS_PAGE_VIEWS_FIELD = 'pageViews'; +const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; +const PAGE_VIEWS_MAX_BYTES = 100 * 1024; + +export interface PageEvent { + pageUrl: string; + sourceMessageId: string; + timestamp: number; + activeTimeOnSite?: number; + activeTimeOnPage?: number; +} + +export interface PageViewLogger { + log(entry: { message: string; code?: string }): void; +} + +export function migrateLegacyPageViewStorage(logger: PageViewLogger | null): void { + const legacyViews = readJSON(LEGACY_PAGE_VIEWS_KEY); + if (legacyViews === null) { + return; + } + + const alreadyMigrated = readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD) !== undefined; + const needsMigration = !alreadyMigrated && Array.isArray(legacyViews); + + if (needsMigration) { + const migrated = writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, legacyViews); + if (!migrated) { + logger?.log({ + message: 'Rokt Kit: Failed to migrate legacy page-view storage; retaining legacy key for retry', + code: 'PAGE_VIEW_CAPTURE_FAILED', + }); + return; + } + } + + removeKey(LEGACY_PAGE_VIEWS_KEY); +} + +export function loadPageViews(logger: PageViewLogger | null): PageEvent[] { + migrateLegacyPageViewStorage(logger); + const stored = readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD); + return Array.isArray(stored) ? (stored as PageEvent[]) : []; +} + +export function writePageViews(pageViews: PageEvent[]): boolean { + return writeNamespacedFieldWithinBudget(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews, PAGE_VIEWS_MAX_BYTES); +} + +export function clearPageViews(): void { + removeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD); +} diff --git a/src/storage.ts b/src/storage.ts index 9e06bd3..9572b50 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -53,3 +53,23 @@ export function removeNamespacedField(namespaceKey: string, field: string): void writeJSON(namespaceKey, next); } } + +export function writeNamespacedFieldWithinBudget( + namespaceKey: string, + field: string, + records: unknown[], + maxBytes: number, +): boolean { + while (records.length > 1 && JSON.stringify(records).length > maxBytes) { + records.shift(); + } + + while (!writeNamespacedField(namespaceKey, field, records)) { + if (records.length <= 1) { + return false; + } + records.shift(); + } + + return true; +} diff --git a/test/src/pageViewStorage.spec.ts b/test/src/pageViewStorage.spec.ts new file mode 100644 index 0000000..74fd3e7 --- /dev/null +++ b/test/src/pageViewStorage.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readJSON, writeNamespacedField } from '../../src/storage'; +import { + PageViewLogger, + migrateLegacyPageViewStorage, + loadPageViews, + writePageViews, + clearPageViews, +} from '../../src/pageViewStorage'; + +const NAMESPACE_KEY = 'mp-rokt-kit'; +const PAGE_VIEWS_FIELD = 'pageViews'; +const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; + +const pageView = (id: string) => ({ pageUrl: 'https://example.com/' + id, sourceMessageId: id, timestamp: 1 }); + +describe('pageViewStorage', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + }); + + describe('migrateLegacyPageViewStorage', () => { + it('is a no-op when the legacy key is absent', () => { + migrateLegacyPageViewStorage(null); + expect(readJSON(NAMESPACE_KEY)).toBeNull(); + }); + + it('moves a legacy array into the namespaced field and removes the legacy key', () => { + window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('a')])); + migrateLegacyPageViewStorage(null); + + expect(readJSON(NAMESPACE_KEY)).toEqual({ [PAGE_VIEWS_FIELD]: [pageView('a')] }); + expect(window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY)).toBeNull(); + }); + + it('does not overwrite an already-migrated field, but still clears the legacy key', () => { + writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, [pageView('current')]); + window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('stale')])); + + migrateLegacyPageViewStorage(null); + + expect(readJSON(NAMESPACE_KEY)).toEqual({ [PAGE_VIEWS_FIELD]: [pageView('current')] }); + expect(window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY)).toBeNull(); + }); + + it('retains the legacy key and logs when the migrating write fails', () => { + window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('a')])); + const logger: PageViewLogger = { log: vi.fn() }; + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError'); + }); + + migrateLegacyPageViewStorage(logger); + + expect(window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY)).not.toBeNull(); + expect(logger.log).toHaveBeenCalledWith(expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED' })); + }); + }); + + describe('loadPageViews', () => { + it('returns an empty array when nothing is stored', () => { + expect(loadPageViews(null)).toEqual([]); + }); + + it('returns the stored page views', () => { + writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, [pageView('a'), pageView('b')]); + expect(loadPageViews(null)).toEqual([pageView('a'), pageView('b')]); + }); + + it('returns an empty array when the stored value is not an array', () => { + writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, { not: 'an array' }); + expect(loadPageViews(null)).toEqual([]); + }); + + it('migrates the legacy key before reading', () => { + window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('legacy')])); + expect(loadPageViews(null)).toEqual([pageView('legacy')]); + expect(window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY)).toBeNull(); + }); + }); + + describe('writePageViews', () => { + it('persists the page views and returns true', () => { + expect(writePageViews([pageView('a')])).toBe(true); + expect(loadPageViews(null)).toEqual([pageView('a')]); + }); + + it('evicts oldest-first to stay within the byte budget', () => { + const oversizedUrl = 'https://example.com/' + 'a'.repeat(5000); + const views = Array.from({ length: 30 }, (_, i) => ({ + pageUrl: oversizedUrl, + sourceMessageId: 'seed-' + i, + timestamp: i, + })); + + expect(writePageViews(views)).toBe(true); + + const stored = loadPageViews(null); + expect(JSON.stringify(stored).length).toBeLessThanOrEqual(100 * 1024); + expect(stored.length).toBeLessThan(30); + expect(stored[stored.length - 1].sourceMessageId).toBe('seed-29'); + }); + }); + + describe('clearPageViews', () => { + it('removes the page-view field', () => { + writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, [pageView('a')]); + clearPageViews(); + expect(loadPageViews(null)).toEqual([]); + }); + }); +}); diff --git a/test/src/storage.spec.ts b/test/src/storage.spec.ts index 985ae9a..bd8d1e5 100644 --- a/test/src/storage.spec.ts +++ b/test/src/storage.spec.ts @@ -6,6 +6,7 @@ import { readNamespacedField, writeNamespacedField, removeNamespacedField, + writeNamespacedFieldWithinBudget, } from '../../src/storage'; describe('storage: key-agnostic localStorage helpers', () => { @@ -86,72 +87,134 @@ describe('storage: key-agnostic localStorage helpers', () => { }); describe('namespaced fields', () => { - const NS = 'mp-rokt-kit'; + const NAMESPACE_KEY = 'mp-rokt-kit'; it('writeNamespacedField stores the value under a field of the namespace object', () => { - expect(writeNamespacedField(NS, 'pageViews', [1, 2])).toBe(true); - expect(readJSON(NS)).toEqual({ pageViews: [1, 2] }); + expect(writeNamespacedField(NAMESPACE_KEY, 'pageViews', [1, 2])).toBe(true); + expect(readJSON(NAMESPACE_KEY)).toEqual({ pageViews: [1, 2] }); }); it('readNamespacedField returns the stored field value', () => { - writeNamespacedField(NS, 'pageViews', ['a']); - expect(readNamespacedField(NS, 'pageViews')).toEqual(['a']); + writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['a']); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toEqual(['a']); }); it('preserves sibling fields on write (read-modify-write)', () => { - writeNamespacedField(NS, 'pageViews', ['a']); - writeNamespacedField(NS, 'other', { x: 1 }); - expect(readJSON(NS)).toEqual({ pageViews: ['a'], other: { x: 1 } }); + writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['a']); + writeNamespacedField(NAMESPACE_KEY, 'other', { x: 1 }); + expect(readJSON(NAMESPACE_KEY)).toEqual({ pageViews: ['a'], other: { x: 1 } }); }); it('overwrites only the targeted field', () => { - writeNamespacedField(NS, 'pageViews', ['a']); - writeNamespacedField(NS, 'other', 1); - writeNamespacedField(NS, 'pageViews', ['b']); - expect(readNamespacedField(NS, 'pageViews')).toEqual(['b']); - expect(readNamespacedField(NS, 'other')).toBe(1); + writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['a']); + writeNamespacedField(NAMESPACE_KEY, 'other', 1); + writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['b']); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toEqual(['b']); + expect(readNamespacedField(NAMESPACE_KEY, 'other')).toBe(1); }); it('readNamespacedField returns undefined when the key is absent', () => { - expect(readNamespacedField(NS, 'pageViews')).toBeUndefined(); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toBeUndefined(); }); it('readNamespacedField returns undefined when the field is absent', () => { - writeNamespacedField(NS, 'other', 1); - expect(readNamespacedField(NS, 'pageViews')).toBeUndefined(); + writeNamespacedField(NAMESPACE_KEY, 'other', 1); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toBeUndefined(); }); it('readNamespacedField returns undefined when the stored value is not a plain object', () => { - writeJSON(NS, [1, 2, 3]); - expect(readNamespacedField(NS, 'pageViews')).toBeUndefined(); + writeJSON(NAMESPACE_KEY, [1, 2, 3]); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toBeUndefined(); }); it('writeNamespacedField returns false when the write throws', () => { vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('QuotaExceededError'); }); - expect(writeNamespacedField(NS, 'pageViews', ['a'])).toBe(false); + expect(writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['a'])).toBe(false); }); it('removeNamespacedField clears the field but keeps other fields', () => { - writeNamespacedField(NS, 'pageViews', ['a']); - writeNamespacedField(NS, 'other', 1); - removeNamespacedField(NS, 'pageViews'); - expect(readNamespacedField(NS, 'pageViews')).toBeUndefined(); - expect(readJSON(NS)).toEqual({ other: 1 }); + writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['a']); + writeNamespacedField(NAMESPACE_KEY, 'other', 1); + removeNamespacedField(NAMESPACE_KEY, 'pageViews'); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toBeUndefined(); + expect(readJSON(NAMESPACE_KEY)).toEqual({ other: 1 }); }); it('removeNamespacedField drops the namespace key once its last field is gone', () => { - writeNamespacedField(NS, 'pageViews', ['a']); - removeNamespacedField(NS, 'pageViews'); - expect(window.localStorage.getItem(NS)).toBeNull(); + writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['a']); + removeNamespacedField(NAMESPACE_KEY, 'pageViews'); + expect(window.localStorage.getItem(NAMESPACE_KEY)).toBeNull(); }); it('removeNamespacedField is a no-op for an absent key or field', () => { - expect(() => removeNamespacedField(NS, 'pageViews')).not.toThrow(); - writeNamespacedField(NS, 'other', 1); - removeNamespacedField(NS, 'pageViews'); - expect(readJSON(NS)).toEqual({ other: 1 }); + expect(() => removeNamespacedField(NAMESPACE_KEY, 'pageViews')).not.toThrow(); + writeNamespacedField(NAMESPACE_KEY, 'other', 1); + removeNamespacedField(NAMESPACE_KEY, 'pageViews'); + expect(readJSON(NAMESPACE_KEY)).toEqual({ other: 1 }); + }); + }); + + describe('writeNamespacedFieldWithinBudget', () => { + const NAMESPACE_KEY = 'mp-rokt-kit'; + const BUDGET = 1024; + + it('writes all records unchanged when under budget', () => { + const records = [{ id: 1 }, { id: 2 }]; + expect(writeNamespacedFieldWithinBudget(NAMESPACE_KEY, 'pageViews', records, BUDGET)).toBe(true); + expect(records).toHaveLength(2); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it('evicts oldest-first until the serialized size is within budget', () => { + const big = 'x'.repeat(300); + const records = [ + { id: 'a', v: big }, + { id: 'b', v: big }, + { id: 'c', v: big }, + { id: 'd', v: big }, + ]; + expect(writeNamespacedFieldWithinBudget(NAMESPACE_KEY, 'pageViews', records, BUDGET)).toBe(true); + + const stored = readNamespacedField(NAMESPACE_KEY, 'pageViews') as { id: string }[]; + expect(JSON.stringify(stored).length).toBeLessThanOrEqual(BUDGET); + expect(stored[stored.length - 1].id).toBe('d'); + expect(stored.map((r) => r.id)).not.toContain('a'); + }); + + it('keeps at least the newest record even when it alone exceeds the budget', () => { + const records = [{ id: 'newest', v: 'x'.repeat(BUDGET * 2) }]; + expect(writeNamespacedFieldWithinBudget(NAMESPACE_KEY, 'pageViews', records, BUDGET)).toBe(true); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toEqual([{ id: 'newest', v: 'x'.repeat(BUDGET * 2) }]); + }); + + it('evicts and retries when writes fail, then persists', () => { + let calls = 0; + const realSetItem = Storage.prototype.setItem; + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + calls += 1; + if (calls <= 2) { + throw new DOMException('quota', 'QuotaExceededError'); + } + return realSetItem.call(this, key, value); + }); + + const records = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }]; + expect(writeNamespacedFieldWithinBudget(NAMESPACE_KEY, 'pageViews', records, BUDGET)).toBe(true); + + const stored = readNamespacedField(NAMESPACE_KEY, 'pageViews') as { id: string }[]; + // 2 failed writes evict the oldest twice (a, then b), leaving [c, d]. + expect(stored.map((r) => r.id)).toEqual(['c', 'd']); + }); + + it('returns false when even a single record cannot be written', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError'); + }); + const records = [{ id: 'a' }, { id: 'b' }]; + expect(writeNamespacedFieldWithinBudget(NAMESPACE_KEY, 'pageViews', records, BUDGET)).toBe(false); + expect(records).toHaveLength(1); }); }); }); diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 72c1628..62e2590 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5791,7 +5791,6 @@ describe('Rokt Forwarder', () => { }); const stored = readStoredPageViews(); - // Trimmed under budget, oldest evicted, newest always retained. expect(JSON.stringify(stored).length).toBeLessThanOrEqual(PAGE_VIEWS_MAX_BYTES); expect(stored.length).toBeLessThan(31); expect(stored[stored.length - 1].sourceMessageId).toBe('newest'); @@ -5811,7 +5810,6 @@ describe('Rokt Forwarder', () => { await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); - // A single seed record larger than the entire budget. const hugeUrl = 'https://example.com/' + 'a'.repeat(PAGE_VIEWS_MAX_BYTES + 1); seedStoredPageViews([{ pageUrl: hugeUrl, sourceMessageId: 'seed-huge', timestamp: 1712345678000 }]); @@ -5825,7 +5823,6 @@ describe('Rokt Forwarder', () => { }); const stored = readStoredPageViews(); - // The oversized older record is evicted; the newest is always kept. expect(stored.length).toBe(1); expect(stored[0].sourceMessageId).toBe('newest'); }); @@ -6122,16 +6119,14 @@ describe('Rokt Forwarder', () => { await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); - // Seed a small history so there are older records to evict. const seed = []; for (let i = 0; i < 5; i++) { seed.push({ pageUrl: 'https://example.com/', sourceMessageId: 'seed-' + i, timestamp: 1712345678000 + i }); } seedStoredPageViews(seed); - // Fail the first 3 namespaced writes with a QuotaExceededError; the - // storage layer catches it and reports failure, driving writePageViews- - // Storage's evict-and-retry until the write succeeds. + // writeNamespacedField swallows the quota error and returns false, so + // failing the first 3 writes drives writePageViews's evict-and-retry. let calls = 0; const realSetItem = Storage.prototype.setItem; const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function ( From 06058aaef910d5955e0881377000c814ea15036f Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 18:47:34 -0400 Subject: [PATCH 3/8] refactor: extract shared type guards into utils module Unify the duplicated isObject/isPlainObject guards and move isString and isEmpty into src/utils.ts with co-located tests. Share an evictOldest helper across the budget-shrink and write-retry loops in writeNamespacedFieldWithinBudget. --- src/Rokt-Kit.ts | 21 ++------------ src/storage.ts | 30 ++++++++++--------- src/utils.ts | 18 ++++++++++++ test/src/utils.spec.ts | 65 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 src/utils.ts create mode 100644 test/src/utils.spec.ts diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 43fc7f2..8dfcd57 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -32,6 +32,8 @@ import { clearPageViews, } from './pageViewStorage'; +import { isObject, isString, isEmpty } from './utils'; + interface RoktKitSettings { accountId: string; roktExtensions?: string; @@ -352,10 +354,6 @@ function loadRoktScript( target.appendChild(script); } -function isObject(val: unknown): val is Record { - return val != null && typeof val === 'object' && Array.isArray(val) === false; -} - function parseSettingsString(settingsString?: string): T[] { if (!settingsString) { return []; @@ -447,21 +445,6 @@ function hashEventMessage(messageType: number, eventType: number, eventName: str return mp().generateHash([messageType, eventType, eventName].join('')); } -function isEmpty(value: unknown): boolean { - if (value == null) return true; - if (typeof value === 'object') { - return Object.keys(value as object).length === 0; - } - if (Array.isArray(value)) { - return (value as unknown[]).length === 0; - } - return false; -} - -function isString(value: unknown): value is string { - return typeof value === 'string'; -} - // Strips the query string from a page-view URL before it is persisted and sent // to Rokt, since query params commonly carry PII (emails, tokens, order refs). // Returns the input unchanged if it can't be parsed as a URL. diff --git a/src/storage.ts b/src/storage.ts index 9572b50..9e0d8cc 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,3 +1,5 @@ +import { isObject } from './utils'; + export function readJSON(key: string): unknown { try { const stored = window.localStorage.getItem(key); @@ -24,25 +26,21 @@ export function removeKey(key: string): void { } } -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - export function readNamespacedField(namespaceKey: string, field: string): unknown { const blob = readJSON(namespaceKey); - return isPlainObject(blob) ? blob[field] : undefined; + return isObject(blob) ? blob[field] : undefined; } export function writeNamespacedField(namespaceKey: string, field: string, value: unknown): boolean { const blob = readJSON(namespaceKey); - const next = isPlainObject(blob) ? { ...blob } : {}; + const next = isObject(blob) ? { ...blob } : {}; next[field] = value; return writeJSON(namespaceKey, next); } export function removeNamespacedField(namespaceKey: string, field: string): void { const blob = readJSON(namespaceKey); - if (!isPlainObject(blob) || !(field in blob)) { + if (!isObject(blob) || !(field in blob)) { return; } const next = { ...blob }; @@ -60,16 +58,22 @@ export function writeNamespacedFieldWithinBudget( records: unknown[], maxBytes: number, ): boolean { - while (records.length > 1 && JSON.stringify(records).length > maxBytes) { - records.shift(); - } - - while (!writeNamespacedField(namespaceKey, field, records)) { + const evictOldest = (): boolean => { if (records.length <= 1) { return false; } records.shift(); + return true; + }; + + let overBudget = JSON.stringify(records).length > maxBytes; + while (overBudget && evictOldest()) { + overBudget = JSON.stringify(records).length > maxBytes; } - return true; + let written = writeNamespacedField(namespaceKey, field, records); + while (!written && evictOldest()) { + written = writeNamespacedField(namespaceKey, field, records); + } + return written; } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..617aa5c --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,18 @@ +export function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +export function isEmpty(value: unknown): boolean { + if (value == null) return true; + if (typeof value === 'object') { + return Object.keys(value as object).length === 0; + } + if (Array.isArray(value)) { + return (value as unknown[]).length === 0; + } + return false; +} diff --git a/test/src/utils.spec.ts b/test/src/utils.spec.ts new file mode 100644 index 0000000..9c53956 --- /dev/null +++ b/test/src/utils.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { isObject, isString, isEmpty } from '../../src/utils'; + +describe('utils: type guards', () => { + describe('isObject', () => { + it('is true for plain objects', () => { + expect(isObject({})).toBe(true); + expect(isObject({ a: 1 })).toBe(true); + }); + + it('is false for arrays', () => { + expect(isObject([])).toBe(false); + expect(isObject([1, 2])).toBe(false); + }); + + it('is false for null and undefined', () => { + expect(isObject(null)).toBe(false); + expect(isObject(undefined)).toBe(false); + }); + + it('is false for primitives', () => { + expect(isObject('s')).toBe(false); + expect(isObject(1)).toBe(false); + expect(isObject(true)).toBe(false); + }); + }); + + describe('isString', () => { + it('is true for strings', () => { + expect(isString('')).toBe(true); + expect(isString('abc')).toBe(true); + }); + + it('is false for non-strings', () => { + expect(isString(1)).toBe(false); + expect(isString(null)).toBe(false); + expect(isString(undefined)).toBe(false); + expect(isString({})).toBe(false); + expect(isString(['a'])).toBe(false); + }); + }); + + describe('isEmpty', () => { + it('is true for null and undefined', () => { + expect(isEmpty(null)).toBe(true); + expect(isEmpty(undefined)).toBe(true); + }); + + it('is true for empty objects and arrays', () => { + expect(isEmpty({})).toBe(true); + expect(isEmpty([])).toBe(true); + }); + + it('is false for non-empty objects and arrays', () => { + expect(isEmpty({ a: 1 })).toBe(false); + expect(isEmpty([1])).toBe(false); + }); + + it('is false for non-empty primitives', () => { + expect(isEmpty('abc')).toBe(false); + expect(isEmpty(0)).toBe(false); + expect(isEmpty(false)).toBe(false); + }); + }); +}); From e0bc026e10555f83fb159a3bce7ca77f8cd26075 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Thu, 13 Aug 2026 10:13:04 -0400 Subject: [PATCH 4/8] refactor: drop unreachable array branch in isEmpty Arrays are typeof 'object', so they were already handled by the object branch; the Array.isArray check was dead code. --- src/utils.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 617aa5c..0d839b2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -11,8 +11,5 @@ export function isEmpty(value: unknown): boolean { if (typeof value === 'object') { return Object.keys(value as object).length === 0; } - if (Array.isArray(value)) { - return (value as unknown[]).length === 0; - } return false; } From 2ea7ace52d4a390ba338dfe6ace3df55ae6a5cfd Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Thu, 13 Aug 2026 11:55:12 -0400 Subject: [PATCH 5/8] test: assert page-view field removal and use descriptive fixture names --- test/src/pageViewStorage.spec.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/test/src/pageViewStorage.spec.ts b/test/src/pageViewStorage.spec.ts index 74fd3e7..daf1718 100644 --- a/test/src/pageViewStorage.spec.ts +++ b/test/src/pageViewStorage.spec.ts @@ -31,10 +31,10 @@ describe('pageViewStorage', () => { }); it('moves a legacy array into the namespaced field and removes the legacy key', () => { - window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('a')])); + window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('home')])); migrateLegacyPageViewStorage(null); - expect(readJSON(NAMESPACE_KEY)).toEqual({ [PAGE_VIEWS_FIELD]: [pageView('a')] }); + expect(readJSON(NAMESPACE_KEY)).toEqual({ [PAGE_VIEWS_FIELD]: [pageView('home')] }); expect(window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY)).toBeNull(); }); @@ -49,7 +49,7 @@ describe('pageViewStorage', () => { }); it('retains the legacy key and logs when the migrating write fails', () => { - window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('a')])); + window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('home')])); const logger: PageViewLogger = { log: vi.fn() }; vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new DOMException('quota', 'QuotaExceededError'); @@ -68,8 +68,8 @@ describe('pageViewStorage', () => { }); it('returns the stored page views', () => { - writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, [pageView('a'), pageView('b')]); - expect(loadPageViews(null)).toEqual([pageView('a'), pageView('b')]); + writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, [pageView('home'), pageView('about')]); + expect(loadPageViews(null)).toEqual([pageView('home'), pageView('about')]); }); it('returns an empty array when the stored value is not an array', () => { @@ -86,8 +86,8 @@ describe('pageViewStorage', () => { describe('writePageViews', () => { it('persists the page views and returns true', () => { - expect(writePageViews([pageView('a')])).toBe(true); - expect(loadPageViews(null)).toEqual([pageView('a')]); + expect(writePageViews([pageView('home')])).toBe(true); + expect(loadPageViews(null)).toEqual([pageView('home')]); }); it('evicts oldest-first to stay within the byte budget', () => { @@ -109,9 +109,13 @@ describe('pageViewStorage', () => { describe('clearPageViews', () => { it('removes the page-view field', () => { - writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, [pageView('a')]); + writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, [pageView('home')]); + writeNamespacedField(NAMESPACE_KEY, 'unrelatedField', 'keep-me'); clearPageViews(); - expect(loadPageViews(null)).toEqual([]); + + const blob = readJSON(NAMESPACE_KEY); + expect(blob).not.toHaveProperty(PAGE_VIEWS_FIELD); + expect(blob).toHaveProperty('unrelatedField', 'keep-me'); }); }); }); From 8367072bedbf2d49c96842d5bfa785424afd8fed Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Thu, 13 Aug 2026 14:18:40 -0400 Subject: [PATCH 6/8] refactor: clarify byte-budget storage (maxLength, non-mutating) --- src/pageViewStorage.ts | 5 +++-- src/storage.ts | 20 +++++++++++++------- test/src/storage.spec.ts | 2 +- test/src/tests.spec.ts | 8 ++++---- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/pageViewStorage.ts b/src/pageViewStorage.ts index 94cd23d..fc26a00 100644 --- a/src/pageViewStorage.ts +++ b/src/pageViewStorage.ts @@ -10,7 +10,8 @@ import { const LS_NAMESPACE_KEY = 'mp-rokt-kit'; const LS_PAGE_VIEWS_FIELD = 'pageViews'; const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; -const PAGE_VIEWS_MAX_BYTES = 100 * 1024; +// UTF-16 code units (string.length), matching how browsers meter localStorage. +const PAGE_VIEWS_MAX_LENGTH = 100 * 1024; export interface PageEvent { pageUrl: string; @@ -54,7 +55,7 @@ export function loadPageViews(logger: PageViewLogger | null): PageEvent[] { } export function writePageViews(pageViews: PageEvent[]): boolean { - return writeNamespacedFieldWithinBudget(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews, PAGE_VIEWS_MAX_BYTES); + return writeNamespacedFieldWithinBudget(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews, PAGE_VIEWS_MAX_LENGTH); } export function clearPageViews(): void { diff --git a/src/storage.ts b/src/storage.ts index 9e0d8cc..6a125c6 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -56,24 +56,30 @@ export function writeNamespacedFieldWithinBudget( namespaceKey: string, field: string, records: unknown[], - maxBytes: number, + maxLength: number, ): boolean { + // Operate on a copy so the caller's array isn't trimmed as a side effect. + const remaining = records.slice(); + const evictOldest = (): boolean => { - if (records.length <= 1) { + if (remaining.length <= 1) { return false; } - records.shift(); + remaining.shift(); return true; }; - let overBudget = JSON.stringify(records).length > maxBytes; + // Two limits: our own soft cap (maxLength), then the browser's hard quota, + // which is shared across the origin and only surfaces when setItem throws. + let overBudget = JSON.stringify(remaining).length > maxLength; while (overBudget && evictOldest()) { - overBudget = JSON.stringify(records).length > maxBytes; + overBudget = JSON.stringify(remaining).length > maxLength; } - let written = writeNamespacedField(namespaceKey, field, records); + let written = writeNamespacedField(namespaceKey, field, remaining); while (!written && evictOldest()) { - written = writeNamespacedField(namespaceKey, field, records); + written = writeNamespacedField(namespaceKey, field, remaining); } + return written; } diff --git a/test/src/storage.spec.ts b/test/src/storage.spec.ts index bd8d1e5..5f51fa8 100644 --- a/test/src/storage.spec.ts +++ b/test/src/storage.spec.ts @@ -214,7 +214,7 @@ describe('storage: key-agnostic localStorage helpers', () => { }); const records = [{ id: 'a' }, { id: 'b' }]; expect(writeNamespacedFieldWithinBudget(NAMESPACE_KEY, 'pageViews', records, BUDGET)).toBe(false); - expect(records).toHaveLength(1); + expect(records).toEqual([{ id: 'a' }, { id: 'b' }]); }); }); }); diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 62e2590..7edfc49 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5752,10 +5752,10 @@ describe('Rokt Forwarder', () => { expect(readStoredPageViews()).toBeNull(); }); - // The byte budget is a code constant (PAGE_VIEWS_MAX_BYTES = 100 * 1024), + // The budget is a code constant (PAGE_VIEWS_MAX_LENGTH = 100 * 1024), // measured as JSON string length. Not exported, so tests reference the // literal value. - const PAGE_VIEWS_MAX_BYTES = 100 * 1024; + const PAGE_VIEWS_MAX_LENGTH = 100 * 1024; it('caps the stored history by byte budget, evicting oldest first', async () => { await (window as any).mParticle.forwarder.init( @@ -5791,7 +5791,7 @@ describe('Rokt Forwarder', () => { }); const stored = readStoredPageViews(); - expect(JSON.stringify(stored).length).toBeLessThanOrEqual(PAGE_VIEWS_MAX_BYTES); + expect(JSON.stringify(stored).length).toBeLessThanOrEqual(PAGE_VIEWS_MAX_LENGTH); expect(stored.length).toBeLessThan(31); expect(stored[stored.length - 1].sourceMessageId).toBe('newest'); expect(stored[0].sourceMessageId).not.toBe('seed-0'); @@ -5810,7 +5810,7 @@ describe('Rokt Forwarder', () => { await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); - const hugeUrl = 'https://example.com/' + 'a'.repeat(PAGE_VIEWS_MAX_BYTES + 1); + const hugeUrl = 'https://example.com/' + 'a'.repeat(PAGE_VIEWS_MAX_LENGTH + 1); seedStoredPageViews([{ pageUrl: hugeUrl, sourceMessageId: 'seed-huge', timestamp: 1712345678000 }]); (window as any).mParticle.forwarder.process({ From f9e37d7e112d035b4e4fceee1de079ad6a7ff4c0 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Thu, 13 Aug 2026 14:39:19 -0400 Subject: [PATCH 7/8] refactor: use existing LoggingService type in page-view storage --- src/Rokt-Kit.ts | 1 + src/pageViewStorage.ts | 13 +++++-------- test/src/pageViewStorage.spec.ts | 11 +++-------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 8dfcd57..0ba7f23 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -1582,3 +1582,4 @@ if (typeof window !== 'undefined' && window.mParticle && mp().addForwarder) { } export { register }; +export type { LoggingService }; diff --git a/src/pageViewStorage.ts b/src/pageViewStorage.ts index fc26a00..3e94ab3 100644 --- a/src/pageViewStorage.ts +++ b/src/pageViewStorage.ts @@ -1,3 +1,4 @@ +import type { LoggingService } from './Rokt-Kit'; import { readJSON, removeKey, @@ -21,11 +22,7 @@ export interface PageEvent { activeTimeOnPage?: number; } -export interface PageViewLogger { - log(entry: { message: string; code?: string }): void; -} - -export function migrateLegacyPageViewStorage(logger: PageViewLogger | null): void { +export function migrateLegacyPageViewStorage(loggingService: LoggingService | null): void { const legacyViews = readJSON(LEGACY_PAGE_VIEWS_KEY); if (legacyViews === null) { return; @@ -37,7 +34,7 @@ export function migrateLegacyPageViewStorage(logger: PageViewLogger | null): voi if (needsMigration) { const migrated = writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, legacyViews); if (!migrated) { - logger?.log({ + loggingService?.log({ message: 'Rokt Kit: Failed to migrate legacy page-view storage; retaining legacy key for retry', code: 'PAGE_VIEW_CAPTURE_FAILED', }); @@ -48,8 +45,8 @@ export function migrateLegacyPageViewStorage(logger: PageViewLogger | null): voi removeKey(LEGACY_PAGE_VIEWS_KEY); } -export function loadPageViews(logger: PageViewLogger | null): PageEvent[] { - migrateLegacyPageViewStorage(logger); +export function loadPageViews(loggingService: LoggingService | null): PageEvent[] { + migrateLegacyPageViewStorage(loggingService); const stored = readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD); return Array.isArray(stored) ? (stored as PageEvent[]) : []; } diff --git a/test/src/pageViewStorage.spec.ts b/test/src/pageViewStorage.spec.ts index daf1718..88f69e7 100644 --- a/test/src/pageViewStorage.spec.ts +++ b/test/src/pageViewStorage.spec.ts @@ -1,12 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { readJSON, writeNamespacedField } from '../../src/storage'; -import { - PageViewLogger, - migrateLegacyPageViewStorage, - loadPageViews, - writePageViews, - clearPageViews, -} from '../../src/pageViewStorage'; +import { migrateLegacyPageViewStorage, loadPageViews, writePageViews, clearPageViews } from '../../src/pageViewStorage'; +import type { LoggingService } from '../../src/Rokt-Kit'; const NAMESPACE_KEY = 'mp-rokt-kit'; const PAGE_VIEWS_FIELD = 'pageViews'; @@ -50,7 +45,7 @@ describe('pageViewStorage', () => { it('retains the legacy key and logs when the migrating write fails', () => { window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('home')])); - const logger: PageViewLogger = { log: vi.fn() }; + const logger = { log: vi.fn() } as unknown as LoggingService; vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new DOMException('quota', 'QuotaExceededError'); }); From 9dbaa899fb7aa79ecf3eb9c8ef718773e3eac5fc Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Thu, 13 Aug 2026 14:48:03 -0400 Subject: [PATCH 8/8] fix: Remove unnecessary comment --- src/pageViewStorage.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pageViewStorage.ts b/src/pageViewStorage.ts index 3e94ab3..3c22086 100644 --- a/src/pageViewStorage.ts +++ b/src/pageViewStorage.ts @@ -11,7 +11,6 @@ import { const LS_NAMESPACE_KEY = 'mp-rokt-kit'; const LS_PAGE_VIEWS_FIELD = 'pageViews'; const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; -// UTF-16 code units (string.length), matching how browsers meter localStorage. const PAGE_VIEWS_MAX_LENGTH = 100 * 1024; export interface PageEvent {