diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index d3b5b9b..1cea604 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -36,6 +36,17 @@ import { import { isLocalStorageAvailable } from './storage'; import { isObject, isString, isEmpty, isFunction, sanitizeUrl } from './utils'; +import { + createLauncherAttachState, + markLauncherAttached, + markLauncherAttachFailed, + markLauncherTerminated, + rememberAttachContext, + recreateIfTerminated, + resetLauncherAttachState, + type LauncherAttachState, + type RoktLauncherOptions, +} from './launcherAttachState'; interface RoktKitSettings { accountId: string; @@ -86,11 +97,12 @@ interface RoktLauncher { selectPlacements(options: Record): RoktSelection | Promise; hashAttributes(attributes: Record): Promise>; use(extensionName: string): Promise; + terminate(): Promise; } interface RoktGlobal { - createLauncher(options: Record): Promise; - createLocalLauncher(options: Record): RoktLauncher; + createLauncher(options: RoktLauncherOptions): Promise; + createLocalLauncher(options: RoktLauncherOptions): RoktLauncher; currentLauncher?: RoktLauncher; setExtensionData(data: Record): void; } @@ -191,6 +203,7 @@ interface TestHelpers { RateLimiter: typeof RateLimiter; ErrorCodes: typeof ErrorCodes; WSDKErrorSeverity: typeof WSDKErrorSeverity; + resetLauncherAttachState: () => void; } interface ForwarderRegistration { @@ -781,6 +794,8 @@ class RoktKit implements KitInterface { // so a re-login re-evaluates fresh. private _workspaceLastSearchedIdentitiesKey?: string; + private _launcherAttachState: LauncherAttachState = createLauncherAttachState(); + // ---- Private helpers ---- private getEventAttributeValue(event: SDKEvent, eventAttributeKey: string): unknown { @@ -981,10 +996,16 @@ class RoktKit implements KitInterface { private attachLauncher( accountId: string, - launcherOptions: Record, - legacyRoktExtensions: string[] = [], - ): void { - const options: Record = { + launcherOptions: RoktLauncherOptions, + legacyRoktExtensions: readonly string[] = [], + ): Promise { + rememberAttachContext(this._launcherAttachState, { + accountId, + launcherOptions: launcherOptions || {}, + legacyRoktExtensions, + }); + + const options: RoktLauncherOptions = { accountId, ...(launcherOptions || {}), }; @@ -996,16 +1017,23 @@ class RoktKit implements KitInterface { launcherPromise = window.Rokt!.createLauncher(options); } - launcherPromise + return launcherPromise .then(async (launcher) => { - await registerLegacyExtensions(legacyRoktExtensions, launcher); + await registerLegacyExtensions([...legacyRoktExtensions], launcher); this.initRoktLauncher(launcher); }) .catch((err: unknown) => { + markLauncherAttachFailed(this._launcherAttachState); console.error('Error creating Rokt launcher:', err); }); } + private recreateLauncherIfTerminated(): Promise | undefined { + return recreateIfTerminated(this._launcherAttachState, this.isLauncherReadyToAttach(), (context) => + this.attachLauncher(context.accountId, context.launcherOptions, context.legacyRoktExtensions), + ); + } + private initRoktLauncher(launcher: RoktLauncher): void { // Assign the launcher to a global variable for later access if (window.Rokt) { @@ -1013,6 +1041,7 @@ class RoktKit implements KitInterface { } // Locally cache the launcher and filters this.launcher = launcher; + markLauncherAttached(this._launcherAttachState); const roktFilters = mp().Rokt?.filters; @@ -1196,6 +1225,7 @@ class RoktKit implements KitInterface { RateLimiter: RateLimiter, ErrorCodes: ErrorCodes, WSDKErrorSeverity: WSDKErrorSeverity, + resetLauncherAttachState: () => resetLauncherAttachState(this._launcherAttachState), }; this.attachLauncher(accountId, launcherOptions); return 'Successfully initialized: ' + name; @@ -1424,9 +1454,23 @@ class RoktKit implements KitInterface { * rejects it as the awaited return of an async function (TS1058) — * working around that would require a cast or wrapping every return in * `Promise.resolve(...)`. The inner work runs in `_dispatchPlacements`; - * this wrapper just gates it on the in-flight search via `Promise.race`. + * this wrapper just gates it on the in-flight search via `Promise.race`, + * and on a post-terminate createLauncher when the SPA needs a new instance. */ public selectPlacements(options: Record): RoktSelection | Promise | undefined { + const recreate = this.recreateLauncherIfTerminated(); + if (recreate) { + const inFlight = this._workspaceSearchInFlightPromise; + const waitForSearch = inFlight + ? Promise.race([ + inFlight, + new Promise((resolve) => setTimeout(resolve, WORKSPACE_SEARCH_SELECT_TIMEOUT_MS)), + ]) + : Promise.resolve(); + return Promise.all([recreate, waitForSearch]).then(() => + this._dispatchPlacements(options), + ) as Promise; + } if (this._workspaceSearchInFlightPromise) { const inFlight = this._workspaceSearchInFlightPromise; return Promise.race([ @@ -1521,6 +1565,24 @@ class RoktKit implements KitInterface { return this.launcher!.use(extensionName); } + /** + * Tears down the Rokt launcher and the placements it rendered. + * + * The kit's launcher reference is left in place so isKitReady() stays true. + * The Web SDK clears its memoized launcher on terminate, so a later + * createLauncher (SPA navigation) produces a new instance. The next + * selectPlacements call re-attaches that instance. Nulling the reference + * here would flip the kit to not-ready with no drain path for queued calls. + */ + public terminate(): Promise { + if (!this.isKitReady()) { + console.error('Rokt Kit: Not initialized'); + return Promise.resolve(); + } + markLauncherTerminated(this._launcherAttachState); + return this.launcher!.terminate(); + } + /** * Registers a callback to be invoked once rokt-thank-you-element.js becomes available. */ diff --git a/src/launcherAttachState.ts b/src/launcherAttachState.ts new file mode 100644 index 0000000..2b776ec --- /dev/null +++ b/src/launcherAttachState.ts @@ -0,0 +1,74 @@ +export type RoktLauncherOptions = Record; + +export interface LauncherAttachContext { + accountId: string; + launcherOptions: RoktLauncherOptions; + legacyRoktExtensions: readonly string[]; +} + +export type LauncherLifecycle = 'idle' | 'attached' | 'terminated' | 'recreating'; + +export interface LauncherAttachState { + context: LauncherAttachContext | null; + lifecycle: LauncherLifecycle; + recreateInFlight: Promise | null; +} + +export type AttachLauncher = (context: LauncherAttachContext) => Promise; + +export function createLauncherAttachState(): LauncherAttachState { + return { + context: null, + lifecycle: 'idle', + recreateInFlight: null, + }; +} + +export function rememberAttachContext(state: LauncherAttachState, context: LauncherAttachContext): void { + state.context = { + accountId: context.accountId, + launcherOptions: { ...context.launcherOptions }, + legacyRoktExtensions: [...context.legacyRoktExtensions], + }; +} + +export function markLauncherAttached(state: LauncherAttachState): void { + state.lifecycle = 'attached'; +} + +export function markLauncherTerminated(state: LauncherAttachState): void { + if (state.lifecycle === 'idle') { + return; + } + state.lifecycle = 'terminated'; +} + +export function markLauncherAttachFailed(state: LauncherAttachState): void { + state.lifecycle = 'terminated'; +} + +export function resetLauncherAttachState(state: LauncherAttachState): void { + state.context = null; + state.lifecycle = 'idle'; + state.recreateInFlight = null; +} + +export function recreateIfTerminated( + state: LauncherAttachState, + canAttach: boolean, + attach: AttachLauncher, +): Promise | undefined { + if (state.recreateInFlight) { + return state.recreateInFlight; + } + if (state.lifecycle !== 'terminated' || !state.context || !canAttach) { + return undefined; + } + + state.lifecycle = 'recreating'; + const context = state.context; + state.recreateInFlight = attach(context).finally(() => { + state.recreateInFlight = null; + }); + return state.recreateInFlight; +} diff --git a/test/src/launcherAttachState.spec.ts b/test/src/launcherAttachState.spec.ts new file mode 100644 index 0000000..5efbe36 --- /dev/null +++ b/test/src/launcherAttachState.spec.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + createLauncherAttachState, + rememberAttachContext, + markLauncherAttached, + markLauncherTerminated, + markLauncherAttachFailed, + resetLauncherAttachState, + recreateIfTerminated, + type LauncherAttachContext, +} from '../../src/launcherAttachState'; + +function context(overrides: Partial = {}): LauncherAttachContext { + return { + accountId: 'acct-1', + launcherOptions: { sandbox: true }, + legacyRoktExtensions: ['ext-a'], + ...overrides, + }; +} + +describe('launcherAttachState', () => { + it('starts idle with no context', () => { + const state = createLauncherAttachState(); + + expect(state.lifecycle).toBe('idle'); + expect(state.context).toBeNull(); + expect(state.recreateInFlight).toBeNull(); + }); + + it('copies attach context so later mutations do not leak', () => { + const state = createLauncherAttachState(); + const launcherOptions = { sandbox: true }; + const legacyRoktExtensions = ['ext-a']; + + rememberAttachContext(state, { accountId: 'acct-1', launcherOptions, legacyRoktExtensions }); + launcherOptions.sandbox = false; + legacyRoktExtensions.push('ext-b'); + + expect(state.context).toEqual({ + accountId: 'acct-1', + launcherOptions: { sandbox: true }, + legacyRoktExtensions: ['ext-a'], + }); + }); + + it('does not treat idle as terminated', () => { + const state = createLauncherAttachState(); + + markLauncherTerminated(state); + + expect(state.lifecycle).toBe('idle'); + expect(recreateIfTerminated(state, true, vi.fn())).toBeUndefined(); + }); + + it('recreates once after terminate and coalesces concurrent callers', async () => { + const state = createLauncherAttachState(); + rememberAttachContext(state, context()); + markLauncherAttached(state); + markLauncherTerminated(state); + + let resolveAttach: () => void = () => undefined; + const attach = vi.fn( + () => + new Promise((resolve) => { + resolveAttach = resolve; + }), + ); + + const first = recreateIfTerminated(state, true, attach); + const second = recreateIfTerminated(state, true, attach); + + expect(first).toBeInstanceOf(Promise); + expect(second).toBe(first); + expect(attach).toHaveBeenCalledTimes(1); + expect(attach).toHaveBeenCalledWith({ + accountId: 'acct-1', + launcherOptions: { sandbox: true }, + legacyRoktExtensions: ['ext-a'], + }); + expect(state.lifecycle).toBe('recreating'); + + resolveAttach(); + await first; + + expect(state.recreateInFlight).toBeNull(); + }); + + it('does not recreate without context or when attach is unavailable', () => { + const attach = vi.fn(); + const noContext = createLauncherAttachState(); + noContext.lifecycle = 'terminated'; + + expect(recreateIfTerminated(noContext, true, attach)).toBeUndefined(); + + const blocked = createLauncherAttachState(); + rememberAttachContext(blocked, context()); + markLauncherAttached(blocked); + markLauncherTerminated(blocked); + + expect(recreateIfTerminated(blocked, false, attach)).toBeUndefined(); + expect(attach).not.toHaveBeenCalled(); + }); + + it('can retry after a failed attach', async () => { + const state = createLauncherAttachState(); + rememberAttachContext(state, context()); + markLauncherAttached(state); + markLauncherTerminated(state); + + const firstAttach = vi.fn().mockRejectedValue(new Error('createLauncher failed')); + const failed = recreateIfTerminated(state, true, firstAttach); + await expect(failed).rejects.toThrow('createLauncher failed'); + markLauncherAttachFailed(state); + + const secondAttach = vi.fn().mockResolvedValue(undefined); + const retried = recreateIfTerminated(state, true, secondAttach); + await retried; + + expect(secondAttach).toHaveBeenCalledTimes(1); + }); + + it('resets to idle', () => { + const state = createLauncherAttachState(); + rememberAttachContext(state, context()); + markLauncherAttached(state); + markLauncherTerminated(state); + + resetLauncherAttachState(state); + + expect(state).toEqual(createLauncherAttachState()); + }); +}); diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 40c6ed2..6eabbc4 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -178,7 +178,7 @@ describe('Rokt Forwarder', () => { }; this.currentLauncher = function () {}; - }; + } as unknown as { new (): Record }; beforeAll(async () => { (window as any).Rokt = new (MockRoktForwarder as any)(); @@ -216,6 +216,7 @@ describe('Rokt Forwarder', () => { afterEach(() => { (window as any).mParticle.forwarder.userAttributes = {}; + (window as any).mParticle.forwarder.testHelpers?.resetLauncherAttachState(); delete (window as any).mParticle.forwarder.launcherOptions; delete (window as any).mParticle.Rokt.launcherOptions; }); @@ -3249,6 +3250,253 @@ describe('Rokt Forwarder', () => { }); }); + describe('#terminate', () => { + interface TerminateTestLauncher { + id?: number; + terminate: () => Promise; + selectPlacements?: (options: Record) => unknown; + } + + interface TerminateTestKit { + isInitialized: boolean; + launcher: TerminateTestLauncher | null; + terminate: () => Promise; + init: ( + settings: Record, + service: unknown, + testMode: boolean, + trackerId: null, + filteredUserAttributes: Record, + ) => string; + selectPlacements: (options: Record) => unknown; + } + + interface TerminateTestRokt { + currentLauncher?: TerminateTestLauncher; + createLauncher: (options?: Record) => Promise; + attachKitCalled: boolean; + attachKit: (kit: TerminateTestKit) => Promise; + kit?: TerminateTestKit; + filters?: { + userAttributesFilters: unknown[]; + filterUserAttributes: (attributes: Record) => Record; + filteredUser: { getMPID: () => string }; + }; + selectPlacementsCalled?: boolean; + selectPlacementsLauncherId?: number; + selectPlacementsOptions?: Record; + } + + interface TerminateTestWindow { + Rokt: TerminateTestRokt; + mParticle: { + Rokt: TerminateTestRokt; + forwarder: TerminateTestKit; + }; + } + + function tw(): TerminateTestWindow { + return window as unknown as TerminateTestWindow; + } + + beforeEach(() => { + const rokt = new MockRoktForwarder() as unknown as TerminateTestRokt; + tw().Rokt = rokt; + tw().mParticle.Rokt = rokt; + tw().mParticle.Rokt.attachKitCalled = false; + tw().mParticle.Rokt.attachKit = async (kit: TerminateTestKit) => { + tw().mParticle.Rokt.attachKitCalled = true; + tw().mParticle.Rokt.kit = kit; + }; + }); + + it('should call launcher.terminate when fully initialized', async () => { + let terminateCalled = false; + tw().mParticle.forwarder.isInitialized = true; + tw().mParticle.forwarder.launcher = { + terminate: function () { + terminateCalled = true; + return Promise.resolve(); + }, + }; + + await tw().mParticle.forwarder.terminate(); + + expect(terminateCalled).toBe(true); + }); + + it('should return the promise from launcher.terminate', async () => { + let resolved = false; + tw().mParticle.forwarder.isInitialized = true; + tw().mParticle.forwarder.launcher = { + terminate: () => new Promise((resolve) => setTimeout(resolve, 0)), + }; + + await tw() + .mParticle.forwarder.terminate() + .then(() => { + resolved = true; + }); + + expect(resolved).toBe(true); + }); + + it('should resolve without calling the launcher when called before initialization', async () => { + const originalConsoleError = window.console.error; + let errorMessage: string | null = null; + window.console.error = function (message: string) { + errorMessage = message; + }; + + tw().mParticle.forwarder.isInitialized = false; + tw().mParticle.forwarder.launcher = null; + + try { + await expect(tw().mParticle.forwarder.terminate()).resolves.toBeUndefined(); + } finally { + window.console.error = originalConsoleError; + } + + expect(errorMessage).toBe('Rokt Kit: Not initialized'); + }); + + it('should resolve without calling the launcher when initialized but the launcher is missing', async () => { + const originalConsoleError = window.console.error; + let errorMessage: string | null = null; + window.console.error = function (message: string) { + errorMessage = message; + }; + + tw().mParticle.forwarder.isInitialized = true; + tw().mParticle.forwarder.launcher = null; + + try { + await expect(tw().mParticle.forwarder.terminate()).resolves.toBeUndefined(); + } finally { + window.console.error = originalConsoleError; + } + + expect(errorMessage).toBe('Rokt Kit: Not initialized'); + }); + + // Leave the kit ready after terminate. A later createLauncher (SPA) can + // still mint a new instance because the Web SDK drops its memoized launcher. + it('should leave the launcher references intact so the kit stays ready', async () => { + const launcher: TerminateTestLauncher = { + terminate: () => Promise.resolve(), + }; + tw().mParticle.forwarder.isInitialized = true; + tw().mParticle.forwarder.launcher = launcher; + tw().Rokt.currentLauncher = launcher; + + await tw().mParticle.forwarder.terminate(); + + expect(tw().mParticle.forwarder.launcher).toBe(launcher); + expect(tw().Rokt.currentLauncher).toBe(launcher); + }); + + it('should create a new launcher instance and continue after terminate', async () => { + let createCount = 0; + const launchers: TerminateTestLauncher[] = []; + + tw().mParticle.Rokt.filters = { + userAttributesFilters: [], + filterUserAttributes: function (attributes: Record) { + return attributes; + }, + filteredUser: { + getMPID: function () { + return '123'; + }, + }, + }; + + tw().Rokt.createLauncher = async function (): Promise { + createCount += 1; + const id = createCount; + const launcher: TerminateTestLauncher = { + id, + terminate: () => Promise.resolve(), + selectPlacements: function (options: Record) { + tw().Rokt.selectPlacementsCalled = true; + tw().Rokt.selectPlacementsLauncherId = id; + tw().Rokt.selectPlacementsOptions = options; + }, + }; + launchers.push(launcher); + return launcher; + }; + + await tw().mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => tw().mParticle.Rokt.attachKitCalled); + + const firstLauncher = tw().mParticle.forwarder.launcher; + expect(firstLauncher).toBe(launchers[0]); + + await tw().mParticle.forwarder.terminate(); + + expect(tw().mParticle.forwarder.launcher).toBe(firstLauncher); + + tw().mParticle.Rokt.attachKitCalled = false; + + await tw().mParticle.forwarder.selectPlacements({ attributes: {} }); + + await waitForCondition(() => tw().mParticle.Rokt.attachKitCalled); + + const secondLauncher = tw().mParticle.forwarder.launcher; + expect(createCount).toBe(2); + expect(secondLauncher).toBe(launchers[1]); + expect(secondLauncher).not.toBe(firstLauncher); + expect(tw().Rokt.currentLauncher).toBe(secondLauncher); + expect(tw().Rokt.selectPlacementsCalled).toBe(true); + expect(tw().Rokt.selectPlacementsLauncherId).toBe(2); + }); + + it('should call launcher.terminate after init (test mode) and attach', async () => { + let terminateCalled = false; + + tw().mParticle.Rokt.attachKitCalled = false; + tw().mParticle.Rokt.attachKit = async (kit: TerminateTestKit) => { + tw().mParticle.Rokt.attachKitCalled = true; + tw().mParticle.Rokt.kit = kit; + }; + + tw().Rokt.createLauncher = async function () { + return Promise.resolve({ + terminate: function () { + terminateCalled = true; + return Promise.resolve(); + }, + }); + }; + + await tw().mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => tw().mParticle.Rokt.attachKitCalled); + + await tw().mParticle.forwarder.terminate(); + + expect(terminateCalled).toBe(true); + }); + }); + describe('#setUserAttribute', () => { beforeEach(() => { (window as any).mParticle.sessionManager = {