@mparticle/web-sdk 3.20.0 → 3.22.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.
@@ -35,6 +35,8 @@ export declare class BatchUploader {
35
35
  private exitHandler;
36
36
  private visibilityChangeHandler;
37
37
  private destroyed;
38
+ private batchOrder;
39
+ private nextBatchOrder;
38
40
  /**
39
41
  * Creates an instance of a BatchUploader
40
42
  * @param {IMParticleWebSDKInstance} mpInstance - the mParticle SDK instance
@@ -1,5 +1,6 @@
1
- export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet';
2
- export type ButtonLabelSource = 'labelledby' | 'aria' | 'text' | 'alt' | 'title' | 'value' | 'none';
1
+ export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet' | 'link';
2
+ export type LinkType = 'same_page' | 'outbound' | 'download';
3
+ export type ButtonLabelSource = 'labelledby' | 'aria' | 'text' | 'label' | 'alt' | 'title' | 'value' | 'none';
3
4
  export interface IButtonClick {
4
5
  label?: string;
5
6
  labelSource: ButtonLabelSource;
@@ -12,5 +13,9 @@ export interface IButtonClick {
12
13
  form?: string;
13
14
  element: ButtonElementKind;
14
15
  type: string;
16
+ linkType?: LinkType;
17
+ linkDomain?: string;
18
+ fileExtension?: string;
19
+ checked?: boolean;
15
20
  }
16
21
  export declare function describeButtonClick(control: Element): IButtonClick | undefined;
@@ -15,6 +15,9 @@ export declare class ButtonTracker {
15
15
  private pagePath;
16
16
  private pageEvents;
17
17
  private pending;
18
+ private uploadTimerId;
19
+ private uploadHeld;
20
+ private leaving;
18
21
  constructor(mpInstance: IMParticleWebSDKInstance);
19
22
  get isActive(): boolean;
20
23
  init(options: IButtonTrackerOptions): void;
@@ -28,6 +31,9 @@ export declare class ButtonTracker {
28
31
  private readonly onEvent;
29
32
  private observe;
30
33
  private logClick;
34
+ private queuedForUpload;
35
+ private uploadNow;
36
+ private cancelUpload;
31
37
  private emit;
32
38
  private eventsEnabled;
33
39
  private isKitSuppressed;
@@ -1,3 +1,9 @@
1
1
  export type CheckoutIntentKind = 'commit' | 'wallet' | 'checkout_step' | 'frame';
2
+ export type CheckoutIntentSource = 'vendor' | 'label' | 'brand' | 'frame' | 'submit';
3
+ export interface ICheckoutIntent {
4
+ kind: Exclude<CheckoutIntentKind, 'frame'>;
5
+ source: CheckoutIntentSource;
6
+ }
7
+ export declare function classifyIntent(control: Element, pathname: string): ICheckoutIntent | undefined;
2
8
  export declare function classifyIntentControl(control: Element, pathname: string): Exclude<CheckoutIntentKind, 'frame'> | undefined;
3
- export declare function classifyIntentFrame(target: EventTarget | null, win: Window): 'frame' | undefined;
9
+ export declare function classifyIntentFrame(target: EventTarget | null, win: Window): 'wallet' | 'frame' | undefined;
@@ -1,6 +1,9 @@
1
1
  export declare const CONTROL_SELECTOR = "button, input[type=\"submit\"], input[type=\"button\"], input[type=\"image\"], [role=\"button\"]";
2
2
  export declare function hasComposedAncestor(element: Element, selector: string): boolean;
3
3
  export declare function isRoktElement(element: Element): boolean;
4
+ export declare function vendorButtonKind(control: Element): 'wallet' | 'checkout_step' | 'none' | undefined;
4
5
  export declare function isWalletElement(element: Element): boolean;
6
+ export declare function isQuietIntentControl(control: Element, isVendorWallet: boolean): boolean;
5
7
  export declare function findControl(path: Array<EventTarget | null>): Element | undefined;
6
8
  export declare function isUnsafeTextSource(element: Element): boolean;
9
+ export declare function isUnsafeLabelSource(label: Element): boolean;
@@ -8,7 +8,7 @@ import { IStore, LocalSessionAttributes } from "./store";
8
8
  import { IErrorReportingService, ILoggingService } from "./reporting/types";
9
9
  import { IRoktLauncherOptions } from "./roktLauncherOptions";
10
10
  import IntegrationCapture from "./integrationCapture";
11
- import type { CheckoutIntentKind } from "./buttonTracking/checkoutIntent";
11
+ import type { CheckoutIntentKind, CheckoutIntentSource } from "./buttonTracking/checkoutIntent";
12
12
  export type RoktAttributeValueArray = Array<string | number | boolean>;
13
13
  export type RoktAttributeValueType = string | number | boolean | undefined | null;
14
14
  export type RoktAttributeValue = RoktAttributeValueType | RoktAttributeValueArray;
@@ -46,6 +46,8 @@ export interface RoktKitFilterSettings {
46
46
  export interface ICheckoutIntentSignal {
47
47
  kind: CheckoutIntentKind;
48
48
  t: number;
49
+ source?: CheckoutIntentSource;
50
+ pathname?: string;
49
51
  }
50
52
  export interface IRoktKitSettings {
51
53
  accountId?: string;
@@ -123,6 +125,7 @@ export default class RoktManager {
123
125
  private watchRouteChanges;
124
126
  private notifyRouteChange;
125
127
  isCheckoutIntentSuppressed(): boolean;
128
+ isCheckoutIntentLive(): boolean;
126
129
  notifyCheckoutIntent(signal: ICheckoutIntentSignal, pathname?: string): void;
127
130
  discardCheckoutIntents(): void;
128
131
  private flushCheckoutIntents;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mparticle/web-sdk",
3
- "version": "3.20.0",
3
+ "version": "3.22.0",
4
4
  "description": "mParticle core SDK for web applications",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -52,6 +52,11 @@ export class BatchUploader {
52
52
  private exitHandler: (() => void) | null = null;
53
53
  private visibilityChangeHandler: (() => void) | null = null;
54
54
  private destroyed = false;
55
+ // The order this page's uploads first picked each batch up in, by request
56
+ // id. Batches enter uploads in creation order, so this keeps that order for
57
+ // batches that share a millisecond.
58
+ private batchOrder = new Map<string, number>();
59
+ private nextBatchOrder = 0;
55
60
 
56
61
  /**
57
62
  * Creates an instance of a BatchUploader
@@ -444,12 +449,27 @@ export class BatchUploader {
444
449
  // Clear out pending batches to avoid any potential duplication
445
450
  const batchesToUpload = this.batchesQueuedForProcessing;
446
451
  this.batchesQueuedForProcessing = [];
452
+ batchesToUpload.forEach(batch => {
453
+ if (!this.batchOrder.has(batch.source_request_id)) {
454
+ this.batchOrder.set(batch.source_request_id, this.nextBatchOrder++);
455
+ }
456
+ });
447
457
 
448
458
  const batchesThatDidNotUpload = await this.uploadBatches(
449
459
  batchesToUpload,
450
460
  useBeacon
451
461
  );
452
462
 
463
+ // Only a batch that will be retried needs its place kept.
464
+ const retriedIds = (batchesThatDidNotUpload || []).map(
465
+ batch => batch.source_request_id
466
+ );
467
+ batchesToUpload.forEach(batch => {
468
+ if (retriedIds.indexOf(batch.source_request_id) === -1) {
469
+ this.batchOrder.delete(batch.source_request_id);
470
+ }
471
+ });
472
+
453
473
  // Batches that do not successfully upload are added back to the process queue
454
474
  // in the order they were created so that we can attempt re-transmission in
455
475
  // the same sequence. This is to prevent any potential data corruption.
@@ -475,7 +495,19 @@ export class BatchUploader {
475
495
  }
476
496
 
477
497
  private storeBatchesQueuedForProcessing(): void {
478
- const batches = this.batchesQueuedForProcessing;
498
+ // Another upload may have stored its failed batches while this one was
499
+ // in flight. Merge them in creation order, whichever upload finished
500
+ // first, so they are not overwritten and quota trimming drops the oldest.
501
+ const orderOf = (batch: Batch): number =>
502
+ this.batchOrder.get(batch.source_request_id) ?? -1;
503
+ const batches = [
504
+ ...this.batchesQueuedForProcessing,
505
+ ...(this.batchVault.retrieve() ?? []),
506
+ ].sort(
507
+ (a, b) =>
508
+ orderOf(a) - orderOf(b) ||
509
+ (a.timestamp_unixtime_ms ?? 0) - (b.timestamp_unixtime_ms ?? 0)
510
+ );
479
511
 
480
512
  // Nothing to persist: reset offline storage to an empty state.
481
513
  if (isEmpty(batches)) {
@@ -33,12 +33,18 @@ export const buildButtonClickEvent = (
33
33
  test_id: click.testId,
34
34
  controls: click.controls,
35
35
  form: click.form,
36
+ link_type: click.linkType,
37
+ link_domain: click.linkDomain,
38
+ file_extension: click.fileExtension,
36
39
  };
37
40
  for (const key of Object.keys(optional)) {
38
41
  if (optional[key]) {
39
42
  data[key] = optional[key];
40
43
  }
41
44
  }
45
+ if (click.checked !== undefined) {
46
+ data.checked = click.checked;
47
+ }
42
48
  return {
43
49
  messageType: MessageType.PageEvent,
44
50
  name: BUTTON_CLICK_EVENT_NAME,
@@ -1,6 +1,7 @@
1
1
  import { isFunction } from '../utils';
2
2
  import {
3
3
  hasComposedAncestor,
4
+ isUnsafeLabelSource,
4
5
  isUnsafeTextSource,
5
6
  isWalletElement,
6
7
  } from './controls';
@@ -8,12 +9,18 @@ import { maskLabel } from './labelMasking';
8
9
 
9
10
  // Describes a clicked button for auto button tracking: its masked name, icon,
10
11
  // hand-written identifiers and kind. Never reads form field values.
11
- export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet';
12
+ export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet' | 'link';
13
+
14
+ // The link clicks page views can't see: ones that stay on the page, leave the site or download a file.
15
+ export type LinkType = 'same_page' | 'outbound' | 'download';
16
+
17
+ const DOWNLOAD_EXTENSIONS = /\.(pdf|zip|csv|txt|ics|docx?|xlsx?|pptx?|dmg|exe|apk|mp3|mp4)$/i;
12
18
 
13
19
  export type ButtonLabelSource =
14
20
  | 'labelledby'
15
21
  | 'aria'
16
22
  | 'text'
23
+ | 'label'
17
24
  | 'alt'
18
25
  | 'title'
19
26
  | 'value'
@@ -31,6 +38,10 @@ export interface IButtonClick {
31
38
  form?: string;
32
39
  element: ButtonElementKind;
33
40
  type: string;
41
+ linkType?: LinkType;
42
+ linkDomain?: string;
43
+ fileExtension?: string;
44
+ checked?: boolean;
34
45
  }
35
46
 
36
47
  function elementKind(control: Element): ButtonElementKind {
@@ -38,7 +49,10 @@ function elementKind(control: Element): ButtonElementKind {
38
49
  return 'wallet';
39
50
  }
40
51
  const tag = control.tagName.toLowerCase();
41
- return tag === 'button' || tag === 'input' ? tag : 'role';
52
+ if (tag === 'button' || tag === 'input') {
53
+ return tag;
54
+ }
55
+ return control.matches('a[href]:not([role="button"])') ? 'link' : 'role';
42
56
  }
43
57
 
44
58
  function textOf(element: Element | null): string {
@@ -52,9 +66,10 @@ function nonBlank(value: string | null): string | undefined {
52
66
  }
53
67
 
54
68
  // The button's name as a screen reader would read it, simplified: the first of
55
- // aria-labelledby, aria-label, own text (or an inner image's alt), title, and for
56
- // inputs their value. The text sources are skipped for controls that hold form
57
- // fields or more than four children, so a typed value is never read.
69
+ // aria-labelledby, aria-label, own text (or an inner image's alt), title, for
70
+ // inputs their value, and for checkboxes and radios their label's text. The text
71
+ // sources are skipped for controls that hold form fields or more than four
72
+ // children, so a typed value is never read.
58
73
  function accessibleName(
59
74
  control: Element
60
75
  ): { raw?: string; source: ButtonLabelSource } {
@@ -97,6 +112,16 @@ function accessibleName(
97
112
  // What the browser shows on an input without a value.
98
113
  return { raw: type, source: 'value' };
99
114
  }
115
+ } else if (type === 'checkbox' || type === 'radio') {
116
+ // The label the browser ties to the box, never the box's value.
117
+ const label = control.labels ? control.labels[0] : undefined;
118
+ const text =
119
+ label && !isUnsafeLabelSource(label) && label.textContent
120
+ ? label.textContent.trim()
121
+ : '';
122
+ if (text) {
123
+ return { raw: text, source: 'label' };
124
+ }
100
125
  }
101
126
  } else if (readable) {
102
127
  const text = textOf(control);
@@ -230,13 +255,62 @@ function controlType(control: Element, element: ButtonElementKind): string {
230
255
  : 'button';
231
256
  }
232
257
 
233
- // Describes a clicked control for auto button tracking. Links are left to page
234
- // views, so a link click is not described.
258
+ interface ILinkDetails {
259
+ type: LinkType;
260
+ domain?: string;
261
+ fileExtension?: string;
262
+ }
263
+
264
+ // Reads the link's own resolved URL parts, never its full href: only the hostname of
265
+ // a link that leaves the site and the extension of a file it downloads travel. Navigation
266
+ // within the site is left to page views, and mailto:, tel: and other schemes are skipped.
267
+ function describeLink(link: Element): ILinkDetails | undefined {
268
+ const href = (link.getAttribute('href') || '').trim();
269
+ if (!href || href.charAt(0) === '#') {
270
+ return { type: 'same_page' };
271
+ }
272
+ if (!(link instanceof HTMLAnchorElement)) {
273
+ return undefined;
274
+ }
275
+ const protocol = link.protocol.toLowerCase();
276
+ if (protocol === 'javascript:') {
277
+ return { type: 'same_page' };
278
+ }
279
+ if (protocol !== 'http:' && protocol !== 'https:') {
280
+ return undefined;
281
+ }
282
+ const domain =
283
+ link.hostname !== window.location.hostname
284
+ ? link.hostname.toLowerCase()
285
+ : undefined;
286
+ const extension = DOWNLOAD_EXTENSIONS.exec(link.pathname);
287
+ if (link.hasAttribute('download') || extension) {
288
+ return {
289
+ type: 'download',
290
+ domain,
291
+ fileExtension: extension ? extension[1].toLowerCase() : undefined,
292
+ };
293
+ }
294
+ if (domain) {
295
+ return { type: 'outbound', domain };
296
+ }
297
+ return link.pathname === window.location.pathname &&
298
+ link.search === window.location.search
299
+ ? { type: 'same_page' }
300
+ : undefined;
301
+ }
302
+
303
+ // Describes a clicked control for auto button tracking. A link counts only when page
304
+ // views can't see the click, and carries its link type.
235
305
  export function describeButtonClick(
236
306
  control: Element
237
307
  ): IButtonClick | undefined {
308
+ let link: ILinkDetails | undefined;
238
309
  if (control.matches('a[href]:not([role="button"])')) {
239
- return undefined;
310
+ link = describeLink(control);
311
+ if (!link) {
312
+ return undefined;
313
+ }
240
314
  }
241
315
  const element = elementKind(control);
242
316
  const name = accessibleName(control);
@@ -255,5 +329,15 @@ export function describeButtonClick(
255
329
  form: formIdentifier(control),
256
330
  element,
257
331
  type: controlType(control, element),
332
+ linkType: link ? link.type : undefined,
333
+ linkDomain: link ? link.domain : undefined,
334
+ fileExtension: link ? link.fileExtension : undefined,
335
+ // The box as the click left it: the browser toggles it before the click
336
+ // reaches listeners, and puts it back if the click is cancelled.
337
+ checked:
338
+ control instanceof HTMLInputElement &&
339
+ (control.type === 'checkbox' || control.type === 'radio')
340
+ ? control.checked
341
+ : undefined,
258
342
  };
259
343
  }
@@ -4,7 +4,8 @@ import { buildButtonClickEvent } from './buttonClickEvent';
4
4
  import { describeButtonClick, IButtonClick } from './buttonDescription';
5
5
  import {
6
6
  CheckoutIntentKind,
7
- classifyIntentControl,
7
+ CheckoutIntentSource,
8
+ classifyIntent,
8
9
  classifyIntentFrame,
9
10
  } from './checkoutIntent';
10
11
  import { findControl } from './controls';
@@ -19,6 +20,9 @@ const PATH_LIMIT = 12;
19
20
  // A ceiling, not a dedupe: a held Enter key or browser automation fires a
20
21
  // trusted click per repeat, and each one is forwarded to every kit.
21
22
  const PAGE_EVENT_LIMIT = 100;
23
+ // A click uploads at once. Taps that follow it this quickly are held and share
24
+ // one upload once they stop.
25
+ const UPLOAD_DEBOUNCE_MS = 100;
22
26
 
23
27
  interface IBtnState {
24
28
  tracker?: ButtonTracker;
@@ -77,7 +81,7 @@ const CAPTURE_PASSIVE: AddEventListenerOptions = {
77
81
  const PASSIVE: AddEventListenerOptions = { passive: true };
78
82
 
79
83
  // Logs every button click as an mParticle event, and hands checkout intent to the
80
- // Rokt kit as a frozen {kind, t}. Submits and wallet frame focus feed the kit only.
84
+ // Rokt kit as a frozen {kind, t, source, pathname}. Submits and wallet frame focus feed the kit only.
81
85
  export class ButtonTracker {
82
86
  private readonly mpInstance: IMParticleWebSDKInstance;
83
87
  private active = false;
@@ -90,6 +94,10 @@ export class ButtonTracker {
90
94
  private pageEvents = 0;
91
95
  // Clicks read at dispatch and waiting for the next paint.
92
96
  private pending: Array<() => void> = [];
97
+ private uploadTimerId: ReturnType<typeof setTimeout> | null = null;
98
+ private uploadHeld = false;
99
+ // Set while the page is left, when the SDK's exit upload sends the queue.
100
+ private leaving = false;
93
101
 
94
102
  constructor(mpInstance: IMParticleWebSDKInstance) {
95
103
  this.mpInstance = mpInstance;
@@ -181,6 +189,7 @@ export class ButtonTracker {
181
189
  this.lastSent = {};
182
190
  this.pagePath = null;
183
191
  this.pageEvents = 0;
192
+ this.cancelUpload();
184
193
  }
185
194
 
186
195
  private enqueue(delivery: () => void): void {
@@ -206,7 +215,14 @@ export class ButtonTracker {
206
215
  event.type === 'pagehide' ||
207
216
  document.visibilityState === 'hidden'
208
217
  ) {
209
- this.drain();
218
+ this.leaving = true;
219
+ try {
220
+ this.drain();
221
+ } finally {
222
+ this.leaving = false;
223
+ }
224
+ // The SDK's exit upload sends the queue, held taps included.
225
+ this.cancelUpload();
210
226
  }
211
227
  };
212
228
 
@@ -244,7 +260,9 @@ export class ButtonTracker {
244
260
  // button that disables or relabels itself is seen as it was clicked.
245
261
  // Logging and the kit hand-off wait for the next paint.
246
262
  let click: IButtonClick | undefined;
263
+ let box: HTMLInputElement | undefined;
247
264
  let kind: CheckoutIntentKind | undefined;
265
+ let source: CheckoutIntentSource | undefined;
248
266
  let unconfirmedCommit = false;
249
267
  if (layer === 'click') {
250
268
  // The composed path is only populated during dispatch.
@@ -255,10 +273,15 @@ export class ButtonTracker {
255
273
  );
256
274
  if (control) {
257
275
  click = logsEvents ? describeButtonClick(control) : undefined;
276
+ if (click && click.checked !== undefined) {
277
+ box = control as HTMLInputElement;
278
+ }
258
279
  // Checkout intent is only worked out when the kit can use it.
259
- kind = signalsKit
260
- ? classifyIntentControl(control, pathname)
280
+ const intent = signalsKit
281
+ ? classifyIntent(control, pathname)
261
282
  : undefined;
283
+ kind = intent ? intent.kind : undefined;
284
+ source = intent ? intent.source : undefined;
262
285
  }
263
286
  } else if (layer === 'submit') {
264
287
  const submitter = (event as Event & { submitter?: Element | null })
@@ -268,8 +291,11 @@ export class ButtonTracker {
268
291
  // commit click, which is known once that click is delivered.
269
292
  if (!submitter) {
270
293
  kind = 'commit';
294
+ source = 'submit';
271
295
  } else if (control) {
272
- kind = classifyIntentControl(control, pathname);
296
+ const intent = classifyIntent(control, pathname);
297
+ kind = intent ? intent.kind : undefined;
298
+ source = intent ? intent.source : undefined;
273
299
  }
274
300
  unconfirmedCommit = !submitter;
275
301
  }
@@ -284,6 +310,7 @@ export class ButtonTracker {
284
310
  // Read after the blur settles: some browsers have not moved
285
311
  // focus into the frame yet when the window blurs.
286
312
  kind = classifyIntentFrame(document.activeElement, window);
313
+ source = 'frame';
287
314
  } else if (
288
315
  unconfirmedCommit &&
289
316
  t - this.lastCommitClick > SUBMIT_AFTER_COMMIT_CLICK_MS
@@ -291,10 +318,21 @@ export class ButtonTracker {
291
318
  kind = undefined;
292
319
  }
293
320
  if (click && logsEvents) {
321
+ // The box as this click left it. The browser toggles it
322
+ // before dispatch and puts it back once a cancelled click
323
+ // is over, so only a cancelled click changes the reading
324
+ // taken at dispatch: a checkbox to the other state, a
325
+ // radio to however it was restored.
326
+ if (box && event.defaultPrevented) {
327
+ click.checked =
328
+ box.type === 'checkbox'
329
+ ? !click.checked
330
+ : box.checked;
331
+ }
294
332
  this.logClick(click, pathname);
295
333
  }
296
334
  if (kind) {
297
- this.emit(kind, layer, t, pathname);
335
+ this.emit(kind, source, layer, t, pathname);
298
336
  }
299
337
  } catch (e) {
300
338
  this.log('[error] delivery threw: ' + e);
@@ -317,13 +355,59 @@ export class ButtonTracker {
317
355
  this.pageEvents++;
318
356
  // Restarts an expired session, as public logEvent() and page views do.
319
357
  this.mpInstance._SessionManager.resetSessionTimer();
358
+ const queued = this.queuedForUpload();
320
359
  this.mpInstance._Events.logEvent(
321
360
  buildButtonClickEvent(click, pathname)
322
361
  );
362
+ // Only a click waiting for the batch interval is sent early: a held
363
+ // one goes out when released, and with batching off it already has.
364
+ // A checkbox or radio waits for the batch, since choosing an option
365
+ // doesn't leave the page the way a confirm click can.
366
+ if (
367
+ !this.leaving &&
368
+ click.checked === undefined &&
369
+ this.queuedForUpload() > queued
370
+ ) {
371
+ this.uploadNow();
372
+ }
373
+ }
374
+
375
+ // Events waiting in the upload queue for the next batch interval.
376
+ private queuedForUpload(): number {
377
+ const uploader = this.mpInstance._APIClient.uploader;
378
+ return uploader ? uploader.eventsQueuedForProcessing.length : 0;
379
+ }
380
+
381
+ // Uploads a click without waiting for the batch interval. A tap inside the
382
+ // wait after the last one is held, and each restarts the wait, so a burst
383
+ // after the first click goes out in one request once the taps stop.
384
+ private uploadNow(): void {
385
+ if (this.uploadTimerId === null) {
386
+ this.mpInstance.upload();
387
+ } else {
388
+ clearTimeout(this.uploadTimerId);
389
+ this.uploadHeld = true;
390
+ }
391
+ this.uploadTimerId = setTimeout(() => {
392
+ this.uploadTimerId = null;
393
+ if (this.uploadHeld) {
394
+ this.uploadHeld = false;
395
+ this.mpInstance.upload();
396
+ }
397
+ }, UPLOAD_DEBOUNCE_MS);
398
+ }
399
+
400
+ private cancelUpload(): void {
401
+ if (this.uploadTimerId !== null) {
402
+ clearTimeout(this.uploadTimerId);
403
+ this.uploadTimerId = null;
404
+ }
405
+ this.uploadHeld = false;
323
406
  }
324
407
 
325
408
  private emit(
326
409
  kind: CheckoutIntentKind,
410
+ source: CheckoutIntentSource | undefined,
327
411
  layer: string,
328
412
  t: number,
329
413
  pathname: string
@@ -342,7 +426,7 @@ export class ButtonTracker {
342
426
  this.lastSent[kind] = { t, pathname };
343
427
 
344
428
  this.mpInstance._RoktManager.notifyCheckoutIntent(
345
- Object.freeze({ kind, t }),
429
+ Object.freeze({ kind, t, source, pathname }),
346
430
  pathname
347
431
  );
348
432
  }