@mparticle/web-sdk 2.79.2 → 2.79.3

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,12 @@
1
1
  import { IMParticleWebSDKInstance } from './mp-instance';
2
+ import { BaseEvent } from './sdkRuntimeModels';
2
3
  import { EventType, MessageType } from './types';
3
4
 
4
5
  type HistoryStateMethod = History['pushState'];
6
+ type HistoryMethodName = 'pushState' | 'replaceState';
7
+ type NavigationSource = HistoryMethodName | 'popstate';
8
+
9
+ const HISTORY_METHODS: HistoryMethodName[] = ['pushState', 'replaceState'];
5
10
 
6
11
  const WRAPPED_MARKER = '__mpApvWrapped__';
7
12
 
@@ -9,374 +14,429 @@ type MarkedHistoryMethod = HistoryStateMethod & {
9
14
  [WRAPPED_MARKER]?: boolean;
10
15
  };
11
16
 
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__';
17
+ // All APV state hangs off one window key, and it is the public debugging
18
+ // contract: `window.__mpApv__` is what you inspect in a console to see whether
19
+ // tracking is live.
20
+ //
21
+ // It lives on `window` rather than in module scope because Next.js re-executes
22
+ // the SDK bundle on every SPA navigation: module-level state is reset each time,
23
+ // but `window` persists for the lifetime of the tab.
24
+ //
25
+ // One object rather than a key per flag, so resetting is a single reassignment.
26
+ // Nothing has to be deleted, and there is no ordering in which half the state
27
+ // survives the reset.
28
+ export const WIN_APV_KEY = '__mpApv__';
29
+
30
+ interface IApvState {
31
+ // The tracker that currently owns the history patch. May have been
32
+ // registered by a module scope that no longer exists.
33
+ tracker?: PageViewTracker;
34
+
35
+ // Guards the initial page view so repeated mParticle.init() calls from SPA
36
+ // re-renders don't fire duplicate logPageView() events for the same page.
37
+ initialPageViewFired: boolean;
38
+ }
17
39
 
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;
40
+ type WindowWithApv = Window & {
41
+ [WIN_APV_KEY]?: IApvState;
42
+ };
43
+
44
+ interface IPageViewData {
45
+ hostname: string;
46
+ title: string;
47
+ path: string;
48
+ }
21
49
 
22
- export type WindowWithApvFlags = Window & {
23
- [WIN_TRACKER_KEY]?: PageViewTracker;
24
- [WIN_INIT_PV_KEY]?: boolean;
50
+ interface IPendingNavigation {
51
+ path: string;
52
+ timeoutId: ReturnType<typeof setTimeout>;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Pure helpers. No `this`, no globals, no side effects — callable and assertable
57
+ // on their own, which is where the interesting rules live.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ // Dedup keys on pathname only: a query-string- or hash-only change is the same
61
+ // page and must not fire a view.
62
+ export const isNewPage = (
63
+ lastPath: string | null,
64
+ candidatePath: string
65
+ ): boolean => candidatePath !== lastPath;
66
+
67
+ export const supportsHistoryTracking = (win: Window | null): boolean =>
68
+ !!win &&
69
+ win.history !== undefined &&
70
+ typeof win.history.pushState === 'function' &&
71
+ typeof win.addEventListener === 'function';
72
+
73
+ // Mirrors the event shape of the public mParticle.logPageView(), but carries the
74
+ // path captured when the navigation was accepted rather than the live location.
75
+ export const buildPageViewEvent = (data: IPageViewData): BaseEvent => ({
76
+ messageType: MessageType.PageView,
77
+ name: 'PageView',
78
+ data: { ...data },
79
+ eventType: EventType.Unknown,
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Window-scoped APV state. Every read and write goes through these helpers, so
84
+ // neither the tracker nor mp-instance touches `window` directly and
85
+ // `typeof window` is checked in exactly one place.
86
+ // ---------------------------------------------------------------------------
87
+
88
+ const apvWindow = (): WindowWithApv | null =>
89
+ typeof window === 'undefined' ? null : (window as WindowWithApv);
90
+
91
+ const freshState = (): IApvState => ({ initialPageViewFired: false });
92
+
93
+ // Reads tolerate absent state, so a read never mutates `window`. Only the
94
+ // writers below create it.
95
+ const readState = (): IApvState | undefined => {
96
+ const win = apvWindow();
97
+ return win ? win[WIN_APV_KEY] : undefined;
25
98
  };
26
99
 
27
- type NavigationSource = 'pushState' | 'replaceState' | 'popstate';
100
+ const writeState = (): IApvState | null => {
101
+ const win = apvWindow();
102
+ if (!win) {
103
+ return null;
104
+ }
28
105
 
29
- export class PageViewTracker {
30
- static hasInitialPageViewFired(): boolean {
31
- return !!(window as WindowWithApvFlags)[WIN_INIT_PV_KEY];
106
+ if (!win[WIN_APV_KEY]) {
107
+ win[WIN_APV_KEY] = freshState();
32
108
  }
33
109
 
34
- static markInitialPageViewFired(): void {
35
- (window as WindowWithApvFlags)[WIN_INIT_PV_KEY] = true;
110
+ return win[WIN_APV_KEY];
111
+ };
112
+
113
+ export const getActiveTracker = (): PageViewTracker | undefined => {
114
+ const state = readState();
115
+ return state ? state.tracker : undefined;
116
+ };
117
+
118
+ export const hasInitialPageViewFired = (): boolean => {
119
+ const state = readState();
120
+ return !!(state && state.initialPageViewFired);
121
+ };
122
+
123
+ export const markInitialPageViewFired = (): void => {
124
+ const state = writeState();
125
+ if (state) {
126
+ state.initialPageViewFired = true;
36
127
  }
128
+ };
37
129
 
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);
130
+ // Returns the page to its pre-APV state: stops the active tracker (restoring the
131
+ // history methods it patched), then swaps in fresh state. The state object is the
132
+ // authoritative handle, so this reaches trackers whose owning module is gone.
133
+ export const resetPageViewTracking = (): void => {
134
+ const win = apvWindow();
135
+ if (!win) {
136
+ return;
50
137
  }
51
138
 
52
- mpInstance: IMParticleWebSDKInstance;
139
+ const active = getActiveTracker();
140
+ if (active) {
141
+ active.teardown();
142
+ }
53
143
 
54
- private lastPath: string | null = null;
55
- private _isActive = false;
144
+ // One reassignment replaces every flag at once, so nothing can be left behind
145
+ // whatever shape the previous state was in.
146
+ win[WIN_APV_KEY] = freshState();
147
+ };
148
+
149
+ const setActiveTracker = (tracker: PageViewTracker): void => {
150
+ const state = writeState();
151
+ if (state) {
152
+ state.tracker = tracker;
153
+ }
154
+ };
155
+
156
+ const clearActiveTracker = (tracker: PageViewTracker): void => {
157
+ const state = readState();
158
+ if (state && state.tracker === tracker) {
159
+ state.tracker = undefined;
160
+ }
161
+ };
56
162
 
57
- get isActive(): boolean {
58
- return this._isActive;
163
+ const currentPathname = (): string => window.location.pathname;
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // History patching
167
+ // ---------------------------------------------------------------------------
168
+
169
+ // Wraps pushState/replaceState so `onNavigate` runs after the real method, and
170
+ // returns the single function that undoes it — or null when nothing was
171
+ // installed (history is already wrapped, or a frozen/sealed History rejected the
172
+ // assignment). Handing back one undo closure keeps the wrapper and original
173
+ // references out of the tracker entirely: there is no half-patched state for a
174
+ // caller to inspect, and no way to restore the wrong pair.
175
+ export const patchHistory = (
176
+ onNavigate: (source: NavigationSource) => void,
177
+ log: (message: string) => void
178
+ ): (() => void) | null => {
179
+ if ((window.history.pushState as MarkedHistoryMethod)[WRAPPED_MARKER]) {
180
+ log(
181
+ '[patch] history already wrapped, skipping to avoid double-wrap — STACKED WRAPPER DETECTED'
182
+ );
183
+ return null;
59
184
  }
60
185
 
61
- private pendingNavigations: Array<{
62
- path: string;
63
- timeoutId: ReturnType<typeof setTimeout>;
64
- }> = [];
186
+ const originals = {} as Record<HistoryMethodName, HistoryStateMethod>;
187
+ const wrappers = {} as Record<HistoryMethodName, HistoryStateMethod>;
65
188
 
66
- private originalPushState: HistoryStateMethod | null = null;
67
- private originalReplaceState: HistoryStateMethod | null = null;
189
+ HISTORY_METHODS.forEach(name => {
190
+ const original = window.history[name];
191
+ originals[name] = original;
68
192
 
69
- private pushStateWrapper: HistoryStateMethod | null = null;
70
- private replaceStateWrapper: HistoryStateMethod | null = null;
193
+ const wrapper = function(
194
+ this: History,
195
+ ...args: Parameters<HistoryStateMethod>
196
+ ): void {
197
+ const result = original.apply(this, args);
198
+ onNavigate(name);
199
+ return result;
200
+ };
71
201
 
202
+ Object.defineProperty(wrapper, WRAPPED_MARKER, {
203
+ value: true,
204
+ enumerable: false,
205
+ });
206
+
207
+ wrappers[name] = wrapper;
208
+ });
209
+
210
+ // Restore per method: a third party may have patched one of the two on top of
211
+ // ours after we installed. Clobbering theirs would break their tracking, so
212
+ // leave anything that is no longer ours in place.
213
+ const restore = (): void =>
214
+ HISTORY_METHODS.forEach(name => {
215
+ if (window.history[name] === wrappers[name]) {
216
+ window.history[name] = originals[name];
217
+ log(`[teardown] restored original ${name}`);
218
+ } else {
219
+ log(
220
+ `[teardown] ${name} no longer ours; leaving in place, gating callback to no-op`
221
+ );
222
+ }
223
+ });
224
+
225
+ try {
226
+ HISTORY_METHODS.forEach(name => {
227
+ window.history[name] = wrappers[name];
228
+ });
229
+ } catch (e) {
230
+ log(
231
+ `[error] failed to patch history methods (frozen/sealed), rolling back: ${e}`
232
+ );
233
+ try {
234
+ restore();
235
+ } catch (restoreError) {
236
+ log(
237
+ `[error] failed to restore history methods after patch failure: ${restoreError}`
238
+ );
239
+ }
240
+ return null;
241
+ }
242
+
243
+ return restore;
244
+ };
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Tracker
248
+ // ---------------------------------------------------------------------------
249
+
250
+ export class PageViewTracker {
251
+ private readonly mpInstance: IMParticleWebSDKInstance;
252
+
253
+ private lastPath: string | null = null;
254
+ private active = false;
255
+ private pendingNavigations: IPendingNavigation[] = [];
256
+
257
+ private undoHistoryPatch: (() => void) | null = null;
72
258
  private popStateListener: (() => void) | null = null;
73
259
 
74
260
  constructor(mpInstance: IMParticleWebSDKInstance) {
75
261
  this.mpInstance = mpInstance;
76
262
  }
77
263
 
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
- );
264
+ // True while this tracker owns the history patch and is listening for
265
+ // navigations. Flips to false on teardown, including the teardown a
266
+ // successor tracker performs during a handoff. Read-only, and the one thing
267
+ // worth checking on `window.__mpApv__.tracker` from a console.
268
+ public get isActive(): boolean {
269
+ return this.active;
85
270
  }
86
271
 
87
272
  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
- }
273
+ this.log('[init] PageViewTracker Init');
95
274
 
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'
275
+ if (!supportsHistoryTracking(apvWindow())) {
276
+ this.log(
277
+ '[init] unsupported environment (no History API), not starting'
106
278
  );
107
- inheritedPaths = prev.takePendingNavigations();
108
- prev.teardown();
279
+ return;
109
280
  }
110
281
 
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();
282
+ // Take over from whichever tracker is currently active: one left behind by
283
+ // a previous module load, or this same tracker on a repeat
284
+ // mParticle.init(). Their pending navigations are transferred rather than
285
+ // dropped — a route change may have queued a deferred fire that has not
286
+ // flushed yet, and dropping it loses a page view entirely.
287
+ const active = getActiveTracker();
288
+ let inheritedPaths = this.retire(active);
289
+ if (this.active && active !== this) {
290
+ inheritedPaths = inheritedPaths.concat(this.retire(this));
119
291
  }
120
292
 
121
- this._isActive = true;
293
+ this.active = true;
294
+ this.lastPath = currentPathname();
295
+ this.log(`[init] seeded lastPath: ${this.lastPath}`);
122
296
 
123
- this.lastPath = this.getCurrentKey();
124
-
125
- this.mpInstance.Logger.verbose(
126
- `mParticle APV: [init] seeded lastPath: ${this.lastPath}`
297
+ this.undoHistoryPatch = patchHistory(
298
+ source => this.safeHandleNavigation(source),
299
+ message => this.log(message)
127
300
  );
301
+ this.popStateListener = () => this.safeHandleNavigation('popstate');
302
+ window.addEventListener('popstate', this.popStateListener);
128
303
 
129
- this.patchHistoryMethods();
130
- this.addNavigationListeners();
131
-
132
- setWindowTracker(this);
304
+ setActiveTracker(this);
133
305
 
134
- this.mpInstance.Logger.verbose(
135
- 'mParticle APV: [init] patched pushState/replaceState + listening for popstate'
306
+ this.log(
307
+ '[init] patched pushState/replaceState + listening for popstate'
136
308
  );
137
309
 
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
- });
310
+ inheritedPaths.forEach(path => this.scheduleFire(path));
152
311
  }
153
312
 
154
- private patchHistoryMethods(): void {
155
- const self = this;
313
+ public teardown(): void {
314
+ // Discard pending deferred fires (e.g. AutoLogPageView switched off on
315
+ // re-init). A handoff calls takePendingNavigations() first, so by this
316
+ // point those paths are already transferred rather than dropped.
317
+ this.takePendingNavigations();
156
318
 
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;
319
+ if (this.popStateListener) {
320
+ window.removeEventListener('popstate', this.popStateListener);
321
+ this.popStateListener = null;
163
322
  }
164
323
 
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
- };
324
+ if (this.undoHistoryPatch) {
325
+ this.undoHistoryPatch();
326
+ this.undoHistoryPatch = null;
327
+ }
180
328
 
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
- };
329
+ this.active = false;
330
+ clearActiveTracker(this);
331
+ }
191
332
 
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
- });
333
+ // Stops the outgoing tracker and returns the paths it had queued so this
334
+ // tracker can re-schedule them once it is active. Private, but reachable
335
+ // across instances: the handoff protocol stays internal to the class.
336
+ private retire(outgoing: PageViewTracker | undefined): string[] {
337
+ if (!outgoing) {
338
+ return [];
339
+ }
200
340
 
201
- this.pushStateWrapper = pushStateWrapper;
202
- this.replaceStateWrapper = replaceStateWrapper;
341
+ this.log(
342
+ outgoing === this
343
+ ? '[init] retiring this tracker for a repeat mParticle.init() — transferring pending navigations and tearing down'
344
+ : '[init] retiring a tracker from a previous module load — transferring pending navigations and tearing down'
345
+ );
203
346
 
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
- }
347
+ const paths = outgoing.takePendingNavigations();
348
+ outgoing.teardown();
349
+ return paths;
228
350
  }
229
351
 
230
- private addNavigationListeners(): void {
231
- this.popStateListener = () => this.safeHandleNavigation('popstate');
232
- window.addEventListener('popstate', this.popStateListener);
352
+ // Cancels this tracker's deferred fires and hands back their captured paths.
353
+ private takePendingNavigations(): string[] {
354
+ const pending = this.pendingNavigations;
355
+ this.pendingNavigations = [];
356
+ pending.forEach(p => clearTimeout(p.timeoutId));
357
+ return pending.map(p => p.path);
233
358
  }
234
359
 
235
360
  private safeHandleNavigation(source: NavigationSource): void {
361
+ // An orphaned wrapper still calls in: teardown can only restore a history
362
+ // method that is still ours, so one a third party patched over stays
363
+ // installed and keeps firing at a dead tracker. Bail before touching
364
+ // lastPath or queueing a fire the flush would just abort. Guarding here
365
+ // rather than at each wrapper also covers the popstate path.
366
+ if (!this.active) {
367
+ return;
368
+ }
369
+
236
370
  try {
237
371
  this.handleNavigation(source);
238
372
  } catch (e) {
239
- this.mpInstance.Logger.verbose(
240
- `mParticle APV: [error] navigation handler threw (${source}), page view skipped: ${e}`
373
+ this.log(
374
+ `[error] navigation handler threw (${source}), page view skipped: ${e}`
241
375
  );
242
376
  }
243
377
  }
244
378
 
245
- private getCurrentKey(): string {
246
- return window.location.pathname;
247
- }
248
-
249
379
  private handleNavigation(source: NavigationSource): void {
250
- const candidatePath = this.getCurrentKey();
380
+ const candidatePath = currentPathname();
251
381
 
252
- this.mpInstance.Logger.verbose(
253
- `mParticle APV: [detect] navigation signal (source: ${source}, candidatePath: ${candidatePath}, lastPath: ${this.lastPath})`
382
+ this.log(
383
+ `[detect] navigation signal (source: ${source}, candidatePath: ${candidatePath}, lastPath: ${this.lastPath})`
254
384
  );
255
385
 
256
- if (candidatePath === this.lastPath) {
257
- this.mpInstance.Logger.verbose(
258
- `mParticle APV: [dedupe] pathname unchanged, skipping (source: ${source}, path: ${candidatePath})`
386
+ if (!isNewPage(this.lastPath, candidatePath)) {
387
+ this.log(
388
+ `[dedupe] pathname unchanged, skipping (source: ${source}, path: ${candidatePath})`
259
389
  );
260
390
  return;
261
391
  }
262
392
 
263
- this.mpInstance.Logger.verbose(
264
- `mParticle APV: [accept] pathname changed, scheduling fire (source: ${source}, from: ${this.lastPath}, to: ${candidatePath})`
393
+ this.log(
394
+ `[accept] pathname changed, scheduling fire (source: ${source}, from: ${this.lastPath}, to: ${candidatePath})`
265
395
  );
266
396
  this.lastPath = candidatePath;
397
+ this.scheduleFire(candidatePath);
398
+ }
267
399
 
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;
274
-
400
+ // Deferred by a macrotask so the router's post-navigation render commit has a
401
+ // chance to update document.title before the event is built. The path is
402
+ // snapshotted now because it is already settled, whereas a same-tick
403
+ // navigation would overwrite window.location before the flush reads it.
404
+ private scheduleFire(path: string): void {
275
405
  const timeoutId = setTimeout(() => {
276
406
  this.pendingNavigations = this.pendingNavigations.filter(
277
407
  p => p.timeoutId !== timeoutId
278
408
  );
279
- if (!this._isActive) {
280
- this.mpInstance.Logger.verbose(
281
- 'mParticle APV: [defer] fire aborted, tracker inactive (torn down before flush)'
409
+
410
+ if (!this.active) {
411
+ this.log(
412
+ '[defer] fire aborted, tracker inactive (torn down before flush)'
282
413
  );
283
414
  return;
284
415
  }
285
416
 
286
417
  this.mpInstance._SessionManager.resetSessionTimer();
287
- this.firePageView(capturedPath);
418
+ this.firePageView(path);
288
419
  }, 0);
289
- this.pendingNavigations.push({ path: capturedPath, timeoutId });
420
+
421
+ this.pendingNavigations.push({ path, timeoutId });
290
422
  }
291
423
 
292
- // Mirrors the event shape of the public mParticle.logPageView(), but carries
293
- // the captured SPA path rather than reading the live location.
294
424
  private firePageView(path: string): void {
295
425
  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,
426
+ const event = buildPageViewEvent({
427
+ hostname: window.location.hostname,
428
+ title,
429
+ path,
310
430
  });
311
- }
312
431
 
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
-
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;
432
+ this.log(
433
+ `[fire] deferred flush -> _Events.logEvent(PageView) (path: ${path}, title: ${title})`
434
+ );
361
435
 
362
- this._isActive = false;
363
- if (getWindowTracker() === this) {
364
- setWindowTracker(undefined);
365
- }
436
+ this.mpInstance._Events.logEvent(event);
366
437
  }
367
- }
368
-
369
- function getWindowTracker(): PageViewTracker | undefined {
370
- if (typeof window === 'undefined') return undefined;
371
- return (window as WindowWithApvFlags)[WIN_TRACKER_KEY];
372
- }
373
438
 
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];
439
+ private log(message: string): void {
440
+ this.mpInstance.Logger.verbose(`mParticle APV: ${message}`);
381
441
  }
382
442
  }