@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.
- package/README.md +52 -0
- package/dist/mparticle.common.js +156 -56
- package/dist/mparticle.esm.js +156 -56
- package/dist/mparticle.js +941 -708
- package/dist/types/src/filteredMparticleUser.d.ts +15 -7
- package/dist/types/src/forwardingStatsUploader.d.ts +4 -4
- package/dist/types/src/mp-instance.d.ts +3 -2
- package/dist/types/src/nativeSdkHelpers.d.ts +3 -12
- package/dist/types/src/nativeSdkHelpers.interfaces.d.ts +15 -1
- package/dist/types/src/pageViewTracker.d.ts +35 -24
- package/dist/types/src/persistence.d.ts +3 -44
- package/dist/types/src/persistence.interfaces.d.ts +10 -10
- package/dist/types/src/sdkRuntimeModels.d.ts +5 -3
- package/dist/types/src/store.d.ts +3 -0
- package/package.json +1 -1
- package/src/events.ts +9 -1
- package/src/filteredMparticleUser.ts +160 -0
- package/src/{forwardingStatsUploader.js → forwardingStatsUploader.ts} +25 -12
- package/src/mp-instance.ts +27 -25
- package/src/nativeSdkHelpers.interfaces.ts +21 -1
- package/src/{nativeSdkHelpers.js → nativeSdkHelpers.ts} +59 -34
- package/src/pageViewTracker.ts +475 -281
- package/src/persistence.interfaces.ts +10 -10
- package/src/persistence.ts +1210 -0
- package/src/sdkRuntimeModels.ts +5 -3
- package/src/store.ts +3 -0
- package/src/filteredMparticleUser.js +0 -101
- package/src/persistence.js +0 -985
package/src/pageViewTracker.ts
CHANGED
|
@@ -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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
103
|
+
interface IPageViewData extends IPageSnapshot {
|
|
104
|
+
hostname: string;
|
|
105
|
+
title: string;
|
|
106
|
+
}
|
|
21
107
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
206
|
+
const writeState = (): IApvState | null => {
|
|
207
|
+
const win = apvWindow();
|
|
208
|
+
if (!win) {
|
|
209
|
+
return null;
|
|
32
210
|
}
|
|
33
211
|
|
|
34
|
-
|
|
35
|
-
|
|
212
|
+
if (!win[WIN_APV_KEY]) {
|
|
213
|
+
win[WIN_APV_KEY] = freshState();
|
|
36
214
|
}
|
|
37
215
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}> = [];
|
|
285
|
+
const names = capturedNames(page.params);
|
|
286
|
+
return names.length ? `${page.path} (params: ${names.join()})` : page.path;
|
|
287
|
+
};
|
|
65
288
|
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
108
|
-
prev.teardown();
|
|
403
|
+
return;
|
|
109
404
|
}
|
|
110
405
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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.
|
|
417
|
+
this.active = true;
|
|
418
|
+
this.lastPage = currentPage();
|
|
419
|
+
this.log(`[init] seeded lastPage: ${describePage(this.lastPage)}`);
|
|
122
420
|
|
|
123
|
-
this.
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
130
|
-
this.addNavigationListeners();
|
|
131
|
-
|
|
132
|
-
setWindowTracker(this);
|
|
428
|
+
setActiveTracker(this);
|
|
133
429
|
|
|
134
|
-
this.
|
|
135
|
-
'
|
|
430
|
+
this.log(
|
|
431
|
+
'[init] patched pushState/replaceState + listening for popstate'
|
|
136
432
|
);
|
|
137
433
|
|
|
138
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
this.
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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.
|
|
240
|
-
`
|
|
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
|
|
504
|
+
const candidate = currentPage();
|
|
505
|
+
const lastKey = this.lastPage ? pageKey(this.lastPage) : null;
|
|
251
506
|
|
|
252
|
-
this.
|
|
253
|
-
`
|
|
507
|
+
this.log(
|
|
508
|
+
`[detect] navigation signal (source: ${source}, candidate: ${describePage(
|
|
509
|
+
candidate
|
|
510
|
+
)}, last: ${describePage(this.lastPage)})`
|
|
254
511
|
);
|
|
255
512
|
|
|
256
|
-
if (
|
|
257
|
-
this.
|
|
258
|
-
`
|
|
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.
|
|
264
|
-
`
|
|
522
|
+
this.log(
|
|
523
|
+
`[accept] page changed, scheduling fire (source: ${source}, from: ${describePage(
|
|
524
|
+
this.lastPage
|
|
525
|
+
)}, to: ${describePage(candidate)})`
|
|
265
526
|
);
|
|
266
|
-
this.
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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(
|
|
550
|
+
this.firePageView(page);
|
|
288
551
|
}, 0);
|
|
289
|
-
|
|
552
|
+
|
|
553
|
+
this.pendingNavigations.push({ page, timeoutId });
|
|
290
554
|
}
|
|
291
555
|
|
|
292
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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.
|
|
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
|
-
|
|
375
|
-
|
|
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
|
}
|