Skip to content

Commit 0b2dd22

Browse files
committed
Fix race-conditions in resolving
1) Clear pending callbacks before running them, so re-entrant hidden-page flushes can’t replay the same callback and overflow the stack. 2) Keep draining callbacks added during a hidden/pagehide flush, so work queued while the tab is hidden doesn’t get stuck behind a rAF that may never run. 3) Skip stale scheduled rAF/timeout continuations after a lifecycle flush already ran their callback. 4) Clear batched rAF callbacks before invoking them, so callbacks queued during a rAF drain are scheduled for the next frame instead of mutating the active iteration. 5) Run callbacks immediately if the tab becomes hidden while waiting for load, instead of scheduling rAF work in a hidden tab. 6) Rename pendingResolvers to pendingCallbacks, since this script stores synchronous work callbacks, not Promise resolvers. 7) Replace persistent __f mutation for history/submit wrappers with invocation-scoped tracking, so native methods run once per override chain without corrupting repeated calls.
1 parent d7eee72 commit 0b2dd22

2 files changed

Lines changed: 58 additions & 42 deletions

File tree

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

Lines changed: 57 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,19 @@
44
// turns on override logs
55
const DEBUG = false
66

7-
/** A set to keep track of all unresolved yield promises */
8-
const pendingResolvers = new Set<VoidFunction>()
7+
/** A set to keep track of all deferred callbacks that should run before the page is hidden/unloaded. */
8+
const pendingCallbacks = new Set<VoidFunction>()
99
const pendingAnimationFrameCallbacks = new Set<VoidFunction>()
1010
let animationFrameScheduled = false
1111

1212
function resolveAnimationFrameCallbacks() {
1313
animationFrameScheduled = false
14-
for (const callback of pendingAnimationFrameCallbacks) {
14+
const callbacks = Array.from(pendingAnimationFrameCallbacks)
15+
// Clear before invoking callbacks so callbacks queued during this drain are scheduled for the next frame.
16+
pendingAnimationFrameCallbacks.clear()
17+
for (const callback of callbacks) {
1518
callback()
1619
}
17-
pendingAnimationFrameCallbacks.clear()
1820
}
1921

2022
function queueAfterPaintCallback(callback: VoidFunction) {
@@ -26,10 +28,13 @@ function queueAfterPaintCallback(callback: VoidFunction) {
2628
requestAnimationFrame(resolveAnimationFrameCallbacks)
2729
}
2830

29-
/** Resolves all unresolved yield promises and clears the set. */
31+
/** Runs all callbacks that still need to complete before the page is hidden/unloaded. */
3032
function resolvePendingPromises() {
31-
for (const resolve of pendingResolvers) resolve()
32-
pendingResolvers.clear()
33+
while (pendingCallbacks.size) {
34+
const callback = pendingCallbacks.values().next().value!
35+
pendingCallbacks.delete(callback)
36+
callback()
37+
}
3338
}
3439

3540
declare const scheduler: {
@@ -39,8 +44,8 @@ declare const scheduler: {
3944
const lowPriorityCallback =
4045
"scheduler" in window && "postTask" in scheduler
4146
? (cb: VoidFunction) => {
42-
scheduler.postTask(cb, { priority: "background" })
43-
}
47+
scheduler.postTask(cb, { priority: "background" })
48+
}
4449
: (cb: VoidFunction) => setTimeout(cb, 1)
4550

4651
let loadPromise: Promise<void> | undefined = new Promise<void>(resolve => {
@@ -58,7 +63,7 @@ const isInputPending =
5863
: () => false
5964

6065
async function queueYieldCallback(callback: VoidFunction, shouldWaitForLoad: boolean) {
61-
pendingResolvers.add(callback)
66+
pendingCallbacks.add(callback)
6267

6368
let timeStamp: number | undefined
6469
if (DEBUG) {
@@ -77,14 +82,27 @@ async function queueYieldCallback(callback: VoidFunction, shouldWaitForLoad: boo
7782
}
7883
}
7984

85+
// Callback has already been run
86+
if (!pendingCallbacks.has(callback)) return
87+
88+
if (document.hidden) {
89+
// The tab may have been hidden while we were waiting for load; don't leave this callback behind
90+
// for a rAF that may never run.
91+
pendingCallbacks.delete(callback)
92+
callback()
93+
return
94+
}
95+
8096
const run = () => {
8197
lowPriorityCallback(() => {
98+
// A visibility/pagehide flush may have already run this callback synchronously.
99+
if (!pendingCallbacks.delete(callback)) return
100+
82101
if (DEBUG)
83102
// @ts-expect-error TS(2554): TS doesn't know about the new syntax yet
84103
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
85104
console.timeStamp(`yield-${timeStamp}`, timeStamp, performance.now(), "GTM yield", "GTM yield")
86105

87-
pendingResolvers.delete(callback)
88106
callback()
89107
})
90108
}
@@ -106,7 +124,6 @@ async function queueYieldCallback(callback: VoidFunction, shouldWaitForLoad: boo
106124
*/
107125
function yieldUnlessUrgent(callback: VoidFunction, shouldWaitForLoad = false) {
108126
if (document.hidden) {
109-
resolvePendingPromises()
110127
callback()
111128
return
112129
}
@@ -120,7 +137,7 @@ let globalWaitingForClickResolve: (() => void) | undefined
120137
async function getPromiseWithFallback() {
121138
return new Promise<void>(resolve => {
122139
const resolveFn = () => {
123-
pendingResolvers.delete(resolve)
140+
pendingCallbacks.delete(resolve)
124141
resolve()
125142
}
126143

@@ -129,7 +146,7 @@ async function getPromiseWithFallback() {
129146
// Safety fallback in case `click` never fires, where 150ms ensures the delay isn't noticeable by users
130147
// TODO: Add a log here when `yieldOnTap` ships to stable, so we understand how often this occurs.
131148
setTimeout(resolveFn, 150)
132-
pendingResolvers.add(resolve) // Ensure we resolve when the page becomes hidden
149+
pendingCallbacks.add(resolve) // Ensure we resolve when the page becomes hidden
133150
})
134151
}
135152

@@ -158,15 +175,15 @@ document.addEventListener(
158175
globalClickReceivedListener()
159176
}
160177
},
161-
true
178+
true,
162179
)
163180
document.addEventListener(
164181
"pagehide",
165182
() => {
166183
globalClickReceivedListener()
167184
resolvePendingPromises()
168185
},
169-
true
186+
true,
170187
)
171188

172189
type DataLayerPush = (...items: object[]) => boolean
@@ -203,7 +220,7 @@ function logEventDiff(event: Event, newEvent: Event) {
203220
document.addEventListener = function (
204221
type: string,
205222
listener: EventListenerOrEventListenerObject,
206-
options: boolean | AddEventListenerOptions | undefined
223+
options: boolean | AddEventListenerOptions | undefined,
207224
) {
208225
if (typesToIntercept.includes(type as EventType)) {
209226
if (DEBUG) console.log(`Overriding ${type} listener`, listener)
@@ -239,7 +256,7 @@ document.addEventListener = function (
239256
}
240257
})
241258
},
242-
options
259+
options,
243260
)
244261
return
245262
}
@@ -331,30 +348,28 @@ const gtmObserver = new MutationObserver(() => {
331348
gtmObserver.observe(document.documentElement, { childList: true, subtree: true })
332349

333350
// #region History/submit wrapper override
334-
function callOriginalMethod(
335-
this: unknown,
336-
originalMethod: Function,
337-
args: unknown[],
338-
callIfFirstArgIsntObject = false
339-
) {
340-
const firstArg = args[0] ?? this
351+
const originalMethodsCalledInCurrentChain = new Set<Function>()
341352

342-
const argIsObject = firstArg != null && typeof firstArg === "object"
343-
344-
if (argIsObject && !("__f" in firstArg)) {
345-
originalMethod.apply(this, args)
353+
function callOriginalMethod(this: unknown, originalMethod: Function, args: unknown[]) {
354+
if (originalMethodsCalledInCurrentChain.has(originalMethod)) return
355+
originalMethod.apply(this, args)
356+
}
346357

347-
// @ts-expect-error TS(2339): Flag to indicate that the native method was called
348-
firstArg.__f = true
349-
} else if (!argIsObject && callIfFirstArgIsntObject) {
350-
// If for some reason, we haven't called the original method yet, we call it here.
351-
originalMethod.apply(this, args)
358+
function withOriginalMethodAlreadyCalled(originalMethod: Function, callback: VoidFunction) {
359+
const alreadyMarked = originalMethodsCalledInCurrentChain.has(originalMethod)
360+
originalMethodsCalledInCurrentChain.add(originalMethod)
361+
try {
362+
callback()
363+
} finally {
364+
if (!alreadyMarked) {
365+
originalMethodsCalledInCurrentChain.delete(originalMethod)
366+
}
352367
}
353368
}
354369

355370
function wrapListener(originalMethod: Function, value: Function) {
356371
// the function syntax is important here so we keep the correct `this`.
357-
return function yieldingListener(this: unknown, ...args: [data: object, ...args: unknown[]]) {
372+
return function yieldingListener(this: unknown, ...args: unknown[]) {
358373
if (DEBUG) {
359374
console.log("Yielding for", originalMethod)
360375
console.timeStamp(originalMethod as unknown as string)
@@ -363,14 +378,15 @@ function wrapListener(originalMethod: Function, value: Function) {
363378
// We first call the original: This optimizes for UX & correctness of React components.
364379
// 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
365380
// accurate, it might lead to wrong behavior.
366-
// We don't want to call the underlying native method twice (or multiple times), so we add a
367-
// flag to the data object or `this`.
381+
// We don't want to call the underlying native method twice if an override calls a previously captured wrapper.
368382
callOriginalMethod.call(this, originalMethod, args)
369383

370384
// If `method` is overriden N times, it creates N yield points (as overrides might be chained)
371385
yieldUnlessUrgent(() => {
372386
// The arrow FN is important here so we keep the correct `this`.
373-
value.apply(this, args)
387+
withOriginalMethodAlreadyCalled(originalMethod, () => {
388+
value.apply(this, args)
389+
})
374390
})
375391
}
376392
}
@@ -381,9 +397,9 @@ function overrideListener<T extends object>(target: T, method: keyof T) {
381397
let mostRecentWrapper: Function | undefined = wrapListener(
382398
originalMethod,
383399
// The function syntax is important here so we keep the correct `this`.
384-
function firstOverride(this: unknown, ...args: [data: object, ...args: unknown[]]) {
385-
callOriginalMethod.call(this, originalMethod, args, true)
386-
}
400+
function firstOverride(this: unknown, ...args: unknown[]) {
401+
callOriginalMethod.call(this, originalMethod, args)
402+
},
387403
)
388404

389405
Object.defineProperty(target, method, {

plugins/3rd-party-optimizer/yieldGTMCalls.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ When the inline script executes, it installs:
2020
#### `MutationObserver` that waits for `dataLayer` to appear
2121

2222
- We need to wait for `dataLayer` to appear, so that the initial pushes happen as expected (e.g. `gtm.load`, `consent default`)
23-
- Overrides `dataLayer.push` and `ga`/`gtag()` to yield first before calling the browser-native `push` function
23+
- Overrides `dataLayer.push` and `gtag()` to yield first before calling the browser-native `push` function
2424
- The override makes sure any further override is overridden again
2525
- It yields between every overridden-call. This ensures we have natural yield points between the nested GTM tasks (that call `push` from within a `push`), ensuring tasks are split across multiple frames.
2626
- The real `push` is called last.

0 commit comments

Comments
 (0)