Skip to content

Commit b5098e2

Browse files
committed
Allow independent re-entrant history calls
1 parent 79d2ad3 commit b5098e2

1 file changed

Lines changed: 57 additions & 31 deletions

File tree

plugins/3rd-party-optimizer/src/yieldGTMCalls.ts

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ function queueAfterPaintCallback(callback: VoidFunction) {
3131
/** Runs all callbacks that still need to complete before the page is hidden/unloaded. */
3232
function resolvePendingPromises() {
3333
while (pendingCallbacks.size) {
34+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
3435
const callback = pendingCallbacks.values().next().value!
3536
pendingCallbacks.delete(callback)
3637
callback()
@@ -44,8 +45,8 @@ declare const scheduler: {
4445
const lowPriorityCallback =
4546
"scheduler" in window && "postTask" in scheduler
4647
? (cb: VoidFunction) => {
47-
scheduler.postTask(cb, { priority: "background" })
48-
}
48+
scheduler.postTask(cb, { priority: "background" })
49+
}
4950
: (cb: VoidFunction) => setTimeout(cb, 1)
5051

5152
let loadPromise: Promise<void> | undefined = new Promise<void>(resolve => {
@@ -82,9 +83,9 @@ async function queueYieldCallback(callback: VoidFunction, shouldWaitForLoad: boo
8283
}
8384
}
8485

85-
// Callback has already been run
86+
// Callback has already been run
8687
if (!pendingCallbacks.has(callback)) return
87-
88+
8889
if (document.hidden) {
8990
// The tab may have been hidden while we were waiting for load; don't leave this callback behind
9091
// for a rAF that may never run.
@@ -175,15 +176,15 @@ document.addEventListener(
175176
globalClickReceivedListener()
176177
}
177178
},
178-
true,
179+
true
179180
)
180181
document.addEventListener(
181182
"pagehide",
182183
() => {
183184
globalClickReceivedListener()
184185
resolvePendingPromises()
185186
},
186-
true,
187+
true
187188
)
188189

189190
type DataLayerPush = (...items: object[]) => boolean
@@ -220,7 +221,7 @@ function logEventDiff(event: Event, newEvent: Event) {
220221
document.addEventListener = function (
221222
type: string,
222223
listener: EventListenerOrEventListenerObject,
223-
options: boolean | AddEventListenerOptions | undefined,
224+
options: boolean | AddEventListenerOptions | undefined
224225
) {
225226
if (typesToIntercept.includes(type as EventType)) {
226227
if (DEBUG) console.log(`Overriding ${type} listener`, listener)
@@ -256,7 +257,7 @@ document.addEventListener = function (
256257
}
257258
})
258259
},
259-
options,
260+
options
260261
)
261262
return
262263
}
@@ -348,28 +349,51 @@ const gtmObserver = new MutationObserver(() => {
348349
gtmObserver.observe(document.documentElement, { childList: true, subtree: true })
349350

350351
// #region History/submit wrapper override
351-
const originalMethodsCalledInCurrentChain = new Set<Function>()
352-
353-
function callOriginalMethod(this: unknown, originalMethod: Function, args: unknown[]) {
354-
if (originalMethodsCalledInCurrentChain.has(originalMethod)) return
355-
originalMethod.apply(this, args)
356-
}
352+
/**
353+
* History/form overrides usually chain by capturing the previous function:
354+
*
355+
* ```js
356+
* const previousPushState = history.pushState
357+
* history.pushState = function (...args) {
358+
* previousPushState.apply(this, args)
359+
* // 3p side effects
360+
* }
361+
* ```
362+
*
363+
* Our wrapper calls the native method immediately, then yields before running the 3p override body. If the
364+
* override body calls a captured older wrapper, that older wrapper must not call the native method again for
365+
* the same top-level navigation/submit. Each wrapper therefore gets a generation number, and while wrapper N
366+
* runs its override body we mark generations `< N` as "native already handled".
367+
*
368+
* Fresh nested calls still work: if an override intentionally calls `history.pushState(...)` again, it goes
369+
* through the current wrapper, whose generation is `>= N`, so it still calls native. This preserves real nested
370+
* navigations while suppressing duplicate native calls from captured older wrappers, including stale captures
371+
* that are older than the immediately previous wrapper.
372+
*/
373+
const skippedWrappedListenerGenerations = new Map<Function, number>()
374+
let nextWrappedListenerGeneration = 0
357375

358-
function withOriginalMethodAlreadyCalled(originalMethod: Function, callback: VoidFunction) {
359-
const alreadyMarked = originalMethodsCalledInCurrentChain.has(originalMethod)
360-
originalMethodsCalledInCurrentChain.add(originalMethod)
376+
function withOlderWrappedListenersSkipped(originalMethod: Function, generation: number, callback: VoidFunction) {
377+
const previousSkippedGeneration = skippedWrappedListenerGenerations.get(originalMethod)
378+
skippedWrappedListenerGenerations.set(
379+
originalMethod,
380+
previousSkippedGeneration === undefined ? generation : Math.max(previousSkippedGeneration, generation)
381+
)
361382
try {
362383
callback()
363384
} finally {
364-
if (!alreadyMarked) {
365-
originalMethodsCalledInCurrentChain.delete(originalMethod)
385+
if (previousSkippedGeneration === undefined) {
386+
skippedWrappedListenerGenerations.delete(originalMethod)
387+
} else {
388+
skippedWrappedListenerGenerations.set(originalMethod, previousSkippedGeneration)
366389
}
367390
}
368391
}
369392

370-
function wrapListener(originalMethod: Function, value: Function) {
393+
function wrapListener(originalMethod: Function, value?: Function) {
394+
const generation = nextWrappedListenerGeneration++
371395
// the function syntax is important here so we keep the correct `this`.
372-
return function yieldingListener(this: unknown, ...args: unknown[]) {
396+
function yieldingListener(this: unknown, ...args: unknown[]) {
373397
if (DEBUG) {
374398
console.log("Yielding for", originalMethod)
375399
console.timeStamp(originalMethod as unknown as string)
@@ -378,29 +402,31 @@ function wrapListener(originalMethod: Function, value: Function) {
378402
// We first call the original: This optimizes for UX & correctness of React components.
379403
// e.g., for pushState, when a component renders on a new route, it might set state and/or read from the URL. If the URL isn't
380404
// accurate, it might lead to wrong behavior.
381-
// We don't want to call the underlying native method twice if an override calls a previously captured wrapper.
382-
callOriginalMethod.call(this, originalMethod, args)
405+
// If an override calls a previously captured wrapper, that older wrapper skips the native method;
406+
// fresh calls through the current getter still update history/submit normally.
407+
const skippedGeneration = skippedWrappedListenerGenerations.get(originalMethod)
408+
if (skippedGeneration === undefined || generation >= skippedGeneration) {
409+
originalMethod.apply(this, args)
410+
}
411+
412+
if (!value) return
383413

384414
// If `method` is overriden N times, it creates N yield points (as overrides might be chained)
385415
yieldUnlessUrgent(() => {
386416
// The arrow FN is important here so we keep the correct `this`.
387-
withOriginalMethodAlreadyCalled(originalMethod, () => {
417+
withOlderWrappedListenersSkipped(originalMethod, generation, () => {
388418
value.apply(this, args)
389419
})
390420
})
391421
}
422+
423+
return yieldingListener
392424
}
393425
function overrideListener<T extends object>(target: T, method: keyof T) {
394426
// @ts-expect-error TS(2339): Prototype chain call. We try __proto__ first, as this will usually be the original method.
395427
const originalMethod: Function = (target.__proto__ as unknown as T)[method] ?? (target[method] as Function)
396428

397-
let mostRecentWrapper: Function | undefined = wrapListener(
398-
originalMethod,
399-
// The function syntax is important here so we keep the correct `this`.
400-
function firstOverride(this: unknown, ...args: unknown[]) {
401-
callOriginalMethod.call(this, originalMethod, args)
402-
},
403-
)
429+
let mostRecentWrapper: Function | undefined = wrapListener(originalMethod)
404430

405431
Object.defineProperty(target, method, {
406432
enumerable: true,

0 commit comments

Comments
 (0)