From b41570f36d9dca70d2b012bf6a7504d9a55d3385 Mon Sep 17 00:00:00 2001 From: James Newman Date: Tue, 18 Aug 2026 11:08:35 -0400 Subject: [PATCH] feat(rokt): expose terminate on the window.mParticle.Rokt interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partners tearing down placements had no supported teardown path and were falling back to the undocumented `window.Rokt.currentLauncher?.terminate()`. `terminate` is already stubbed in the Rokt snippet, so a snippet-loaded page could queue the call and then fail to dispatch it. RoktManager.terminate() delegates to the kit, which forwards to the Rokt launcher. The kit method is optional on IRoktKit and guarded with isFunction because the Rokt Kit releases independently — a kit published before this change will not implement it. Unlike the other proxied methods a pre-ready call is not queued. Replaying a teardown against a launcher created later would tear down placements the caller never asked to remove; with no launcher there is nothing to terminate, so the call resolves as a no-op. Co-Authored-By: Claude Opus 5 (1M context) --- src/roktManager.ts | 37 +++++++++++++ test/jest/roktManager.spec.ts | 100 ++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/roktManager.ts b/src/roktManager.ts index ae6cb64f0..55681d0b0 100644 --- a/src/roktManager.ts +++ b/src/roktManager.ts @@ -83,6 +83,9 @@ export interface IRoktKit { setExtensionData(extensionData: IRoktPartnerExtensionData): void; use: (name: string) => Promise; onShoppableAdsReady(callback: () => void): void; + // Optional because the Rokt Kit ships on its own release cadence; a kit + // published before terminate() existed will not implement it. + terminate?: () => Promise; launcherOptions?: Dictionary; settings?: IRoktKitSettings; integrationName?: string; @@ -479,6 +482,40 @@ export default class RoktManager { } } + /** + * Tears down the current Rokt launcher and removes the placements it rendered. + * + * Intended for hosts that cannot simply destroy the container element — + * SPA route changes and confirmation pages, for example. + * + * Unlike the other proxied methods, a call made before the kit is ready is + * *not* queued. Replaying a teardown against a launcher created later would + * silently tear down placements the caller never asked to remove; with no + * launcher there is nothing to terminate, so this resolves as a no-op. + * + * @example + * await window.mParticle.Rokt.terminate(); + * + * @returns {Promise} Resolves once the launcher has torn down. Never rejects. + */ + public async terminate(): Promise { + if (!this.isReady()) { + this.logger?.verbose('mParticle.Rokt.terminate called before the Rokt kit was ready. Nothing to terminate.'); + return; + } + + if (!isFunction(this.kit.terminate)) { + this.logger?.error('mParticle.Rokt.terminate is not supported by the attached Rokt Kit version.'); + return; + } + + try { + await this.kit.terminate(); + } catch (error) { + this.logger?.error(`Failed to terminate the Rokt launcher: ${getErrorMessage(error)}`); + } + } + public onShoppableAdsReady(callback: () => void): void { if (!this.kit || !this.isShoppableAdsLoaded) { this.deferredCall('onShoppableAdsReady', callback); diff --git a/test/jest/roktManager.spec.ts b/test/jest/roktManager.spec.ts index 974938617..6ea2bcb0d 100644 --- a/test/jest/roktManager.spec.ts +++ b/test/jest/roktManager.spec.ts @@ -3007,6 +3007,106 @@ describe('RoktManager', () => { }); }); + describe('#terminate', () => { + const makeKit = (): IRoktKit => ({ + launcher: { + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + use: jest.fn(), + }, + filters: undefined, + filteredUser: undefined, + userAttributes: undefined, + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + setExtensionData: jest.fn(), + use: jest.fn(), + onShoppableAdsReady: jest.fn(), + terminate: jest.fn().mockResolvedValue(undefined), + }); + + it('should call kit.terminate when a kit and launcher are attached', async () => { + const kit = makeKit(); + roktManager.attachKit(kit); + + await roktManager.terminate(); + + expect(kit.terminate).toHaveBeenCalledTimes(1); + }); + + it('should await the kit terminate promise before resolving', async () => { + const kit = makeKit(); + let settled = false; + (kit.terminate as jest.Mock).mockImplementation( + () => new Promise(resolve => setTimeout(() => { + settled = true; + resolve(); + }, 0)) + ); + roktManager.attachKit(kit); + + await roktManager.terminate(); + + expect(settled).toBe(true); + }); + + it('should resolve as a no-op and not queue when no kit is attached', async () => { + await expect(roktManager.terminate()).resolves.toBeUndefined(); + + expect(roktManager['kit']).toBeNull(); + expect(roktManager['messageQueue'].size).toBe(0); + expect(mockMPInstance.Logger.verbose).toHaveBeenCalledWith( + 'mParticle.Rokt.terminate called before the Rokt kit was ready. Nothing to terminate.' + ); + }); + + it('should resolve as a no-op when a kit is attached without a launcher', async () => { + const kit = makeKit(); + kit.launcher = null; + roktManager.attachKit(kit); + + await expect(roktManager.terminate()).resolves.toBeUndefined(); + + expect(kit.terminate).not.toHaveBeenCalled(); + expect(roktManager['messageQueue'].size).toBe(0); + }); + + it('should log an error when the attached kit predates terminate support', async () => { + const kit = makeKit(); + delete kit.terminate; + roktManager.attachKit(kit); + + await expect(roktManager.terminate()).resolves.toBeUndefined(); + + expect(mockMPInstance.Logger.error).toHaveBeenCalledWith( + 'mParticle.Rokt.terminate is not supported by the attached Rokt Kit version.' + ); + }); + + it('should log an error and resolve when kit.terminate rejects', async () => { + const kit = makeKit(); + (kit.terminate as jest.Mock).mockRejectedValue(new Error('teardown failed')); + roktManager.attachKit(kit); + + await expect(roktManager.terminate()).resolves.toBeUndefined(); + + expect(mockMPInstance.Logger.error).toHaveBeenCalledWith( + 'Failed to terminate the Rokt launcher: teardown failed' + ); + }); + + it('should stay ready so repeat calls keep reaching the kit', async () => { + const kit = makeKit(); + roktManager.attachKit(kit); + + await roktManager.terminate(); + await roktManager.terminate(); + + expect(roktManager.isReady()).toBe(true); + expect(kit.terminate).toHaveBeenCalledTimes(2); + }); + }); + describe('#onShoppableAdsReady', () => { const makeKit = (): IRoktKit => ({ launcher: {