@mparticle/web-sdk 2.79.2 → 2.80.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.
@@ -1,7 +1,13 @@
1
1
  import { IMParticleWebSDKInstance } from './mp-instance';
2
+ import { BaseEvent } from './sdkRuntimeModels';
2
3
  import { EventType, MessageType } from './types';
4
+ import { Dictionary, getHref, queryStringParser } from './utils';
3
5
 
4
6
  type HistoryStateMethod = History['pushState'];
7
+ type HistoryMethodName = 'pushState' | 'replaceState';
8
+ type NavigationSource = HistoryMethodName | 'popstate';
9
+
10
+ const HISTORY_METHODS: HistoryMethodName[] = ['pushState', 'replaceState'];
5
11
 
6
12
  const WRAPPED_MARKER = '__mpApvWrapped__';
7
13
 
@@ -9,374 +15,562 @@ type MarkedHistoryMethod = HistoryStateMethod & {
9
15
  [WRAPPED_MARKER]?: boolean;
10
16
  };
11
17
 
12
- // window key under which the single active tracker is stored.
13
- // Survives module re-evaluation (Next.js re-executes the bundle on every SPA
14
- // navigation, resetting all module-level state, but window persists for the
15
- // lifetime of the tab).
16
- export const WIN_TRACKER_KEY = '__mpApvTracker__';
18
+ // All APV state hangs off one window key, and it is the public debugging
19
+ // contract: `window.__mpApv__` is what you inspect in a console to see whether
20
+ // tracking is live.
21
+ //
22
+ // It lives on `window` rather than in module scope because Next.js re-executes
23
+ // the SDK bundle on every SPA navigation: module-level state is reset each time,
24
+ // but `window` persists for the lifetime of the tab.
25
+ //
26
+ // One object rather than a key per flag, so resetting is a single reassignment.
27
+ // Nothing has to be deleted, and there is no ordering in which half the state
28
+ // survives the reset.
29
+ export const WIN_APV_KEY = '__mpApv__';
30
+
31
+ interface IApvState {
32
+ // The tracker that currently owns the history patch. May have been
33
+ // registered by a module scope that no longer exists.
34
+ tracker?: PageViewTracker;
35
+
36
+ // Guards the initial page view so repeated mParticle.init() calls from SPA
37
+ // re-renders don't fire duplicate logPageView() events for the same page.
38
+ initialPageViewFired: boolean;
39
+ }
40
+
41
+ type WindowWithApv = Window & {
42
+ [WIN_APV_KEY]?: IApvState;
43
+ };
44
+
45
+ // The query params an auto page view may carry. An allowlist, not a denylist:
46
+ // partner URLs routinely hold order ids, email addresses and session tokens, and
47
+ // none of those should reach the event stream because someone forgot to exclude
48
+ // them. Anything absent from this list is dropped.
49
+ //
50
+ // SECURITY: `code`, `state` and `nonce` are the OAuth 2.0 / OIDC authorization
51
+ // code and the CSRF/replay tokens. They are credentials until redeemed, and
52
+ // attaching them here persists them in the event store and forwards them to
53
+ // every configured kit. They are on the list by explicit product decision —
54
+ // deleting that line is the whole of the fix if that decision is revisited.
55
+ export const ALLOWED_QUERY_PARAMS: string[] = [
56
+ // Campaign attribution
57
+ 'utm_source',
58
+ 'utm_medium',
59
+ 'utm_campaign',
60
+ 'utm_term',
61
+ 'utm_content',
62
+ 'utm_id',
63
+
64
+ // Ad-network click ids
65
+ 'gclid',
66
+ 'gbraid',
67
+ 'wbraid',
68
+ 'fbclid',
69
+ 'msclkid',
70
+ 'ttclid',
71
+ 'twclid',
72
+ 'li_fat_id',
73
+ 'dclid',
74
+
75
+ // OAuth / OIDC — see the SECURITY note above
76
+ 'client_id',
77
+ 'redirect_uri',
78
+ 'response_type',
79
+ 'scope',
80
+ 'state',
81
+ 'code',
82
+ 'nonce',
83
+
84
+ // Pagination and search
85
+ 'page',
86
+ 'limit',
87
+ 'offset',
88
+ 'cursor',
89
+ 'per_page',
90
+ 'q',
91
+ 'search',
92
+
93
+ // Referral
94
+ 'ref',
95
+ 'referrer',
96
+ ];
97
+
98
+ interface IPageSnapshot {
99
+ path: string;
100
+ params: Dictionary<string>;
101
+ }
17
102
 
18
- // Guards the initial page view so repeated mParticle.init() calls from SPA
19
- // re-renders don't fire duplicate logPageView() events for the same page.
20
- export const WIN_INIT_PV_KEY = '__mpApvInitPVLogged__' as const;
103
+ interface IPageViewData extends IPageSnapshot {
104
+ hostname: string;
105
+ title: string;
106
+ }
21
107
 
22
- export type WindowWithApvFlags = Window & {
23
- [WIN_TRACKER_KEY]?: PageViewTracker;
24
- [WIN_INIT_PV_KEY]?: boolean;
108
+ interface IPendingNavigation {
109
+ page: IPageSnapshot;
110
+ timeoutId: ReturnType<typeof setTimeout>;
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Pure helpers. No `this`, no globals, no side effects — callable and assertable
115
+ // on their own, which is where the interesting rules live.
116
+ // ---------------------------------------------------------------------------
117
+
118
+ // Pulls the allowlisted query params off a URL. Delegates to the SDK's own
119
+ // parser, which lowercases keys (so `?UTM_Source=` and `?utm_source=` land on one
120
+ // attribute), drops empty values, and carries the fallback for browsers without
121
+ // URLSearchParams. Tolerates an empty href, so SSR yields no params rather than
122
+ // throwing.
123
+ export const allowedQueryParams = (href: string): Dictionary<string> =>
124
+ queryStringParser(href, ALLOWED_QUERY_PARAMS);
125
+
126
+ // The captured params, in allowlist order. Ordering comes from the constant
127
+ // rather than a sort: it is deterministic without needing a comparator, and it
128
+ // does not depend on the object's insertion order, so reordering the query string
129
+ // cannot produce a different key.
130
+ const capturedNames = (params: Dictionary<string>): string[] =>
131
+ ALLOWED_QUERY_PARAMS.filter(name => name in params);
132
+
133
+ // The dedup key: pathname plus the allowlisted params in a fixed order, so that
134
+ // reordering the query string is not a new page. Params outside the allowlist
135
+ // never make it into `page.params` and so cannot key a view — nor can the hash.
136
+ //
137
+ // Values are re-encoded because queryStringParser hands them back DECODED. A
138
+ // value holding the pair delimiters would otherwise serialize exactly like two
139
+ // separate params — `{q: 'a&search=b'}` and `{q: 'a', search: 'b'}` both becoming
140
+ // `q=a&search=b` — and dedup would treat a real navigation between them as the
141
+ // same page and drop the view. `q`, `search` and `redirect_uri` carry `&` and `=`
142
+ // routinely, so this is reachable rather than theoretical.
143
+ export const pageKey = (page: IPageSnapshot): string => {
144
+ const query = capturedNames(page.params)
145
+ .map(name => `${name}=${encodeURIComponent(page.params[name])}`)
146
+ .join('&');
147
+
148
+ return query ? `${page.path}?${query}` : page.path;
25
149
  };
26
150
 
27
- type NavigationSource = 'pushState' | 'replaceState' | 'popstate';
151
+ // Dedup keys on the pageKey above: a change to any captured param is a new page
152
+ // (`?page=2` is a distinct pagination view), while a hash-only change, or a
153
+ // change confined to params we do not capture, is the same page.
154
+ export const isNewPage = (
155
+ lastKey: string | null,
156
+ candidateKey: string
157
+ ): boolean => candidateKey !== lastKey;
158
+
159
+ export const supportsHistoryTracking = (win: Window | null): boolean =>
160
+ !!win &&
161
+ win.history !== undefined &&
162
+ typeof win.history.pushState === 'function' &&
163
+ typeof win.addEventListener === 'function';
164
+
165
+ // Mirrors the event shape of the public mParticle.logPageView(), but carries the
166
+ // path and query params captured when the navigation was accepted rather than the
167
+ // live location.
168
+ export const buildPageViewEvent = ({
169
+ params,
170
+ hostname,
171
+ title,
172
+ path,
173
+ }: IPageViewData): BaseEvent => ({
174
+ messageType: MessageType.PageView,
175
+ name: 'PageView',
176
+ // Params spread first, then the core fields by name, so a core field always
177
+ // wins. No allowlist entry collides with hostname/title/path today; naming
178
+ // them here is what keeps a later addition from silently overwriting one.
179
+ data: {
180
+ ...params,
181
+ hostname,
182
+ title,
183
+ path,
184
+ },
185
+ eventType: EventType.Unknown,
186
+ });
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Window-scoped APV state. Every read and write goes through these helpers, so
190
+ // neither the tracker nor mp-instance touches `window` directly and
191
+ // `typeof window` is checked in exactly one place.
192
+ // ---------------------------------------------------------------------------
193
+
194
+ const apvWindow = (): WindowWithApv | null =>
195
+ typeof window === 'undefined' ? null : (window as WindowWithApv);
196
+
197
+ const freshState = (): IApvState => ({ initialPageViewFired: false });
198
+
199
+ // Reads tolerate absent state, so a read never mutates `window`. Only the
200
+ // writers below create it.
201
+ const readState = (): IApvState | undefined => {
202
+ const win = apvWindow();
203
+ return win ? win[WIN_APV_KEY] : undefined;
204
+ };
28
205
 
29
- export class PageViewTracker {
30
- static hasInitialPageViewFired(): boolean {
31
- return !!(window as WindowWithApvFlags)[WIN_INIT_PV_KEY];
206
+ const writeState = (): IApvState | null => {
207
+ const win = apvWindow();
208
+ if (!win) {
209
+ return null;
32
210
  }
33
211
 
34
- static markInitialPageViewFired(): void {
35
- (window as WindowWithApvFlags)[WIN_INIT_PV_KEY] = true;
212
+ if (!win[WIN_APV_KEY]) {
213
+ win[WIN_APV_KEY] = freshState();
36
214
  }
37
215
 
38
- // Tears down any window-registered tracker that differs from instanceTracker
39
- // (e.g. left by a previous module load), then clears both APV window flags.
40
- // Call from _resetForTests after tearing down the instance-level tracker.
41
- static resetWindowState(instanceTracker?: PageViewTracker): void {
42
- const windowTracker = getWindowTracker();
43
- if (windowTracker && windowTracker !== instanceTracker) {
44
- windowTracker.teardown();
45
- }
46
- if (typeof window !== 'undefined') {
47
- delete (window as WindowWithApvFlags)[WIN_INIT_PV_KEY];
48
- }
49
- setWindowTracker(undefined);
216
+ return win[WIN_APV_KEY];
217
+ };
218
+
219
+ export const getActiveTracker = (): PageViewTracker | undefined => {
220
+ const state = readState();
221
+ return state ? state.tracker : undefined;
222
+ };
223
+
224
+ export const hasInitialPageViewFired = (): boolean => {
225
+ const state = readState();
226
+ return !!(state && state.initialPageViewFired);
227
+ };
228
+
229
+ export const markInitialPageViewFired = (): void => {
230
+ const state = writeState();
231
+ if (state) {
232
+ state.initialPageViewFired = true;
233
+ }
234
+ };
235
+
236
+ // Returns the page to its pre-APV state: stops the active tracker (restoring the
237
+ // history methods it patched), then swaps in fresh state. The state object is the
238
+ // authoritative handle, so this reaches trackers whose owning module is gone.
239
+ export const resetPageViewTracking = (): void => {
240
+ const win = apvWindow();
241
+ if (!win) {
242
+ return;
243
+ }
244
+
245
+ const active = getActiveTracker();
246
+ if (active) {
247
+ active.teardown();
50
248
  }
51
249
 
52
- mpInstance: IMParticleWebSDKInstance;
250
+ // One reassignment replaces every flag at once, so nothing can be left behind
251
+ // whatever shape the previous state was in.
252
+ win[WIN_APV_KEY] = freshState();
253
+ };
254
+
255
+ const setActiveTracker = (tracker: PageViewTracker): void => {
256
+ const state = writeState();
257
+ if (state) {
258
+ state.tracker = tracker;
259
+ }
260
+ };
53
261
 
54
- private lastPath: string | null = null;
55
- private _isActive = false;
262
+ const clearActiveTracker = (tracker: PageViewTracker): void => {
263
+ const state = readState();
264
+ if (state && state.tracker === tracker) {
265
+ state.tracker = undefined;
266
+ }
267
+ };
56
268
 
57
- get isActive(): boolean {
58
- return this._isActive;
269
+ const currentPage = (): IPageSnapshot => ({
270
+ path: window.location.pathname,
271
+ params: allowedQueryParams(getHref()),
272
+ });
273
+
274
+ // Log-safe description of a page: the path, plus the NAMES of the captured
275
+ // params. Values are deliberately omitted — verbose logging goes to the console,
276
+ // and session-replay tooling ships console output off-domain, which is not a
277
+ // place for an OAuth code or a consumer's search terms.
278
+ // Only reachable before init() assigns lastPage, which is ahead of any navigation
279
+ // handler being able to run, so this returns nothing rather than a sentinel.
280
+ const describePage = (page: IPageSnapshot | null): string => {
281
+ if (!page) {
282
+ return '';
59
283
  }
60
284
 
61
- private pendingNavigations: Array<{
62
- path: string;
63
- timeoutId: ReturnType<typeof setTimeout>;
64
- }> = [];
285
+ const names = capturedNames(page.params);
286
+ return names.length ? `${page.path} (params: ${names.join()})` : page.path;
287
+ };
65
288
 
66
- private originalPushState: HistoryStateMethod | null = null;
67
- private originalReplaceState: HistoryStateMethod | null = null;
289
+ // ---------------------------------------------------------------------------
290
+ // History patching
291
+ // ---------------------------------------------------------------------------
292
+
293
+ // Wraps pushState/replaceState so `onNavigate` runs after the real method, and
294
+ // returns the single function that undoes it — or null when nothing was
295
+ // installed (history is already wrapped, or a frozen/sealed History rejected the
296
+ // assignment). Handing back one undo closure keeps the wrapper and original
297
+ // references out of the tracker entirely: there is no half-patched state for a
298
+ // caller to inspect, and no way to restore the wrong pair.
299
+ export const patchHistory = (
300
+ onNavigate: (source: NavigationSource) => void,
301
+ log: (message: string) => void
302
+ ): (() => void) | null => {
303
+ if ((window.history.pushState as MarkedHistoryMethod)[WRAPPED_MARKER]) {
304
+ log(
305
+ '[patch] history already wrapped, skipping to avoid double-wrap — STACKED WRAPPER DETECTED'
306
+ );
307
+ return null;
308
+ }
68
309
 
69
- private pushStateWrapper: HistoryStateMethod | null = null;
70
- private replaceStateWrapper: HistoryStateMethod | null = null;
310
+ const originals = {} as Record<HistoryMethodName, HistoryStateMethod>;
311
+ const wrappers = {} as Record<HistoryMethodName, HistoryStateMethod>;
71
312
 
313
+ HISTORY_METHODS.forEach(name => {
314
+ const original = window.history[name];
315
+ originals[name] = original;
316
+
317
+ const wrapper = function(
318
+ this: History,
319
+ ...args: Parameters<HistoryStateMethod>
320
+ ): void {
321
+ const result = original.apply(this, args);
322
+ onNavigate(name);
323
+ return result;
324
+ };
325
+
326
+ Object.defineProperty(wrapper, WRAPPED_MARKER, {
327
+ value: true,
328
+ enumerable: false,
329
+ });
330
+
331
+ wrappers[name] = wrapper;
332
+ });
333
+
334
+ // Restore per method: a third party may have patched one of the two on top of
335
+ // ours after we installed. Clobbering theirs would break their tracking, so
336
+ // leave anything that is no longer ours in place.
337
+ const restore = (): void =>
338
+ HISTORY_METHODS.forEach(name => {
339
+ if (window.history[name] === wrappers[name]) {
340
+ window.history[name] = originals[name];
341
+ log(`[teardown] restored original ${name}`);
342
+ } else {
343
+ log(
344
+ `[teardown] ${name} no longer ours; leaving in place, gating callback to no-op`
345
+ );
346
+ }
347
+ });
348
+
349
+ try {
350
+ HISTORY_METHODS.forEach(name => {
351
+ window.history[name] = wrappers[name];
352
+ });
353
+ } catch (e) {
354
+ log(
355
+ `[error] failed to patch history methods (frozen/sealed), rolling back: ${e}`
356
+ );
357
+ try {
358
+ restore();
359
+ } catch (restoreError) {
360
+ log(
361
+ `[error] failed to restore history methods after patch failure: ${restoreError}`
362
+ );
363
+ }
364
+ return null;
365
+ }
366
+
367
+ return restore;
368
+ };
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Tracker
372
+ // ---------------------------------------------------------------------------
373
+
374
+ export class PageViewTracker {
375
+ private readonly mpInstance: IMParticleWebSDKInstance;
376
+
377
+ private lastPage: IPageSnapshot | null = null;
378
+ private active = false;
379
+ private pendingNavigations: IPendingNavigation[] = [];
380
+
381
+ private undoHistoryPatch: (() => void) | null = null;
72
382
  private popStateListener: (() => void) | null = null;
73
383
 
74
384
  constructor(mpInstance: IMParticleWebSDKInstance) {
75
385
  this.mpInstance = mpInstance;
76
386
  }
77
387
 
78
- private isSupportedEnvironment(): boolean {
79
- return (
80
- typeof window !== 'undefined' &&
81
- typeof window.history !== 'undefined' &&
82
- typeof window.history.pushState === 'function' &&
83
- typeof window.addEventListener === 'function'
84
- );
388
+ // True while this tracker owns the history patch and is listening for
389
+ // navigations. Flips to false on teardown, including the teardown a
390
+ // successor tracker performs during a handoff. Read-only, and the one thing
391
+ // worth checking on `window.__mpApv__.tracker` from a console.
392
+ public get isActive(): boolean {
393
+ return this.active;
85
394
  }
86
395
 
87
396
  public init(): void {
88
- this.mpInstance.Logger.verbose('mParticle APV: [init] PageViewTracker Init');
89
- if (!this.isSupportedEnvironment()) {
90
- this.mpInstance.Logger.verbose(
91
- 'mParticle APV: [init] unsupported environment (no History API), not starting'
92
- );
93
- return;
94
- }
397
+ this.log('[init] PageViewTracker Init');
95
398
 
96
- // Tear down any tracker left over from a previous module load.
97
- // window[WIN_TRACKER_KEY] outlives module re-evaluation; this is the
98
- // only way to reach a tracker instance in a dead module scope.
99
- // Transfer any pending navigations first so a route change that queued a
100
- // deferred fire before module re-evaluation occurs is not silently dropped.
101
- const prev = getWindowTracker();
102
- let inheritedPaths: string[] = [];
103
- if (prev && prev !== this) {
104
- this.mpInstance.Logger.verbose(
105
- 'mParticle APV: [init] found stale tracker from previous module load — transferring pending navigations and tearing down'
399
+ if (!supportsHistoryTracking(apvWindow())) {
400
+ this.log(
401
+ '[init] unsupported environment (no History API), not starting'
106
402
  );
107
- inheritedPaths = prev.takePendingNavigations();
108
- prev.teardown();
403
+ return;
109
404
  }
110
405
 
111
- if (this._isActive) {
112
- this.mpInstance.Logger.verbose(
113
- 'mParticle APV: [init] starting (teardown-first for idempotency)'
114
- );
115
- // Preserve pending navigations through the self-teardown so a route
116
- // change queued before mParticle.init() was called again is not lost.
117
- inheritedPaths = [...inheritedPaths, ...this.takePendingNavigations()];
118
- this.teardown();
406
+ // Take over from whichever tracker is currently active: one left behind by
407
+ // a previous module load, or this same tracker on a repeat
408
+ // mParticle.init(). Their pending navigations are transferred rather than
409
+ // dropped — a route change may have queued a deferred fire that has not
410
+ // flushed yet, and dropping it loses a page view entirely.
411
+ const active = getActiveTracker();
412
+ let inheritedPages = this.retire(active);
413
+ if (this.active && active !== this) {
414
+ inheritedPages = inheritedPages.concat(this.retire(this));
119
415
  }
120
416
 
121
- this._isActive = true;
417
+ this.active = true;
418
+ this.lastPage = currentPage();
419
+ this.log(`[init] seeded lastPage: ${describePage(this.lastPage)}`);
122
420
 
123
- this.lastPath = this.getCurrentKey();
124
-
125
- this.mpInstance.Logger.verbose(
126
- `mParticle APV: [init] seeded lastPath: ${this.lastPath}`
421
+ this.undoHistoryPatch = patchHistory(
422
+ source => this.safeHandleNavigation(source),
423
+ message => this.log(message)
127
424
  );
425
+ this.popStateListener = () => this.safeHandleNavigation('popstate');
426
+ window.addEventListener('popstate', this.popStateListener);
128
427
 
129
- this.patchHistoryMethods();
130
- this.addNavigationListeners();
131
-
132
- setWindowTracker(this);
428
+ setActiveTracker(this);
133
429
 
134
- this.mpInstance.Logger.verbose(
135
- 'mParticle APV: [init] patched pushState/replaceState + listening for popstate'
430
+ this.log(
431
+ '[init] patched pushState/replaceState + listening for popstate'
136
432
  );
137
433
 
138
- // Re-fire navigations that the stale tracker had queued but not yet flushed
139
- // when module re-evaluation occurred. Title is read at flush time so the
140
- // router's post-navigation render commit still has a chance to update it.
141
- inheritedPaths.forEach(path => {
142
- const timeoutId = setTimeout(() => {
143
- this.pendingNavigations = this.pendingNavigations.filter(
144
- p => p.timeoutId !== timeoutId
145
- );
146
- if (!this._isActive) return;
147
- this.mpInstance._SessionManager.resetSessionTimer();
148
- this.firePageView(path);
149
- }, 0);
150
- this.pendingNavigations.push({ path, timeoutId });
151
- });
434
+ inheritedPages.forEach(page => this.scheduleFire(page));
152
435
  }
153
436
 
154
- private patchHistoryMethods(): void {
155
- const self = this;
437
+ public teardown(): void {
438
+ // Discard pending deferred fires (e.g. AutoLogPageView switched off on
439
+ // re-init). A handoff calls takePendingNavigations() first, so by this
440
+ // point those paths are already transferred rather than dropped.
441
+ this.takePendingNavigations();
156
442
 
157
- const installed = window.history.pushState as MarkedHistoryMethod;
158
- if (installed[WRAPPED_MARKER]) {
159
- this.mpInstance.Logger.verbose(
160
- 'mParticle APV: [patch] history already wrapped, skipping to avoid double-wrap — STACKED WRAPPER DETECTED'
161
- );
162
- return;
443
+ if (this.popStateListener) {
444
+ window.removeEventListener('popstate', this.popStateListener);
445
+ this.popStateListener = null;
163
446
  }
164
447
 
165
- const originalPushState = window.history.pushState;
166
- const originalReplaceState = window.history.replaceState;
167
- this.originalPushState = originalPushState;
168
- this.originalReplaceState = originalReplaceState;
169
-
170
- const pushStateWrapper = function(
171
- this: History,
172
- ...args: Parameters<HistoryStateMethod>
173
- ): void {
174
- const result = originalPushState.apply(this, args);
175
- if (self._isActive) {
176
- self.safeHandleNavigation('pushState');
177
- }
178
- return result;
179
- };
448
+ if (this.undoHistoryPatch) {
449
+ this.undoHistoryPatch();
450
+ this.undoHistoryPatch = null;
451
+ }
180
452
 
181
- const replaceStateWrapper = function(
182
- this: History,
183
- ...args: Parameters<HistoryStateMethod>
184
- ): void {
185
- const result = originalReplaceState.apply(this, args);
186
- if (self._isActive) {
187
- self.safeHandleNavigation('replaceState');
188
- }
189
- return result;
190
- };
453
+ this.active = false;
454
+ clearActiveTracker(this);
455
+ }
191
456
 
192
- Object.defineProperty(pushStateWrapper, WRAPPED_MARKER, {
193
- value: true,
194
- enumerable: false,
195
- });
196
- Object.defineProperty(replaceStateWrapper, WRAPPED_MARKER, {
197
- value: true,
198
- enumerable: false,
199
- });
457
+ // Stops the outgoing tracker and returns the pages it had queued so this
458
+ // tracker can re-schedule them once it is active. Private, but reachable
459
+ // across instances: the handoff protocol stays internal to the class.
460
+ private retire(outgoing: PageViewTracker | undefined): IPageSnapshot[] {
461
+ if (!outgoing) {
462
+ return [];
463
+ }
200
464
 
201
- this.pushStateWrapper = pushStateWrapper;
202
- this.replaceStateWrapper = replaceStateWrapper;
465
+ this.log(
466
+ outgoing === this
467
+ ? '[init] retiring this tracker for a repeat mParticle.init() — transferring pending navigations and tearing down'
468
+ : '[init] retiring a tracker from a previous module load — transferring pending navigations and tearing down'
469
+ );
203
470
 
204
- try {
205
- window.history.pushState = pushStateWrapper;
206
- window.history.replaceState = replaceStateWrapper;
207
- } catch (e) {
208
- this.mpInstance.Logger.verbose(
209
- `mParticle APV: [error] failed to patch history methods (frozen/sealed), rolling back: ${e}`
210
- );
211
- try {
212
- if (window.history.pushState === pushStateWrapper) {
213
- window.history.pushState = originalPushState;
214
- }
215
- if (window.history.replaceState === replaceStateWrapper) {
216
- window.history.replaceState = originalReplaceState;
217
- }
218
- } catch (restoreError) {
219
- this.mpInstance.Logger.verbose(
220
- `mParticle APV: [error] failed to restore history methods after patch failure: ${restoreError}`
221
- );
222
- }
223
- this.pushStateWrapper = null;
224
- this.replaceStateWrapper = null;
225
- this.originalPushState = null;
226
- this.originalReplaceState = null;
227
- }
471
+ const pages = outgoing.takePendingNavigations();
472
+ outgoing.teardown();
473
+ return pages;
228
474
  }
229
475
 
230
- private addNavigationListeners(): void {
231
- this.popStateListener = () => this.safeHandleNavigation('popstate');
232
- window.addEventListener('popstate', this.popStateListener);
476
+ // Cancels this tracker's deferred fires and hands back their captured pages.
477
+ private takePendingNavigations(): IPageSnapshot[] {
478
+ const pending = this.pendingNavigations;
479
+ this.pendingNavigations = [];
480
+ pending.forEach(({ timeoutId }) => clearTimeout(timeoutId));
481
+ return pending.map(({ page }) => page);
233
482
  }
234
483
 
235
484
  private safeHandleNavigation(source: NavigationSource): void {
485
+ // An orphaned wrapper still calls in: teardown can only restore a history
486
+ // method that is still ours, so one a third party patched over stays
487
+ // installed and keeps firing at a dead tracker. Bail before touching
488
+ // lastPath or queueing a fire the flush would just abort. Guarding here
489
+ // rather than at each wrapper also covers the popstate path.
490
+ if (!this.active) {
491
+ return;
492
+ }
493
+
236
494
  try {
237
495
  this.handleNavigation(source);
238
496
  } catch (e) {
239
- this.mpInstance.Logger.verbose(
240
- `mParticle APV: [error] navigation handler threw (${source}), page view skipped: ${e}`
497
+ this.log(
498
+ `[error] navigation handler threw (${source}), page view skipped: ${e}`
241
499
  );
242
500
  }
243
501
  }
244
502
 
245
- private getCurrentKey(): string {
246
- return window.location.pathname;
247
- }
248
-
249
503
  private handleNavigation(source: NavigationSource): void {
250
- const candidatePath = this.getCurrentKey();
504
+ const candidate = currentPage();
505
+ const lastKey = this.lastPage ? pageKey(this.lastPage) : null;
251
506
 
252
- this.mpInstance.Logger.verbose(
253
- `mParticle APV: [detect] navigation signal (source: ${source}, candidatePath: ${candidatePath}, lastPath: ${this.lastPath})`
507
+ this.log(
508
+ `[detect] navigation signal (source: ${source}, candidate: ${describePage(
509
+ candidate
510
+ )}, last: ${describePage(this.lastPage)})`
254
511
  );
255
512
 
256
- if (candidatePath === this.lastPath) {
257
- this.mpInstance.Logger.verbose(
258
- `mParticle APV: [dedupe] pathname unchanged, skipping (source: ${source}, path: ${candidatePath})`
513
+ if (!isNewPage(lastKey, pageKey(candidate))) {
514
+ this.log(
515
+ `[dedupe] page unchanged, skipping (source: ${source}, page: ${describePage(
516
+ candidate
517
+ )})`
259
518
  );
260
519
  return;
261
520
  }
262
521
 
263
- this.mpInstance.Logger.verbose(
264
- `mParticle APV: [accept] pathname changed, scheduling fire (source: ${source}, from: ${this.lastPath}, to: ${candidatePath})`
522
+ this.log(
523
+ `[accept] page changed, scheduling fire (source: ${source}, from: ${describePage(
524
+ this.lastPage
525
+ )}, to: ${describePage(candidate)})`
265
526
  );
266
- this.lastPath = candidatePath;
267
-
268
- // Snapshot the path now: it is already settled at this point, and a
269
- // same-tick navigation would otherwise overwrite window.location
270
- // before the deferred flush reads it. The title is intentionally read
271
- // later (in the deferred flush) so the router's post-navigation render
272
- // commit has a chance to update document.title first.
273
- const capturedPath = candidatePath;
527
+ this.lastPage = candidate;
528
+ this.scheduleFire(candidate);
529
+ }
274
530
 
531
+ // Deferred by a macrotask so the router's post-navigation render commit has a
532
+ // chance to update document.title before the event is built. The path and
533
+ // query params are snapshotted now because they are already settled, whereas a
534
+ // same-tick navigation would overwrite window.location before the flush reads
535
+ // it.
536
+ private scheduleFire(page: IPageSnapshot): void {
275
537
  const timeoutId = setTimeout(() => {
276
538
  this.pendingNavigations = this.pendingNavigations.filter(
277
539
  p => p.timeoutId !== timeoutId
278
540
  );
279
- if (!this._isActive) {
280
- this.mpInstance.Logger.verbose(
281
- 'mParticle APV: [defer] fire aborted, tracker inactive (torn down before flush)'
541
+
542
+ if (!this.active) {
543
+ this.log(
544
+ '[defer] fire aborted, tracker inactive (torn down before flush)'
282
545
  );
283
546
  return;
284
547
  }
285
548
 
286
549
  this.mpInstance._SessionManager.resetSessionTimer();
287
- this.firePageView(capturedPath);
550
+ this.firePageView(page);
288
551
  }, 0);
289
- this.pendingNavigations.push({ path: capturedPath, timeoutId });
552
+
553
+ this.pendingNavigations.push({ page, timeoutId });
290
554
  }
291
555
 
292
- // Mirrors the event shape of the public mParticle.logPageView(), but carries
293
- // the captured SPA path rather than reading the live location.
294
- private firePageView(path: string): void {
556
+ private firePageView(page: IPageSnapshot): void {
295
557
  const title = window.document.title;
296
-
297
- this.mpInstance.Logger.verbose(
298
- `mParticle APV: [fire] deferred flush -> _Events.logEvent(PageView) (path: ${path}, title: ${title})`
299
- );
300
-
301
- this.mpInstance._Events.logEvent({
302
- messageType: MessageType.PageView,
303
- name: 'PageView',
304
- data: {
305
- hostname: window.location.hostname,
306
- title,
307
- path,
308
- },
309
- eventType: EventType.Unknown,
558
+ const event = buildPageViewEvent({
559
+ ...page,
560
+ hostname: window.location.hostname,
561
+ title,
310
562
  });
311
- }
312
-
313
- // Cancels pending deferred fires and returns their captured paths so a
314
- // successor tracker can re-schedule them. Call before teardown() during
315
- // module-replacement handoff.
316
- public takePendingNavigations(): string[] {
317
- const paths = this.pendingNavigations.map(p => p.path);
318
- this.pendingNavigations.forEach(p => clearTimeout(p.timeoutId));
319
- this.pendingNavigations = [];
320
- return paths;
321
- }
322
-
323
- private restoreHistoryMethod(
324
- methodName: 'pushState' | 'replaceState',
325
- original: HistoryStateMethod | null,
326
- wrapper: HistoryStateMethod | null
327
- ): void {
328
- if (!original) return;
329
- const stillOurs = wrapper !== null && window.history[methodName] === wrapper;
330
- if (stillOurs) {
331
- window.history[methodName] = original;
332
- this.mpInstance.Logger.verbose(
333
- `mParticle APV: [teardown] restored original ${methodName}`
334
- );
335
- } else {
336
- this.mpInstance.Logger.verbose(
337
- `mParticle APV: [teardown] ${methodName} no longer ours; leaving in place, gating callback to no-op`
338
- );
339
- }
340
- }
341
-
342
- public teardown(): void {
343
- // Cancel any pending deferred fires (e.g. when the feature flag is
344
- // disabled on re-init). For module-replacement, call takePendingNavigations()
345
- // before teardown() instead so paths are transferred, not discarded.
346
- this.pendingNavigations.forEach(p => clearTimeout(p.timeoutId));
347
- this.pendingNavigations = [];
348
-
349
- if (this.popStateListener) {
350
- window.removeEventListener('popstate', this.popStateListener);
351
- this.popStateListener = null;
352
- }
353
563
 
354
- this.restoreHistoryMethod('pushState', this.originalPushState, this.pushStateWrapper);
355
- this.originalPushState = null;
356
- this.pushStateWrapper = null;
357
-
358
- this.restoreHistoryMethod('replaceState', this.originalReplaceState, this.replaceStateWrapper);
359
- this.originalReplaceState = null;
360
- this.replaceStateWrapper = null;
564
+ this.log(
565
+ `[fire] deferred flush -> _Events.logEvent(PageView) (page: ${describePage(
566
+ page
567
+ )}, title: ${title})`
568
+ );
361
569
 
362
- this._isActive = false;
363
- if (getWindowTracker() === this) {
364
- setWindowTracker(undefined);
365
- }
570
+ this.mpInstance._Events.logEvent(event);
366
571
  }
367
- }
368
-
369
- function getWindowTracker(): PageViewTracker | undefined {
370
- if (typeof window === 'undefined') return undefined;
371
- return (window as WindowWithApvFlags)[WIN_TRACKER_KEY];
372
- }
373
572
 
374
- function setWindowTracker(tracker: PageViewTracker | undefined): void {
375
- if (typeof window === 'undefined') return;
376
- const win = window as WindowWithApvFlags;
377
- if (tracker) {
378
- win[WIN_TRACKER_KEY] = tracker;
379
- } else {
380
- delete win[WIN_TRACKER_KEY];
573
+ private log(message: string): void {
574
+ this.mpInstance.Logger.verbose(`mParticle APV: ${message}`);
381
575
  }
382
576
  }