@mparticle/web-sdk 3.8.1 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,8 @@ import { ErrorCodes, IErrorReportingService, ILoggingService, WSDKErrorSeverity
21
21
  import { IRoktLauncherOptions, normalizeRoktLauncherOptions } from "./roktLauncherOptions";
22
22
  import { PARTNER_MODULE_IDS } from "./cookieSyncManager";
23
23
  import IntegrationCapture from "./integrationCapture";
24
+ import Constants from "./constants";
25
+ import { subscribeToRouteChange } from "./routeChangeMonitor";
24
26
 
25
27
  const PASSBACK_CONVERSION_TRACKING_ID = 'passbackconversiontrackingid';
26
28
 
@@ -86,6 +88,9 @@ export interface IRoktKit {
86
88
  // Optional because the Rokt Kit ships on its own release cadence; a kit
87
89
  // published before terminate() existed will not implement it.
88
90
  terminate?: () => Promise<void>;
91
+ // Set by the kit only when it wants route changes; nothing subscribes otherwise. The
92
+ // manager also calls it once on attach.
93
+ onRouteChange?: () => void;
89
94
  launcherOptions?: Dictionary<any>;
90
95
  settings?: IRoktKitSettings;
91
96
  integrationName?: string;
@@ -130,6 +135,11 @@ export default class RoktManager {
130
135
  private onReadyCallback: (() => void) | null = null;
131
136
  private initialized: boolean = false;
132
137
  private isShoppableAdsLoaded: boolean = false;
138
+ private stopRouteChangeWatch: (() => void) | null = null;
139
+
140
+ // Keys this manager's route subscription, so a re-executed bundle's manager replaces
141
+ // it while a second named instance keeps its own.
142
+ constructor(private readonly instanceName?: string) {}
133
143
 
134
144
  /**
135
145
  * Sets a callback to be invoked when RoktManager becomes ready
@@ -219,6 +229,8 @@ export default class RoktManager {
219
229
  public attachKit(kit: IRoktKit): void {
220
230
  this.kit = kit;
221
231
 
232
+ this.watchRouteChanges();
233
+
222
234
  if (kit.settings?.accountId) {
223
235
  this.store.setRoktAccountId(kit.settings.accountId);
224
236
  }
@@ -236,6 +248,50 @@ export default class RoktManager {
236
248
  }
237
249
  }
238
250
 
251
+ // Core already emits a page view per navigation when AutoLogPageView is on, so the kit
252
+ // subscribes to route changes only when it is off. The call on attach happens either
253
+ // way: the auto page view fires once per page load, so a re-init emits none.
254
+ private watchRouteChanges(): void {
255
+ if (!isFunction(this.kit?.onRouteChange)) {
256
+ this.stopRouteChangeWatch?.();
257
+ this.stopRouteChangeWatch = null;
258
+ return;
259
+ }
260
+
261
+ if (this.isAutoLogPageViewEnabled()) {
262
+ this.stopRouteChangeWatch?.();
263
+ this.stopRouteChangeWatch = null;
264
+ } else if (!this.stopRouteChangeWatch) {
265
+ this.stopRouteChangeWatch = subscribeToRouteChange(
266
+ `rokt:${this.instanceName}`,
267
+ () => this.notifyRouteChange()
268
+ );
269
+ }
270
+
271
+ // A full navigation or a re-init lands on the trigger route without a route change
272
+ // of its own.
273
+ this.notifyRouteChange();
274
+ }
275
+
276
+ // Reads `this.kit` at call time, so a kit attached by a later init() takes over.
277
+ private notifyRouteChange(): void {
278
+ try {
279
+ this.kit?.onRouteChange?.();
280
+ } catch (e) {
281
+ this.logger?.error(
282
+ `RoktManager: Error in onRouteChange: ${getErrorMessage(e)}`
283
+ );
284
+ }
285
+ }
286
+
287
+ private isAutoLogPageViewEnabled(): boolean {
288
+ return (
289
+ this.store?.SDKConfig?.flags?.[
290
+ Constants.FeatureFlags.AutoLogPageView
291
+ ] === true
292
+ );
293
+ }
294
+
239
295
  /**
240
296
  * Renders ads based on the options provided
241
297
  *
@@ -0,0 +1,207 @@
1
+ import { Dictionary } from './utils';
2
+
3
+ type HistoryStateMethod = History['pushState'];
4
+ type HistoryMethodName = 'pushState' | 'replaceState';
5
+ export type RouteChangeSource = HistoryMethodName | 'popstate';
6
+ export type RouteChangeListener = (source: RouteChangeSource) => void;
7
+
8
+ const HISTORY_METHODS: HistoryMethodName[] = ['pushState', 'replaceState'];
9
+
10
+ const WRAPPED_MARKER = '__mpApvWrapped__';
11
+
12
+ type MarkedHistoryMethod = HistoryStateMethod & {
13
+ [WRAPPED_MARKER]?: boolean;
14
+ };
15
+
16
+ export const supportsHistoryTracking = (win: Window | null): boolean =>
17
+ !!win &&
18
+ win.history !== undefined &&
19
+ typeof win.history.pushState === 'function' &&
20
+ typeof win.addEventListener === 'function';
21
+
22
+ // Wraps pushState/replaceState so `onNavigate` runs after the real method, and
23
+ // returns the single function that undoes it — or null when nothing was
24
+ // installed (history is already wrapped, or a frozen/sealed History rejected the
25
+ // assignment). Handing back one undo closure keeps the wrapper and original
26
+ // references out of the tracker entirely: there is no half-patched state for a
27
+ // caller to inspect, and no way to restore the wrong pair.
28
+ export const patchHistory = (
29
+ onNavigate: RouteChangeListener,
30
+ log: (message: string) => void
31
+ ): (() => void) | null => {
32
+ if ((window.history.pushState as MarkedHistoryMethod)[WRAPPED_MARKER]) {
33
+ log(
34
+ '[patch] history already wrapped, skipping to avoid double-wrap — STACKED WRAPPER DETECTED'
35
+ );
36
+ return null;
37
+ }
38
+
39
+ const originals = {} as Record<HistoryMethodName, HistoryStateMethod>;
40
+ const wrappers = {} as Record<HistoryMethodName, HistoryStateMethod>;
41
+
42
+ HISTORY_METHODS.forEach(name => {
43
+ const original = window.history[name];
44
+ originals[name] = original;
45
+
46
+ const wrapper = function(
47
+ this: History,
48
+ ...args: Parameters<HistoryStateMethod>
49
+ ): void {
50
+ const result = original.apply(this, args);
51
+ onNavigate(name);
52
+ return result;
53
+ };
54
+
55
+ Object.defineProperty(wrapper, WRAPPED_MARKER, {
56
+ value: true,
57
+ enumerable: false,
58
+ });
59
+
60
+ wrappers[name] = wrapper;
61
+ });
62
+
63
+ // Restore per method: a third party may have patched one of the two on top of
64
+ // ours after we installed. Clobbering theirs would break their tracking, so
65
+ // leave anything that is no longer ours in place.
66
+ const restore = (): void =>
67
+ HISTORY_METHODS.forEach(name => {
68
+ if (window.history[name] === wrappers[name]) {
69
+ window.history[name] = originals[name];
70
+ log(`[teardown] restored original ${name}`);
71
+ } else {
72
+ log(
73
+ `[teardown] ${name} no longer ours; leaving in place, gating callback to no-op`
74
+ );
75
+ }
76
+ });
77
+
78
+ try {
79
+ HISTORY_METHODS.forEach(name => {
80
+ window.history[name] = wrappers[name];
81
+ });
82
+ } catch (e) {
83
+ log(
84
+ `[error] failed to patch history methods (frozen/sealed), rolling back: ${e}`
85
+ );
86
+ try {
87
+ restore();
88
+ } catch (restoreError) {
89
+ log(
90
+ `[error] failed to restore history methods after patch failure: ${restoreError}`
91
+ );
92
+ }
93
+ return null;
94
+ }
95
+
96
+ return restore;
97
+ };
98
+
99
+ // One History patch for the whole SDK, so page-view tracking and preselection do not
100
+ // stack two wrappers.
101
+ //
102
+ // State lives on `window`, not module scope, because Next.js re-executes the bundle per
103
+ // SPA navigation while the patch from the previous execution stays installed.
104
+ export const WIN_ROUTE_MONITOR_KEY = '__mpRouteMonitor__';
105
+
106
+ // Keyed by owner, so a later bundle execution's subscriber replaces its predecessor
107
+ // rather than joining it.
108
+ interface IRouteMonitorState {
109
+ listeners: Dictionary<RouteChangeListener>;
110
+ undoHistoryPatch: (() => void) | null;
111
+ popStateListener: (() => void) | null;
112
+ }
113
+
114
+ type WindowWithRouteMonitor = Window & {
115
+ [WIN_ROUTE_MONITOR_KEY]?: IRouteMonitorState;
116
+ };
117
+
118
+ const monitorState = (): IRouteMonitorState => {
119
+ const win = window as WindowWithRouteMonitor;
120
+
121
+ if (!win[WIN_ROUTE_MONITOR_KEY]) {
122
+ win[WIN_ROUTE_MONITOR_KEY] = {
123
+ listeners: {},
124
+ undoHistoryPatch: null,
125
+ popStateListener: null,
126
+ };
127
+ }
128
+
129
+ return win[WIN_ROUTE_MONITOR_KEY] as IRouteMonitorState;
130
+ };
131
+
132
+ // Reads the shared state at call time, so a wrapper installed by a previous bundle
133
+ // execution still reaches the current listeners.
134
+ const emit = (source: RouteChangeSource): void => {
135
+ Object.values(monitorState().listeners).forEach(listener => {
136
+ try {
137
+ listener(source);
138
+ } catch (e) {
139
+ // One subscriber must not stop the others, and must never break navigation.
140
+ }
141
+ });
142
+ };
143
+
144
+ const install = (
145
+ state: IRouteMonitorState,
146
+ log: (message: string) => void
147
+ ): void => {
148
+ if (state.undoHistoryPatch || state.popStateListener) {
149
+ return;
150
+ }
151
+
152
+ state.undoHistoryPatch = patchHistory(emit, log);
153
+ state.popStateListener = (): void => emit('popstate');
154
+ window.addEventListener('popstate', state.popStateListener);
155
+ };
156
+
157
+ const uninstall = (state: IRouteMonitorState): void => {
158
+ if (state.popStateListener) {
159
+ window.removeEventListener('popstate', state.popStateListener);
160
+ state.popStateListener = null;
161
+ }
162
+
163
+ if (state.undoHistoryPatch) {
164
+ state.undoHistoryPatch();
165
+ state.undoHistoryPatch = null;
166
+ }
167
+ };
168
+
169
+ // Installs on the first subscriber and tears down after the last leaves, so a workspace
170
+ // that needs neither is never patched. Unsubscribing only removes the listener while it
171
+ // still owns its key, so an outgoing instance cannot remove its replacement.
172
+ export const subscribeToRouteChange = (
173
+ key: string,
174
+ listener: RouteChangeListener,
175
+ log: (message: string) => void = () => undefined
176
+ ): (() => void) => {
177
+ if (!supportsHistoryTracking(typeof window === 'undefined' ? null : window)) {
178
+ return () => undefined;
179
+ }
180
+
181
+ const state = monitorState();
182
+
183
+ state.listeners[key] = listener;
184
+ install(state, log);
185
+
186
+ return (): void => {
187
+ if (state.listeners[key] !== listener) {
188
+ return;
189
+ }
190
+
191
+ delete state.listeners[key];
192
+ if (Object.keys(state.listeners).length === 0) {
193
+ uninstall(state);
194
+ }
195
+ };
196
+ };
197
+
198
+ export const resetRouteChangeMonitor = (): void => {
199
+ if (typeof window === 'undefined') {
200
+ return;
201
+ }
202
+
203
+ const state = monitorState();
204
+ state.listeners = {};
205
+ uninstall(state);
206
+ delete (window as WindowWithRouteMonitor)[WIN_ROUTE_MONITOR_KEY];
207
+ };