From 726e4cf973266fe1945213cf3bb2908f23786d62 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Tue, 11 Aug 2026 17:30:19 -0400 Subject: [PATCH 01/12] feat: namespace page-view localStorage key and migrate legacy key Rename the persisted page-view key from the unprefixed 'mpPageViews' to 'mp-rokt-kit.pageViews', adopting an 'mp-rokt-kit.*' namespace for kit-owned localStorage keys. - Generalize the storage accessors to key-agnostic readJSON/writeJSON/ removeKey so future kit keys can reuse them. All three swallow storage failures (private mode, quota, access denied): persisted page views are a best-effort cache and a failed read/write/remove must never break the caller. - Centralize reads behind loadPageViews(), which runs a one-time, unconditional legacy migration (adopt-if-empty, always-sweep) before reading. - Sweep the legacy key on SessionEnd before clearing the new key. - Confine all migration logic to migrateLegacyPageViewStorage() + LEGACY_PAGE_VIEWS_KEY, marked with a TODO removal date. It works on the opaque stored string (raw localStorage) so malformed legacy data is still swept, not adopted. - Guard the selectPlacements read path: the migration touches localStorage directly and can throw, which must not break placement selection. The stored value stays a bare array; capacity semantics are unchanged. --- src/Rokt-Kit.ts | 98 +++++++++++++++++++---- test/src/tests.spec.ts | 173 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 249 insertions(+), 22 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 3246b2f..e18a160 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -261,8 +261,15 @@ const MESSAGE_TYPE_SESSION_END = 2; // mParticle MessageType.SessionEnd // string). The kit owns this storage directly — separate from mParticle's // cookie/localStorage — so page-view capture does not affect mParticle // persistence or cookie sync. Distinct from PAGE_EVENTS_KEY, which is the -// flattened wire shape sent to Rokt on selectPlacements. -const LS_PAGE_VIEWS_KEY = 'mpPageViews'; +// flattened wire shape sent to Rokt on selectPlacements. Namespaced under the +// kit-owned `mp-rokt-kit.*` prefix; future kit-owned keys follow the same +// `mp-rokt-kit.` convention. +const LS_PAGE_VIEWS_KEY = 'mp-rokt-kit.pageViews'; +// Legacy unprefixed key this feature originally shipped under. Read once and +// swept by migrateLegacyPageViewStorage() so existing history survives the +// rename. +// TODO: remove after 2027-02-11 — one-time migration of the legacy key. +const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; // Fixed cap on the number of persisted page views (oldest evicted first). Code // constant, not a kit setting — change it here. const PAGE_VIEWS_MAX_COUNT = 25; @@ -311,25 +318,72 @@ function mp(): MParticleExtended { // Module-level utility functions // ============================================================ -function readPageViewsStorage(): PageEvent[] { +// Key-agnostic localStorage helpers. Page-view semantics (array shape, count +// cap, migration) live in the callers below so these can be reused verbatim +// for future `mp-rokt-kit.*` keys. +function readJSON(key: string): unknown { try { - const stored = window.localStorage.getItem(LS_PAGE_VIEWS_KEY); - if (stored === null) { - return []; - } - const parsed = JSON.parse(stored); - return Array.isArray(parsed) ? (parsed as PageEvent[]) : []; + const stored = window.localStorage.getItem(key); + return stored === null ? null : JSON.parse(stored); } catch { - return []; + return null; + } +} + +// writeJSON/removeKey swallow storage failures (private mode, quota, access +// denied) rather than throw: persisted page views are a best-effort cache, and +// a failed write/remove must never break the caller. Failures are intentionally +// not reported here — revisit if these keys ever hold must-persist data. +function writeJSON(key: string, value: unknown): void { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + // no-op } } +function removeKey(key: string): void { + try { + window.localStorage.removeItem(key); + } catch { + // no-op + } +} + +// TODO: remove after 2027-02-11 — one-time migration of the legacy 'mpPageViews' +// key to the prefixed LS_PAGE_VIEWS_KEY. 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(): void { + // The read + adopt work on the opaque stored string on purpose — no + // readJSON/writeJSON round-trip. That keeps the move byte-for-byte and lets + // us still sweep malformed legacy data (readJSON would collapse "absent" and + // "malformed" to null and leave garbage behind). The sweep uses removeKey. + const legacy = window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY); + if (legacy === null) { + return; + } + if (window.localStorage.getItem(LS_PAGE_VIEWS_KEY) === null) { + window.localStorage.setItem(LS_PAGE_VIEWS_KEY, legacy); // adopt-if-empty + } + removeKey(LEGACY_PAGE_VIEWS_KEY); // always sweep +} + +// Single entry point for reading persisted page views. Runs the one-time legacy +// migration first, then returns the stored array (or [] if absent/malformed). +function loadPageViews(): PageEvent[] { + migrateLegacyPageViewStorage(); + const parsed = readJSON(LS_PAGE_VIEWS_KEY); + return Array.isArray(parsed) ? (parsed as PageEvent[]) : []; +} + function writePageViewsStorage(pageViews: PageEvent[]): void { - window.localStorage.setItem(LS_PAGE_VIEWS_KEY, JSON.stringify(pageViews)); + writeJSON(LS_PAGE_VIEWS_KEY, pageViews); } function clearPageViewsStorage(): void { - window.localStorage.removeItem(LS_PAGE_VIEWS_KEY); + removeKey(LS_PAGE_VIEWS_KEY); } function generateLauncherScript(domain: string | undefined, extensions: string[]): string { @@ -909,7 +963,7 @@ class RoktKit implements KitInterface { try { pageUrl = sanitizeUrl(window.location.href); - const pageViews = readPageViewsStorage(); + const pageViews = loadPageViews(); const pageView: PageEvent = { pageUrl, @@ -1306,6 +1360,7 @@ class RoktKit implements KitInterface { if (event.EventDataType === MESSAGE_TYPE_SESSION_END) { try { + migrateLegacyPageViewStorage(); clearPageViewsStorage(); } catch (err) { this.errorReportingService?.report({ @@ -1533,7 +1588,22 @@ class RoktKit implements KitInterface { const filteredUserIdentities = this.returnUserIdentities(filteredUser); const sessionAttributes = this.returnLocalSessionAttributes(); - const pageEvents = this.buildPageEvents(readPageViewsStorage()); + // loadPageViews() runs the legacy migration, which touches localStorage + // directly and can throw (e.g. access denied, quota). A read failure must + // not break placement selection — fall back to no page events, matching the + // best-effort posture of the capture and clear paths. + let storedPageViews: PageEvent[] = []; + try { + storedPageViews = loadPageViews(); + } catch (err) { + this.errorReportingService?.report({ + message: 'Rokt Kit: Failed to load page views for selectPlacements', + code: 'PAGE_VIEW_CAPTURE_FAILED', + severity: WSDKErrorSeverity.INFO, + stackTrace: err instanceof Error ? err.stack : undefined, + }); + } + const pageEvents = this.buildPageEvents(storedPageViews); const selectPlacementsAttributes: Record = { ...(filteredUserIdentities as Record), diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index dc18813..3517c59 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5639,7 +5639,7 @@ describe('Rokt Forwarder', () => { describe('page view capture', () => { const readStoredPageViews = () => { - const raw = window.localStorage.getItem('mpPageViews'); + const raw = window.localStorage.getItem('mp-rokt-kit.pageViews'); return raw === null ? null : JSON.parse(raw); }; @@ -5815,6 +5815,164 @@ describe('Rokt Forwarder', () => { expect(readStoredPageViews()).toBeNull(); }); + describe('legacy storage migration', () => { + const LEGACY_KEY = 'mpPageViews'; + const NEW_KEY = 'mp-rokt-kit.pageViews'; + + const readRaw = (key: string) => { + const raw = window.localStorage.getItem(key); + return raw === null ? null : JSON.parse(raw); + }; + + const initKit = async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + }; + + const runSelectPlacements = async () => { + (window as any).mParticle._Store.localSessionAttributes = {}; + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + return (window as any).mParticle.Rokt.selectPlacementsOptions.attributes; + }; + + it('adopts legacy history into the new key and sweeps the legacy key on read', async () => { + const seeded = [ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-1', + timestamp: 1712345678000, + }, + ]; + window.localStorage.setItem(LEGACY_KEY, JSON.stringify(seeded)); + + await initKit(); + const attributes = await runSelectPlacements(); + + // Legacy history surfaces on read (adopted into the new key). + expect(JSON.parse(attributes.page_events)).toEqual(seeded); + expect(readRaw(NEW_KEY)).toEqual(seeded); + // Legacy key is always swept. + expect(readRaw(LEGACY_KEY)).toBeNull(); + }); + + it('keeps the new key and sweeps the legacy key when both exist', async () => { + const legacy = [ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-1', + timestamp: 1712345678000, + }, + ]; + const current = [ + { + pageUrl: 'https://example.com/current', + sourceMessageId: 'current-1', + timestamp: 1712345679000, + }, + ]; + window.localStorage.setItem(LEGACY_KEY, JSON.stringify(legacy)); + window.localStorage.setItem(NEW_KEY, JSON.stringify(current)); + + await initKit(); + const attributes = await runSelectPlacements(); + + // New key wins — legacy value is discarded, not merged. + expect(JSON.parse(attributes.page_events)).toEqual(current); + expect(readRaw(NEW_KEY)).toEqual(current); + expect(readRaw(LEGACY_KEY)).toBeNull(); + }); + + it('leaves the new key untouched when there is no legacy key', async () => { + const current = [ + { + pageUrl: 'https://example.com/current', + sourceMessageId: 'current-1', + timestamp: 1712345679000, + }, + ]; + window.localStorage.setItem(NEW_KEY, JSON.stringify(current)); + + await initKit(); + const attributes = await runSelectPlacements(); + + expect(JSON.parse(attributes.page_events)).toEqual(current); + expect(readRaw(NEW_KEY)).toEqual(current); + expect(readRaw(LEGACY_KEY)).toBeNull(); + }); + + it('sweeps the legacy key on SessionEnd before clearing the new key', async () => { + window.localStorage.setItem( + LEGACY_KEY, + JSON.stringify([ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-1', + timestamp: 1712345678000, + }, + ]), + ); + + await initKit(); + + (window as any).mParticle.forwarder.process({ + EventName: 'Session End', + EventCategory: EventType.Unknown, + EventDataType: MessageType.SessionEnd, + SourceMessageId: 'source-message-id-session-end', + Timestamp: 1712345679000, + }); + + expect(readRaw(LEGACY_KEY)).toBeNull(); + expect(readRaw(NEW_KEY)).toBeNull(); + }); + + it('does not throw out of selectPlacements when the migration hits a storage error', async () => { + // Legacy present + new absent → migration attempts the adopt setItem, + // which throws here. The read path must swallow it (best-effort) so + // placement selection still proceeds without page events. + window.localStorage.setItem( + LEGACY_KEY, + JSON.stringify([ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-1', + timestamp: 1712345678000, + }, + ]), + ); + + await initKit(); + + const reportSpy = vi.spyOn((window as any).mParticle.forwarder.errorReportingService, 'report'); + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string) => { + if (key === NEW_KEY) { + throw new Error('QuotaExceededError'); + } + }); + + try { + const attributes = await runSelectPlacements(); + // Selection proceeds; page events are simply omitted. + expect(attributes.page_events).toBeUndefined(); + } finally { + setItemSpy.mockRestore(); + } + + expect(reportSpy).toHaveBeenCalledWith( + expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED', severity: 'INFO' }), + ); + reportSpy.mockRestore(); + }); + }); + it('captures the page view but returns the not-ready signal when the kit is not ready', () => { // Force a not-ready state: capture must still run (kit-owned storage), // but process() must tell the core SDK the forwarder is not ready. @@ -5870,7 +6028,7 @@ describe('Rokt Forwarder', () => { expect(readStoredPageViews()).toBeNull(); }); - it('does not throw and reports a warning when localStorage writes throw', async () => { + it('does not throw and silently drops the page view when localStorage writes throw', async () => { await (window as any).mParticle.forwarder.init( { accountId: '123456', @@ -5905,10 +6063,9 @@ describe('Rokt Forwarder', () => { // Nothing is persisted, but the forwarder keeps running. expect(readStoredPageViews()).toBeNull(); - // The write failure is surfaced as an INFO (rate-limited per severity). - expect(reportSpy).toHaveBeenCalledWith( - expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED', severity: 'INFO' }), - ); + // The write is best-effort: writeJSON swallows the failure, so nothing + // is reported for it (see writeJSON/removeKey — swallow and forget). + expect(reportSpy).not.toHaveBeenCalledWith(expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED' })); reportSpy.mockRestore(); }); @@ -6158,7 +6315,7 @@ describe('Rokt Forwarder', () => { // against the next (300000 - 0) and invent a 5-minute dwell that never // happened; "unknown" must stay distinguishable from a genuine zero. window.localStorage.setItem( - 'mpPageViews', + 'mp-rokt-kit.pageViews', JSON.stringify([ { pageUrl: 'https://example.com/a', @@ -6202,7 +6359,7 @@ describe('Rokt Forwarder', () => { it('clears stored page views on init when targeting is disabled', async () => { // Seed a stored page view from a period when targeting was permitted. window.localStorage.setItem( - 'mpPageViews', + 'mp-rokt-kit.pageViews', JSON.stringify([ { pageUrl: 'https://example.com/', From d49ba2ed6ba18835e6a4003de130766d852be465 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 10:27:43 -0400 Subject: [PATCH 02/12] refactor: surface page-view persistence failures as diagnostic logs Route page-view read/write/capture failures through loggingService.log (INFO diagnostic) instead of errorReportingService.report, matching the best-effort-cache posture. writeJSON now returns a success flag so a failed persist is observable without breaking the caller. Add a test pinning the intentional behavior that the targeting-disabled init clear does not sweep the legacy mpPageViews key. --- src/Rokt-Kit.ts | 48 +++++++++++++++++++----------- test/src/tests.spec.ts | 67 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 90 insertions(+), 25 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index e18a160..4834019 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -330,15 +330,17 @@ function readJSON(key: string): unknown { } } -// writeJSON/removeKey swallow storage failures (private mode, quota, access -// denied) rather than throw: persisted page views are a best-effort cache, and -// a failed write/remove must never break the caller. Failures are intentionally -// not reported here — revisit if these keys ever hold must-persist data. -function writeJSON(key: string, value: unknown): void { +// writeJSON/removeKey never throw on storage failure (private mode, quota, +// access denied): persisted page views are a best-effort cache, and a failed +// write/remove must not break the caller. writeJSON returns whether the write +// landed so callers can surface a diagnostic; removeKey is fire-and-forget (a +// failed remove only risks orphaned data, resolved by the next write/clear). +function writeJSON(key: string, value: unknown): boolean { try { window.localStorage.setItem(key, JSON.stringify(value)); + return true; } catch { - // no-op + return false; } } @@ -378,8 +380,8 @@ function loadPageViews(): PageEvent[] { return Array.isArray(parsed) ? (parsed as PageEvent[]) : []; } -function writePageViewsStorage(pageViews: PageEvent[]): void { - writeJSON(LS_PAGE_VIEWS_KEY, pageViews); +function writePageViewsStorage(pageViews: PageEvent[]): boolean { + return writeJSON(LS_PAGE_VIEWS_KEY, pageViews); } function clearPageViewsStorage(): void { @@ -981,13 +983,22 @@ class RoktKit implements KitInterface { pageViews.shift(); } - writePageViewsStorage(pageViews); + if (!writePageViewsStorage(pageViews)) { + // Best-effort cache: a failed persist only means fewer page events are + // forwarded later. Surface it as a diagnostic INFO log, not an error. + this.loggingService?.log({ + message: `Rokt Kit: Failed to persist page view for ${pageUrl}`, + code: 'PAGE_VIEW_CAPTURE_FAILED', + }); + } } catch (err) { - this.errorReportingService?.report({ - message: `Rokt Kit: Failed to capture page view for ${pageUrl}`, + // sanitizeUrl / loadPageViews (legacy migration) failure — same best-effort + // posture: capture is skipped, no user-facing breakage. Diagnostic INFO log. + this.loggingService?.log({ + message: `Rokt Kit: Failed to capture page view for ${pageUrl}: ${ + err instanceof Error ? err.message : String(err) + }`, code: 'PAGE_VIEW_CAPTURE_FAILED', - severity: WSDKErrorSeverity.INFO, - stackTrace: err instanceof Error ? err.stack : undefined, }); } } @@ -1596,11 +1607,14 @@ class RoktKit implements KitInterface { try { storedPageViews = loadPageViews(); } catch (err) { - this.errorReportingService?.report({ - message: 'Rokt Kit: Failed to load page views for selectPlacements', + // A read/migration failure is a benign, best-effort miss (fall back to no + // page events) — not an SDK error. Surface it as a diagnostic INFO log + // rather than an error report. + this.loggingService?.log({ + message: `Rokt Kit: Failed to load page views for selectPlacements: ${ + err instanceof Error ? err.message : String(err) + }`, code: 'PAGE_VIEW_CAPTURE_FAILED', - severity: WSDKErrorSeverity.INFO, - stackTrace: err instanceof Error ? err.stack : undefined, }); } const pageEvents = this.buildPageEvents(storedPageViews); diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 3517c59..90af170 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5951,7 +5951,9 @@ describe('Rokt Forwarder', () => { await initKit(); - const reportSpy = vi.spyOn((window as any).mParticle.forwarder.errorReportingService, 'report'); + // A read/migration failure is surfaced as a diagnostic INFO log + // (loggingService.log), not an error report. + const logSpy = vi.spyOn((window as any).mParticle.forwarder.loggingService, 'log'); const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string) => { if (key === NEW_KEY) { throw new Error('QuotaExceededError'); @@ -5966,10 +5968,8 @@ describe('Rokt Forwarder', () => { setItemSpy.mockRestore(); } - expect(reportSpy).toHaveBeenCalledWith( - expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED', severity: 'INFO' }), - ); - reportSpy.mockRestore(); + expect(logSpy).toHaveBeenCalledWith(expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED' })); + logSpy.mockRestore(); }); }); @@ -6028,7 +6028,7 @@ describe('Rokt Forwarder', () => { expect(readStoredPageViews()).toBeNull(); }); - it('does not throw and silently drops the page view when localStorage writes throw', async () => { + it('does not throw and logs a diagnostic when localStorage writes throw', async () => { await (window as any).mParticle.forwarder.init( { accountId: '123456', @@ -6042,6 +6042,7 @@ describe('Rokt Forwarder', () => { await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); const reportSpy = vi.spyOn((window as any).mParticle.forwarder.errorReportingService, 'report'); + const logSpy = vi.spyOn((window as any).mParticle.forwarder.loggingService, 'log'); const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('QuotaExceededError'); }); @@ -6063,9 +6064,11 @@ describe('Rokt Forwarder', () => { // Nothing is persisted, but the forwarder keeps running. expect(readStoredPageViews()).toBeNull(); - // The write is best-effort: writeJSON swallows the failure, so nothing - // is reported for it (see writeJSON/removeKey — swallow and forget). + // The failed write is best-effort: surfaced as a diagnostic INFO log + // (loggingService.log), never an error report. + expect(logSpy).toHaveBeenCalledWith(expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED' })); expect(reportSpy).not.toHaveBeenCalledWith(expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED' })); + logSpy.mockRestore(); reportSpy.mockRestore(); }); @@ -6397,6 +6400,54 @@ describe('Rokt Forwarder', () => { expect(forwardedAttributes.page_events).toBeUndefined(); }); + it('does not sweep the legacy key on init when targeting is disabled', async () => { + // The targeting-disabled clear path (initForwarder) intentionally only + // clears the kit-owned new key; it does not run the legacy migration. + // A user with targeting off keeps an orphaned legacy `mpPageViews` until + // the shim's removal date — benign, and swept the moment targeting is + // re-enabled (loadPageViews) or a SessionEnd fires. + window.localStorage.setItem( + 'mpPageViews', + JSON.stringify([ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-seeded', + timestamp: 1712345678000, + }, + ]), + ); + window.localStorage.setItem( + 'mp-rokt-kit.pageViews', + JSON.stringify([ + { + pageUrl: 'https://example.com/', + sourceMessageId: 'seeded', + timestamp: 1712345678000, + }, + ]), + ); + + (window as any).mParticle.Rokt.launcherOptions = { + noTargeting: true, + }; + + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + // New key is cleared; legacy key is left untouched (not swept on this path). + expect(readStoredPageViews()).toBeNull(); + expect(window.localStorage.getItem('mpPageViews')).not.toBeNull(); + }); + it('strips query params from the captured pageUrl', async () => { const originalLocation = window.location; // Query params commonly carry PII (emails, tokens); they must not be captured. From 48a9fe8db1a449bbea8a908e6b1a97f3e58ebbc4 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 10:55:07 -0400 Subject: [PATCH 03/12] refactor: extract localStorage helpers into src/storage.ts Move the key-agnostic localStorage wrappers (readJSON/writeJSON/removeKey) out of Rokt-Kit.ts into a dedicated src/storage.ts module and import them. Page-view semantics (keys, migration, load/write/clear) stay in Rokt-Kit.ts. - Add test/src/storage.spec.ts with dedicated unit tests for the helpers (happy paths plus getItem/setItem/removeItem throwing). - Trim comments in storage.ts to the one load-bearing "never throw / best-effort" rationale. - Update AGENTS.md: reflect the multi-module source layout, note co-located src/**/*.spec.ts are run by Vitest, and add a no-unnecessary-comments convention. --- AGENTS.md | 12 ++++-- src/Rokt-Kit.ts | 38 +++---------------- src/storage.ts | 30 +++++++++++++++ test/src/storage.spec.ts | 80 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 37 deletions(-) create mode 100644 src/storage.ts create mode 100644 test/src/storage.spec.ts diff --git a/AGENTS.md b/AGENTS.md index bbfd3fb..cc7924f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,14 +22,17 @@ The Rokt web kit (`@mparticle/web-rokt-kit`) is an mParticle integration kit (fo ``` / src/ - Rokt-Kit.ts # Single monolithic source file + 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 # Vitest test suite + 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 @@ -58,11 +61,12 @@ The `dist/` folder, `CHANGELOG.md`, and version bumps in `package.json`/`package ## Code Conventions -- **Single source file**: All kit logic lives in `src/Rokt-Kit.ts` +- **Mostly one source file**: The bulk of kit logic lives in `src/Rokt-Kit.ts`. A few cohesive, reusable concerns are extracted into sibling modules (`storage.ts`, `selectPlacementsAttributePersistence.ts`) and imported. Vite/Rollup bundles all source files into the single `dist/` output, so extraction doesn't change the shipped bundle shape. Prefer keeping new logic in `Rokt-Kit.ts` unless it's a self-contained, independently-testable concern. - **TypeScript class pattern**: `class RoktKit { ... }` with typed public/private members - **const/let**: Use `const` for values that don't change, `let` for reassignable variables - **Strict TypeScript**: `strict: true` — all values must be typed, no implicit `any` - **Module registration**: Kit self-registers via `window.mParticle.addForwarder()` at load time +- **No unnecessary comments**: Don't restate what the code already says. Reserve comments for non-obvious *why* — rationale, invariants, gotchas (e.g. why storage writes swallow errors, why a migration is byte-for-byte). Delete comments that a reader could infer from the code itself. ## Architecture @@ -81,7 +85,7 @@ The `dist/` folder, `CHANGELOG.md`, and version bumps in `package.json`/`package ## Common Gotchas -1. **Single file**: All changes go in `src/Rokt-Kit.ts` — there are no imports/modules +1. **Mostly one file**: Most changes go in `src/Rokt-Kit.ts`, which imports a few sibling modules (`storage.ts`, `selectPlacementsAttributePersistence.ts`). Co-located `*.spec.ts` under `src/` are also picked up by Vitest (see `vite.config.ts` `test.include`) 2. **Browser-only**: Code runs in browser context, `window` is always available 3. **Async launcher**: Rokt launcher loads asynchronously — events must be queued until ready 4. **Window extensions**: `window.Rokt` and `window.mParticle.Rokt` are typed via `declare global` diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 4834019..e5a330b 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -24,6 +24,8 @@ import { removeSelectPlacementsAttributePersistenceDeniedAttributes, } from './selectPlacementsAttributePersistence'; +import { readJSON, writeJSON, removeKey } from './storage'; + interface RoktKitSettings { accountId: string; roktExtensions?: string; @@ -318,39 +320,9 @@ function mp(): MParticleExtended { // Module-level utility functions // ============================================================ -// Key-agnostic localStorage helpers. Page-view semantics (array shape, count -// cap, migration) live in the callers below so these can be reused verbatim -// for future `mp-rokt-kit.*` keys. -function readJSON(key: string): unknown { - try { - const stored = window.localStorage.getItem(key); - return stored === null ? null : JSON.parse(stored); - } catch { - return null; - } -} - -// writeJSON/removeKey never throw on storage failure (private mode, quota, -// access denied): persisted page views are a best-effort cache, and a failed -// write/remove must not break the caller. writeJSON returns whether the write -// landed so callers can surface a diagnostic; removeKey is fire-and-forget (a -// failed remove only risks orphaned data, resolved by the next write/clear). -function writeJSON(key: string, value: unknown): boolean { - try { - window.localStorage.setItem(key, JSON.stringify(value)); - return true; - } catch { - return false; - } -} - -function removeKey(key: string): void { - try { - window.localStorage.removeItem(key); - } catch { - // no-op - } -} +// Key-agnostic localStorage helpers (readJSON / writeJSON / removeKey) live in +// ./storage. Page-view semantics (array shape, count cap, migration) stay here +// in the callers below. // TODO: remove after 2027-02-11 — one-time migration of the legacy 'mpPageViews' // key to the prefixed LS_PAGE_VIEWS_KEY. Everything migration-related is confined diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..b2b60fd --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,30 @@ +export function readJSON(key: string): unknown { + try { + const stored = window.localStorage.getItem(key); + return stored === null ? null : JSON.parse(stored); + } catch { + return null; + } +} + +// writeJSON/removeKey never throw on storage failure (private mode, quota, +// access denied): kit-owned storage is a best-effort cache, and a failed +// write/remove must not break the caller. writeJSON returns whether the write +// landed so callers can surface a diagnostic; removeKey is fire-and-forget (a +// failed remove only risks orphaned data, resolved by the next write/clear). +export function writeJSON(key: string, value: unknown): boolean { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch { + return false; + } +} + +export function removeKey(key: string): void { + try { + window.localStorage.removeItem(key); + } catch { + // no-op + } +} diff --git a/test/src/storage.spec.ts b/test/src/storage.spec.ts new file mode 100644 index 0000000..654fec0 --- /dev/null +++ b/test/src/storage.spec.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readJSON, writeJSON, removeKey } from '../../src/storage'; + +describe('storage: key-agnostic localStorage helpers', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + }); + + describe('readJSON', () => { + it('returns the parsed value for a stored JSON string', () => { + window.localStorage.setItem('k', JSON.stringify({ a: 1, b: [2, 3] })); + expect(readJSON('k')).toEqual({ a: 1, b: [2, 3] }); + }); + + it('round-trips values written by writeJSON', () => { + writeJSON('k', ['x', 'y']); + expect(readJSON('k')).toEqual(['x', 'y']); + }); + + it('returns null when the key is absent', () => { + expect(readJSON('missing')).toBeNull(); + }); + + it('returns null for malformed JSON (does not throw)', () => { + window.localStorage.setItem('k', '{not valid json'); + expect(readJSON('k')).toBeNull(); + }); + + it('returns null when getItem throws (access denied)', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + expect(readJSON('k')).toBeNull(); + }); + }); + + describe('writeJSON', () => { + it('persists the value as a JSON string and returns true', () => { + expect(writeJSON('k', { hello: 'world' })).toBe(true); + expect(window.localStorage.getItem('k')).toBe(JSON.stringify({ hello: 'world' })); + }); + + it('returns false when setItem throws (quota exceeded / private mode)', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + expect(writeJSON('k', { hello: 'world' })).toBe(false); + }); + + it('overwrites an existing value', () => { + writeJSON('k', 1); + writeJSON('k', 2); + expect(readJSON('k')).toBe(2); + }); + }); + + describe('removeKey', () => { + it('removes the stored key', () => { + window.localStorage.setItem('k', '1'); + removeKey('k'); + expect(window.localStorage.getItem('k')).toBeNull(); + }); + + it('does not throw when removeItem throws', () => { + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + expect(() => removeKey('k')).not.toThrow(); + }); + + it('is a no-op for an absent key', () => { + expect(() => removeKey('missing')).not.toThrow(); + }); + }); +}); From fb2a4c3502bbfe9ea1d8a02cea1cfc50d4904a08 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 11:00:25 -0400 Subject: [PATCH 04/12] refactor: drop redundant comments left by storage extraction --- src/Rokt-Kit.ts | 4 ---- src/storage.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index e5a330b..b77c79b 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -320,10 +320,6 @@ function mp(): MParticleExtended { // Module-level utility functions // ============================================================ -// Key-agnostic localStorage helpers (readJSON / writeJSON / removeKey) live in -// ./storage. Page-view semantics (array shape, count cap, migration) stay here -// in the callers below. - // TODO: remove after 2027-02-11 — one-time migration of the legacy 'mpPageViews' // key to the prefixed LS_PAGE_VIEWS_KEY. Everything migration-related is confined // to this function + LEGACY_PAGE_VIEWS_KEY so it can be deleted as a single unit. diff --git a/src/storage.ts b/src/storage.ts index b2b60fd..4a9e80d 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -25,6 +25,6 @@ export function removeKey(key: string): void { try { window.localStorage.removeItem(key); } catch { - // no-op + /* empty */ } } From 7b4c9fbc699773355bc374cc2086f454e7ab8238 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 11:03:55 -0400 Subject: [PATCH 05/12] fix: Remove unnecessary comments --- src/Rokt-Kit.ts | 33 ++------------------------------- src/storage.ts | 5 ----- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index b77c79b..807cbea 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -259,21 +259,9 @@ 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 -// localStorage key under which captured page views are persisted (as a JSON -// string). The kit owns this storage directly — separate from mParticle's -// cookie/localStorage — so page-view capture does not affect mParticle -// persistence or cookie sync. Distinct from PAGE_EVENTS_KEY, which is the -// flattened wire shape sent to Rokt on selectPlacements. Namespaced under the -// kit-owned `mp-rokt-kit.*` prefix; future kit-owned keys follow the same -// `mp-rokt-kit.` convention. const LS_PAGE_VIEWS_KEY = 'mp-rokt-kit.pageViews'; -// Legacy unprefixed key this feature originally shipped under. Read once and -// swept by migrateLegacyPageViewStorage() so existing history survives the -// rename. // TODO: remove after 2027-02-11 — one-time migration of the legacy key. const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; -// Fixed cap on the number of persisted page views (oldest evicted first). Code -// constant, not a kit setting — change it here. const PAGE_VIEWS_MAX_COUNT = 25; const PAGE_EVENTS_KEY = 'page_events'; @@ -326,22 +314,16 @@ function mp(): MParticleExtended { // Unconditional (no freshness gate): staleness is mParticle's job — a timed-out // prior session fires SessionEnd (→ clear) before selectPlacements runs. function migrateLegacyPageViewStorage(): void { - // The read + adopt work on the opaque stored string on purpose — no - // readJSON/writeJSON round-trip. That keeps the move byte-for-byte and lets - // us still sweep malformed legacy data (readJSON would collapse "absent" and - // "malformed" to null and leave garbage behind). The sweep uses removeKey. const legacy = window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY); if (legacy === null) { return; } if (window.localStorage.getItem(LS_PAGE_VIEWS_KEY) === null) { - window.localStorage.setItem(LS_PAGE_VIEWS_KEY, legacy); // adopt-if-empty + window.localStorage.setItem(LS_PAGE_VIEWS_KEY, legacy); } - removeKey(LEGACY_PAGE_VIEWS_KEY); // always sweep + removeKey(LEGACY_PAGE_VIEWS_KEY); } -// Single entry point for reading persisted page views. Runs the one-time legacy -// migration first, then returns the stored array (or [] if absent/malformed). function loadPageViews(): PageEvent[] { migrateLegacyPageViewStorage(); const parsed = readJSON(LS_PAGE_VIEWS_KEY); @@ -952,16 +934,12 @@ class RoktKit implements KitInterface { } if (!writePageViewsStorage(pageViews)) { - // Best-effort cache: a failed persist only means fewer page events are - // forwarded later. Surface it as a diagnostic INFO log, not an error. this.loggingService?.log({ message: `Rokt Kit: Failed to persist page view for ${pageUrl}`, code: 'PAGE_VIEW_CAPTURE_FAILED', }); } } catch (err) { - // sanitizeUrl / loadPageViews (legacy migration) failure — same best-effort - // posture: capture is skipped, no user-facing breakage. Diagnostic INFO log. this.loggingService?.log({ message: `Rokt Kit: Failed to capture page view for ${pageUrl}: ${ err instanceof Error ? err.message : String(err) @@ -1567,17 +1545,10 @@ class RoktKit implements KitInterface { const filteredUserIdentities = this.returnUserIdentities(filteredUser); const sessionAttributes = this.returnLocalSessionAttributes(); - // loadPageViews() runs the legacy migration, which touches localStorage - // directly and can throw (e.g. access denied, quota). A read failure must - // not break placement selection — fall back to no page events, matching the - // best-effort posture of the capture and clear paths. let storedPageViews: PageEvent[] = []; try { storedPageViews = loadPageViews(); } catch (err) { - // A read/migration failure is a benign, best-effort miss (fall back to no - // page events) — not an SDK error. Surface it as a diagnostic INFO log - // rather than an error report. this.loggingService?.log({ message: `Rokt Kit: Failed to load page views for selectPlacements: ${ err instanceof Error ? err.message : String(err) diff --git a/src/storage.ts b/src/storage.ts index 4a9e80d..1332b47 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -7,11 +7,6 @@ export function readJSON(key: string): unknown { } } -// writeJSON/removeKey never throw on storage failure (private mode, quota, -// access denied): kit-owned storage is a best-effort cache, and a failed -// write/remove must not break the caller. writeJSON returns whether the write -// landed so callers can surface a diagnostic; removeKey is fire-and-forget (a -// failed remove only risks orphaned data, resolved by the next write/clear). export function writeJSON(key: string, value: unknown): boolean { try { window.localStorage.setItem(key, JSON.stringify(value)); From b008b5e3f38f981fffe8243066329d542b8d91cc Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 11:13:28 -0400 Subject: [PATCH 06/12] docs: encourage modular extraction over single-file kit source --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cc7924f..7317c26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ The `dist/` folder, `CHANGELOG.md`, and version bumps in `package.json`/`package ## Code Conventions -- **Mostly one source file**: The bulk of kit logic lives in `src/Rokt-Kit.ts`. A few cohesive, reusable concerns are extracted into sibling modules (`storage.ts`, `selectPlacementsAttributePersistence.ts`) and imported. Vite/Rollup bundles all source files into the single `dist/` output, so extraction doesn't change the shipped bundle shape. Prefer keeping new logic in `Rokt-Kit.ts` unless it's a self-contained, independently-testable concern. +- **Prefer small, focused modules**: `src/Rokt-Kit.ts` is the entry point (forwarder class + registration), but favor extracting cohesive concerns into sibling modules (as with `storage.ts`, `selectPlacementsAttributePersistence.ts`) rather than growing `Rokt-Kit.ts`. Vite/Rollup bundles all source files into the single `dist/` output, so extraction is free — it doesn't change the shipped bundle shape. When you add or touch a self-contained concern (storage, serialization, a deny-list, event mapping, etc.), pull it into its own module with a clear name and a co-located `*.spec.ts`. Keep only orchestration and kit lifecycle in `Rokt-Kit.ts`. - **TypeScript class pattern**: `class RoktKit { ... }` with typed public/private members - **const/let**: Use `const` for values that don't change, `let` for reassignable variables - **Strict TypeScript**: `strict: true` — all values must be typed, no implicit `any` @@ -85,7 +85,7 @@ The `dist/` folder, `CHANGELOG.md`, and version bumps in `package.json`/`package ## Common Gotchas -1. **Mostly one file**: Most changes go in `src/Rokt-Kit.ts`, which imports a few sibling modules (`storage.ts`, `selectPlacementsAttributePersistence.ts`). Co-located `*.spec.ts` under `src/` are also picked up by Vitest (see `vite.config.ts` `test.include`) +1. **Favor modular extraction**: `src/Rokt-Kit.ts` is the entry point, but prefer splitting self-contained concerns into sibling modules (e.g. `storage.ts`, `selectPlacementsAttributePersistence.ts`) rather than growing the entry file. Co-located `*.spec.ts` under `src/` are picked up by Vitest (see `vite.config.ts` `test.include`), so each extracted module can carry its own unit tests 2. **Browser-only**: Code runs in browser context, `window` is always available 3. **Async launcher**: Rokt launcher loads asynchronously — events must be queued until ready 4. **Window extensions**: `window.Rokt` and `window.mParticle.Rokt` are typed via `declare global` From f466d079f1e8f71f2d061cdbb567033850ef7681 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 11:36:52 -0400 Subject: [PATCH 07/12] refactor: simplify legacy page-view key guard to falsy check --- src/Rokt-Kit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 807cbea..6053bda 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -315,7 +315,7 @@ function mp(): MParticleExtended { // prior session fires SessionEnd (→ clear) before selectPlacements runs. function migrateLegacyPageViewStorage(): void { const legacy = window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY); - if (legacy === null) { + if (!legacy) { return; } if (window.localStorage.getItem(LS_PAGE_VIEWS_KEY) === null) { From 979f4d943c44ecb6c3c1dc9c9b528632d17ffc49 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 12:06:31 -0400 Subject: [PATCH 08/12] refactor: store page views as a field under a single namespaced key Group kit-owned localStorage state as fields on one JSON object under the 'mp-rokt-kit' key instead of a flat 'mp-rokt-kit.pageViews' key, so future items can share the namespace. Add read/write/removeNamespacedField helpers and migrate the legacy 'mpPageViews' array into the pageViews field. --- src/Rokt-Kit.ts | 27 ++++++---- src/storage.ts | 30 +++++++++++ test/src/storage.spec.ts | 79 ++++++++++++++++++++++++++++- test/src/tests.spec.ts | 106 ++++++++++++++++++++++----------------- 4 files changed, 186 insertions(+), 56 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 6053bda..0712153 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -24,7 +24,7 @@ import { removeSelectPlacementsAttributePersistenceDeniedAttributes, } from './selectPlacementsAttributePersistence'; -import { readJSON, writeJSON, removeKey } from './storage'; +import { readJSON, removeKey, readNamespacedField, writeNamespacedField, removeNamespacedField } from './storage'; interface RoktKitSettings { accountId: string; @@ -259,7 +259,8 @@ 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_PAGE_VIEWS_KEY = 'mp-rokt-kit.pageViews'; +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; @@ -309,8 +310,9 @@ function mp(): MParticleExtended { // ============================================================ // TODO: remove after 2027-02-11 — one-time migration of the legacy 'mpPageViews' -// key to the prefixed LS_PAGE_VIEWS_KEY. Everything migration-related is confined -// to this function + LEGACY_PAGE_VIEWS_KEY so it can be deleted as a single unit. +// 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(): void { @@ -318,24 +320,29 @@ function migrateLegacyPageViewStorage(): void { if (!legacy) { return; } - if (window.localStorage.getItem(LS_PAGE_VIEWS_KEY) === null) { - window.localStorage.setItem(LS_PAGE_VIEWS_KEY, legacy); + if (readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD) === undefined) { + const legacyViews = readJSON(LEGACY_PAGE_VIEWS_KEY); + // A failed adopt must not sweep the legacy key — throw so the caller's + // diagnostic path handles it and the next read can retry. + if (Array.isArray(legacyViews) && !writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, legacyViews)) { + throw new Error('Rokt Kit: Failed to migrate legacy page-view storage'); + } } removeKey(LEGACY_PAGE_VIEWS_KEY); } function loadPageViews(): PageEvent[] { migrateLegacyPageViewStorage(); - const parsed = readJSON(LS_PAGE_VIEWS_KEY); - return Array.isArray(parsed) ? (parsed as PageEvent[]) : []; + const stored = readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD); + return Array.isArray(stored) ? (stored as PageEvent[]) : []; } function writePageViewsStorage(pageViews: PageEvent[]): boolean { - return writeJSON(LS_PAGE_VIEWS_KEY, pageViews); + return writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews); } function clearPageViewsStorage(): void { - removeKey(LS_PAGE_VIEWS_KEY); + removeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD); } function generateLauncherScript(domain: string | undefined, extensions: string[]): string { diff --git a/src/storage.ts b/src/storage.ts index 1332b47..9e06bd3 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -23,3 +23,33 @@ export function removeKey(key: string): void { /* empty */ } } + +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; +} + +export function writeNamespacedField(namespaceKey: string, field: string, value: unknown): boolean { + const blob = readJSON(namespaceKey); + const next = isPlainObject(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)) { + return; + } + const next = { ...blob }; + delete next[field]; + if (Object.keys(next).length === 0) { + removeKey(namespaceKey); + } else { + writeJSON(namespaceKey, next); + } +} diff --git a/test/src/storage.spec.ts b/test/src/storage.spec.ts index 654fec0..985ae9a 100644 --- a/test/src/storage.spec.ts +++ b/test/src/storage.spec.ts @@ -1,5 +1,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { readJSON, writeJSON, removeKey } from '../../src/storage'; +import { + readJSON, + writeJSON, + removeKey, + readNamespacedField, + writeNamespacedField, + removeNamespacedField, +} from '../../src/storage'; describe('storage: key-agnostic localStorage helpers', () => { beforeEach(() => { @@ -77,4 +84,74 @@ describe('storage: key-agnostic localStorage helpers', () => { expect(() => removeKey('missing')).not.toThrow(); }); }); + + describe('namespaced fields', () => { + const NS = '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] }); + }); + + it('readNamespacedField returns the stored field value', () => { + writeNamespacedField(NS, 'pageViews', ['a']); + expect(readNamespacedField(NS, '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 } }); + }); + + 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); + }); + + it('readNamespacedField returns undefined when the key is absent', () => { + expect(readNamespacedField(NS, 'pageViews')).toBeUndefined(); + }); + + it('readNamespacedField returns undefined when the field is absent', () => { + writeNamespacedField(NS, 'other', 1); + expect(readNamespacedField(NS, '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(); + }); + + 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); + }); + + 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 }); + }); + + 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(); + }); + + 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 }); + }); + }); }); diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 90af170..8f6e49d 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5639,8 +5639,10 @@ describe('Rokt Forwarder', () => { describe('page view capture', () => { const readStoredPageViews = () => { - const raw = window.localStorage.getItem('mp-rokt-kit.pageViews'); - return raw === null ? null : JSON.parse(raw); + const raw = window.localStorage.getItem('mp-rokt-kit'); + if (raw === null) return null; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' && 'pageViews' in parsed ? parsed.pageViews : null; }; beforeEach(() => { @@ -5817,13 +5819,21 @@ describe('Rokt Forwarder', () => { describe('legacy storage migration', () => { const LEGACY_KEY = 'mpPageViews'; - const NEW_KEY = 'mp-rokt-kit.pageViews'; + const NS_KEY = 'mp-rokt-kit'; const readRaw = (key: string) => { const raw = window.localStorage.getItem(key); return raw === null ? null : JSON.parse(raw); }; + const readNamespacedPageViews = () => { + const blob = readRaw(NS_KEY); + return blob && typeof blob === 'object' && 'pageViews' in blob ? blob.pageViews : null; + }; + + const seedNamespacedPageViews = (views: unknown) => + window.localStorage.setItem(NS_KEY, JSON.stringify({ pageViews: views })); + const initKit = async () => { await (window as any).mParticle.forwarder.init( { @@ -5856,9 +5866,9 @@ describe('Rokt Forwarder', () => { await initKit(); const attributes = await runSelectPlacements(); - // Legacy history surfaces on read (adopted into the new key). + // Legacy history surfaces on read (adopted into the namespaced field). expect(JSON.parse(attributes.page_events)).toEqual(seeded); - expect(readRaw(NEW_KEY)).toEqual(seeded); + expect(readNamespacedPageViews()).toEqual(seeded); // Legacy key is always swept. expect(readRaw(LEGACY_KEY)).toBeNull(); }); @@ -5879,14 +5889,14 @@ describe('Rokt Forwarder', () => { }, ]; window.localStorage.setItem(LEGACY_KEY, JSON.stringify(legacy)); - window.localStorage.setItem(NEW_KEY, JSON.stringify(current)); + seedNamespacedPageViews(current); await initKit(); const attributes = await runSelectPlacements(); - // New key wins — legacy value is discarded, not merged. + // Namespaced field wins — legacy value is discarded, not merged. expect(JSON.parse(attributes.page_events)).toEqual(current); - expect(readRaw(NEW_KEY)).toEqual(current); + expect(readNamespacedPageViews()).toEqual(current); expect(readRaw(LEGACY_KEY)).toBeNull(); }); @@ -5898,13 +5908,13 @@ describe('Rokt Forwarder', () => { timestamp: 1712345679000, }, ]; - window.localStorage.setItem(NEW_KEY, JSON.stringify(current)); + seedNamespacedPageViews(current); await initKit(); const attributes = await runSelectPlacements(); expect(JSON.parse(attributes.page_events)).toEqual(current); - expect(readRaw(NEW_KEY)).toEqual(current); + expect(readNamespacedPageViews()).toEqual(current); expect(readRaw(LEGACY_KEY)).toBeNull(); }); @@ -5931,12 +5941,12 @@ describe('Rokt Forwarder', () => { }); expect(readRaw(LEGACY_KEY)).toBeNull(); - expect(readRaw(NEW_KEY)).toBeNull(); + expect(readNamespacedPageViews()).toBeNull(); }); it('does not throw out of selectPlacements when the migration hits a storage error', async () => { - // Legacy present + new absent → migration attempts the adopt setItem, - // which throws here. The read path must swallow it (best-effort) so + // Legacy present + namespaced field absent → migration attempts the adopt + // write, which throws here. The read path must swallow it (best-effort) so // placement selection still proceeds without page events. window.localStorage.setItem( LEGACY_KEY, @@ -5955,7 +5965,7 @@ describe('Rokt Forwarder', () => { // (loggingService.log), not an error report. const logSpy = vi.spyOn((window as any).mParticle.forwarder.loggingService, 'log'); const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string) => { - if (key === NEW_KEY) { + if (key === NS_KEY) { throw new Error('QuotaExceededError'); } }); @@ -6318,20 +6328,22 @@ describe('Rokt Forwarder', () => { // against the next (300000 - 0) and invent a 5-minute dwell that never // happened; "unknown" must stay distinguishable from a genuine zero. window.localStorage.setItem( - 'mp-rokt-kit.pageViews', - JSON.stringify([ - { - pageUrl: 'https://example.com/a', - sourceMessageId: 'missing-ats', - timestamp: 1712345678000, - }, - { - pageUrl: 'https://example.com/b', - sourceMessageId: 'has-ats', - timestamp: 1712345679000, - activeTimeOnSite: 300000, - }, - ]), + 'mp-rokt-kit', + JSON.stringify({ + pageViews: [ + { + pageUrl: 'https://example.com/a', + sourceMessageId: 'missing-ats', + timestamp: 1712345678000, + }, + { + pageUrl: 'https://example.com/b', + sourceMessageId: 'has-ats', + timestamp: 1712345679000, + activeTimeOnSite: 300000, + }, + ], + }), ); await (window as any).mParticle.forwarder.init( @@ -6362,15 +6374,17 @@ describe('Rokt Forwarder', () => { it('clears stored page views on init when targeting is disabled', async () => { // Seed a stored page view from a period when targeting was permitted. window.localStorage.setItem( - 'mp-rokt-kit.pageViews', - JSON.stringify([ - { - pageUrl: 'https://example.com/', - sourceMessageId: 'seeded', - timestamp: 1712345678000, - activeTimeOnSite: 4200, - }, - ]), + 'mp-rokt-kit', + JSON.stringify({ + pageViews: [ + { + pageUrl: 'https://example.com/', + sourceMessageId: 'seeded', + timestamp: 1712345678000, + activeTimeOnSite: 4200, + }, + ], + }), ); (window as any).mParticle.Rokt.launcherOptions = { @@ -6417,14 +6431,16 @@ describe('Rokt Forwarder', () => { ]), ); window.localStorage.setItem( - 'mp-rokt-kit.pageViews', - JSON.stringify([ - { - pageUrl: 'https://example.com/', - sourceMessageId: 'seeded', - timestamp: 1712345678000, - }, - ]), + 'mp-rokt-kit', + JSON.stringify({ + pageViews: [ + { + pageUrl: 'https://example.com/', + sourceMessageId: 'seeded', + timestamp: 1712345678000, + }, + ], + }), ); (window as any).mParticle.Rokt.launcherOptions = { From 07cc062f7b161be118389d08b7ce25241188a5c3 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 13:07:22 -0400 Subject: [PATCH 09/12] refactor: address PR feedback on migration read and test helpers - migrateLegacyPageViewStorage reads the legacy key once via readJSON instead of a getItem + readJSON pair - tests reuse storage.ts helpers (readJSON/readNamespacedField/ writeNamespacedField) instead of re-implementing localStorage parsing --- src/Rokt-Kit.ts | 17 +++--- test/src/tests.spec.ts | 124 ++++++++++++++++------------------------- 2 files changed, 55 insertions(+), 86 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 0712153..7b9f621 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -316,17 +316,16 @@ function mp(): MParticleExtended { // Unconditional (no freshness gate): staleness is mParticle's job — a timed-out // prior session fires SessionEnd (→ clear) before selectPlacements runs. function migrateLegacyPageViewStorage(): void { - const legacy = window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY); - if (!legacy) { + const legacyViews = readJSON(LEGACY_PAGE_VIEWS_KEY); + if (legacyViews === null) { return; } - if (readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD) === undefined) { - const legacyViews = readJSON(LEGACY_PAGE_VIEWS_KEY); - // A failed adopt must not sweep the legacy key — throw so the caller's - // diagnostic path handles it and the next read can retry. - if (Array.isArray(legacyViews) && !writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, legacyViews)) { - throw new Error('Rokt Kit: Failed to migrate legacy page-view storage'); - } + if ( + readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD) === undefined && + Array.isArray(legacyViews) && + !writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, legacyViews) + ) { + throw new Error('Rokt Kit: Failed to migrate legacy page-view storage'); } removeKey(LEGACY_PAGE_VIEWS_KEY); } diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 8f6e49d..e6b53e2 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5,6 +5,7 @@ import { isSelectPlacementsAttributePersistenceDenied, removeSelectPlacementsAttributePersistenceDeniedAttributes, } from '../../src/selectPlacementsAttributePersistence'; +import { readJSON, readNamespacedField, writeNamespacedField } from '../../src/storage'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -5638,12 +5639,12 @@ describe('Rokt Forwarder', () => { }); describe('page view capture', () => { - const readStoredPageViews = () => { - const raw = window.localStorage.getItem('mp-rokt-kit'); - if (raw === null) return null; - const parsed = JSON.parse(raw); - return parsed && typeof parsed === 'object' && 'pageViews' in parsed ? parsed.pageViews : null; - }; + const NS_KEY = 'mp-rokt-kit'; + const PAGE_VIEWS_FIELD = 'pageViews'; + const LEGACY_KEY = 'mpPageViews'; + + const readStoredPageViews = () => readNamespacedField(NS_KEY, PAGE_VIEWS_FIELD) ?? null; + const seedStoredPageViews = (views: unknown) => writeNamespacedField(NS_KEY, PAGE_VIEWS_FIELD, views); beforeEach(() => { window.localStorage.clear(); @@ -5818,22 +5819,6 @@ describe('Rokt Forwarder', () => { }); describe('legacy storage migration', () => { - const LEGACY_KEY = 'mpPageViews'; - const NS_KEY = 'mp-rokt-kit'; - - const readRaw = (key: string) => { - const raw = window.localStorage.getItem(key); - return raw === null ? null : JSON.parse(raw); - }; - - const readNamespacedPageViews = () => { - const blob = readRaw(NS_KEY); - return blob && typeof blob === 'object' && 'pageViews' in blob ? blob.pageViews : null; - }; - - const seedNamespacedPageViews = (views: unknown) => - window.localStorage.setItem(NS_KEY, JSON.stringify({ pageViews: views })); - const initKit = async () => { await (window as any).mParticle.forwarder.init( { @@ -5868,9 +5853,9 @@ describe('Rokt Forwarder', () => { // Legacy history surfaces on read (adopted into the namespaced field). expect(JSON.parse(attributes.page_events)).toEqual(seeded); - expect(readNamespacedPageViews()).toEqual(seeded); + expect(readStoredPageViews()).toEqual(seeded); // Legacy key is always swept. - expect(readRaw(LEGACY_KEY)).toBeNull(); + expect(readJSON(LEGACY_KEY)).toBeNull(); }); it('keeps the new key and sweeps the legacy key when both exist', async () => { @@ -5889,15 +5874,15 @@ describe('Rokt Forwarder', () => { }, ]; window.localStorage.setItem(LEGACY_KEY, JSON.stringify(legacy)); - seedNamespacedPageViews(current); + seedStoredPageViews(current); await initKit(); const attributes = await runSelectPlacements(); // Namespaced field wins — legacy value is discarded, not merged. expect(JSON.parse(attributes.page_events)).toEqual(current); - expect(readNamespacedPageViews()).toEqual(current); - expect(readRaw(LEGACY_KEY)).toBeNull(); + expect(readStoredPageViews()).toEqual(current); + expect(readJSON(LEGACY_KEY)).toBeNull(); }); it('leaves the new key untouched when there is no legacy key', async () => { @@ -5908,14 +5893,14 @@ describe('Rokt Forwarder', () => { timestamp: 1712345679000, }, ]; - seedNamespacedPageViews(current); + seedStoredPageViews(current); await initKit(); const attributes = await runSelectPlacements(); expect(JSON.parse(attributes.page_events)).toEqual(current); - expect(readNamespacedPageViews()).toEqual(current); - expect(readRaw(LEGACY_KEY)).toBeNull(); + expect(readStoredPageViews()).toEqual(current); + expect(readJSON(LEGACY_KEY)).toBeNull(); }); it('sweeps the legacy key on SessionEnd before clearing the new key', async () => { @@ -5940,8 +5925,8 @@ describe('Rokt Forwarder', () => { Timestamp: 1712345679000, }); - expect(readRaw(LEGACY_KEY)).toBeNull(); - expect(readNamespacedPageViews()).toBeNull(); + expect(readJSON(LEGACY_KEY)).toBeNull(); + expect(readStoredPageViews()).toBeNull(); }); it('does not throw out of selectPlacements when the migration hits a storage error', async () => { @@ -6327,24 +6312,19 @@ describe('Rokt Forwarder', () => { // followed by one that has it. A coerced-to-0 first record would diff // against the next (300000 - 0) and invent a 5-minute dwell that never // happened; "unknown" must stay distinguishable from a genuine zero. - window.localStorage.setItem( - 'mp-rokt-kit', - JSON.stringify({ - pageViews: [ - { - pageUrl: 'https://example.com/a', - sourceMessageId: 'missing-ats', - timestamp: 1712345678000, - }, - { - pageUrl: 'https://example.com/b', - sourceMessageId: 'has-ats', - timestamp: 1712345679000, - activeTimeOnSite: 300000, - }, - ], - }), - ); + seedStoredPageViews([ + { + pageUrl: 'https://example.com/a', + sourceMessageId: 'missing-ats', + timestamp: 1712345678000, + }, + { + pageUrl: 'https://example.com/b', + sourceMessageId: 'has-ats', + timestamp: 1712345679000, + activeTimeOnSite: 300000, + }, + ]); await (window as any).mParticle.forwarder.init( { @@ -6373,19 +6353,14 @@ describe('Rokt Forwarder', () => { it('clears stored page views on init when targeting is disabled', async () => { // Seed a stored page view from a period when targeting was permitted. - window.localStorage.setItem( - 'mp-rokt-kit', - JSON.stringify({ - pageViews: [ - { - pageUrl: 'https://example.com/', - sourceMessageId: 'seeded', - timestamp: 1712345678000, - activeTimeOnSite: 4200, - }, - ], - }), - ); + seedStoredPageViews([ + { + pageUrl: 'https://example.com/', + sourceMessageId: 'seeded', + timestamp: 1712345678000, + activeTimeOnSite: 4200, + }, + ]); (window as any).mParticle.Rokt.launcherOptions = { noTargeting: true, @@ -6421,7 +6396,7 @@ describe('Rokt Forwarder', () => { // the shim's removal date — benign, and swept the moment targeting is // re-enabled (loadPageViews) or a SessionEnd fires. window.localStorage.setItem( - 'mpPageViews', + LEGACY_KEY, JSON.stringify([ { pageUrl: 'https://example.com/legacy', @@ -6430,18 +6405,13 @@ describe('Rokt Forwarder', () => { }, ]), ); - window.localStorage.setItem( - 'mp-rokt-kit', - JSON.stringify({ - pageViews: [ - { - pageUrl: 'https://example.com/', - sourceMessageId: 'seeded', - timestamp: 1712345678000, - }, - ], - }), - ); + seedStoredPageViews([ + { + pageUrl: 'https://example.com/', + sourceMessageId: 'seeded', + timestamp: 1712345678000, + }, + ]); (window as any).mParticle.Rokt.launcherOptions = { noTargeting: true, @@ -6461,7 +6431,7 @@ describe('Rokt Forwarder', () => { // New key is cleared; legacy key is left untouched (not swept on this path). expect(readStoredPageViews()).toBeNull(); - expect(window.localStorage.getItem('mpPageViews')).not.toBeNull(); + expect(readJSON(LEGACY_KEY)).not.toBeNull(); }); it('strips query params from the captured pageUrl', async () => { From 12a3c361c59581f8abfce85dad7cc75d6e9236b0 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 13:16:36 -0400 Subject: [PATCH 10/12] refactor: log instead of throw on failed legacy migration migrateLegacyPageViewStorage takes the logging service and emits a diagnostic INFO log (retaining the legacy key for retry) instead of throwing on a failed adopt. loadPageViews forwards the logger. With the migration no longer throwing, the purpose-built try/catch blocks in selectPlacements and the SessionEnd handler are removed. --- src/Rokt-Kit.ts | 54 +++++++++++++++++++------------------------------ 1 file changed, 21 insertions(+), 33 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 7b9f621..f23b9c3 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -315,23 +315,31 @@ function mp(): MParticleExtended { // 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(): void { +function migrateLegacyPageViewStorage(loggingService: LoggingService | null): void { const legacyViews = readJSON(LEGACY_PAGE_VIEWS_KEY); if (legacyViews === null) { return; } - if ( - readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD) === undefined && - Array.isArray(legacyViews) && - !writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, legacyViews) - ) { - throw new Error('Rokt Kit: Failed to migrate legacy page-view storage'); + + const alreadyMigrated = readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD) !== undefined; + const needsAdoption = !alreadyMigrated && Array.isArray(legacyViews); + + if (needsAdoption) { + const adopted = writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, legacyViews); + if (!adopted) { + 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(): PageEvent[] { - migrateLegacyPageViewStorage(); +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[]) : []; } @@ -921,7 +929,7 @@ class RoktKit implements KitInterface { try { pageUrl = sanitizeUrl(window.location.href); - const pageViews = loadPageViews(); + const pageViews = loadPageViews(this.loggingService); const pageView: PageEvent = { pageUrl, @@ -1322,17 +1330,8 @@ class RoktKit implements KitInterface { } if (event.EventDataType === MESSAGE_TYPE_SESSION_END) { - try { - migrateLegacyPageViewStorage(); - clearPageViewsStorage(); - } catch (err) { - this.errorReportingService?.report({ - message: 'Rokt Kit: Failed to clear page views on session end', - code: 'PAGE_VIEW_CAPTURE_FAILED', - severity: WSDKErrorSeverity.INFO, - stackTrace: err instanceof Error ? err.stack : undefined, - }); - } + migrateLegacyPageViewStorage(this.loggingService); + clearPageViewsStorage(); } } @@ -1551,18 +1550,7 @@ class RoktKit implements KitInterface { const filteredUserIdentities = this.returnUserIdentities(filteredUser); const sessionAttributes = this.returnLocalSessionAttributes(); - let storedPageViews: PageEvent[] = []; - try { - storedPageViews = loadPageViews(); - } catch (err) { - this.loggingService?.log({ - message: `Rokt Kit: Failed to load page views for selectPlacements: ${ - err instanceof Error ? err.message : String(err) - }`, - code: 'PAGE_VIEW_CAPTURE_FAILED', - }); - } - const pageEvents = this.buildPageEvents(storedPageViews); + const pageEvents = this.buildPageEvents(loadPageViews(this.loggingService)); const selectPlacementsAttributes: Record = { ...(filteredUserIdentities as Record), From 32a84c69287dda235bd5565b59e9565355ebeab8 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 13:38:14 -0400 Subject: [PATCH 11/12] fix: Rename migrated object --- src/Rokt-Kit.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index f23b9c3..2db1f88 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -322,11 +322,11 @@ function migrateLegacyPageViewStorage(loggingService: LoggingService | null): vo } const alreadyMigrated = readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD) !== undefined; - const needsAdoption = !alreadyMigrated && Array.isArray(legacyViews); + const needsMigration = !alreadyMigrated && Array.isArray(legacyViews); - if (needsAdoption) { - const adopted = writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, legacyViews); - if (!adopted) { + 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', From b3996a97dd05acfc3cea5ecbcd81f1453f3c2051 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 12 Aug 2026 13:50:24 -0400 Subject: [PATCH 12/12] refactor: route legacy page-view test seeding through a helper --- test/src/tests.spec.ts | 56 ++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index e6b53e2..76ecbee 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5645,6 +5645,7 @@ describe('Rokt Forwarder', () => { const readStoredPageViews = () => readNamespacedField(NS_KEY, PAGE_VIEWS_FIELD) ?? null; const seedStoredPageViews = (views: unknown) => writeNamespacedField(NS_KEY, PAGE_VIEWS_FIELD, views); + const seedLegacyPageViews = (views: unknown) => window.localStorage.setItem(LEGACY_KEY, JSON.stringify(views)); beforeEach(() => { window.localStorage.clear(); @@ -5846,7 +5847,7 @@ describe('Rokt Forwarder', () => { timestamp: 1712345678000, }, ]; - window.localStorage.setItem(LEGACY_KEY, JSON.stringify(seeded)); + seedLegacyPageViews(seeded); await initKit(); const attributes = await runSelectPlacements(); @@ -5873,7 +5874,7 @@ describe('Rokt Forwarder', () => { timestamp: 1712345679000, }, ]; - window.localStorage.setItem(LEGACY_KEY, JSON.stringify(legacy)); + seedLegacyPageViews(legacy); seedStoredPageViews(current); await initKit(); @@ -5904,16 +5905,13 @@ describe('Rokt Forwarder', () => { }); it('sweeps the legacy key on SessionEnd before clearing the new key', async () => { - window.localStorage.setItem( - LEGACY_KEY, - JSON.stringify([ - { - pageUrl: 'https://example.com/legacy', - sourceMessageId: 'legacy-1', - timestamp: 1712345678000, - }, - ]), - ); + seedLegacyPageViews([ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-1', + timestamp: 1712345678000, + }, + ]); await initKit(); @@ -5933,16 +5931,13 @@ describe('Rokt Forwarder', () => { // Legacy present + namespaced field absent → migration attempts the adopt // write, which throws here. The read path must swallow it (best-effort) so // placement selection still proceeds without page events. - window.localStorage.setItem( - LEGACY_KEY, - JSON.stringify([ - { - pageUrl: 'https://example.com/legacy', - sourceMessageId: 'legacy-1', - timestamp: 1712345678000, - }, - ]), - ); + seedLegacyPageViews([ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-1', + timestamp: 1712345678000, + }, + ]); await initKit(); @@ -6395,16 +6390,13 @@ describe('Rokt Forwarder', () => { // A user with targeting off keeps an orphaned legacy `mpPageViews` until // the shim's removal date — benign, and swept the moment targeting is // re-enabled (loadPageViews) or a SessionEnd fires. - window.localStorage.setItem( - LEGACY_KEY, - JSON.stringify([ - { - pageUrl: 'https://example.com/legacy', - sourceMessageId: 'legacy-seeded', - timestamp: 1712345678000, - }, - ]), - ); + seedLegacyPageViews([ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-seeded', + timestamp: 1712345678000, + }, + ]); seedStoredPageViews([ { pageUrl: 'https://example.com/',