From 0b2dd22a1925319cd0d451f60338e816788bed0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20Gro=C3=9F?= Date: Wed, 24 Jun 2026 13:57:40 +0200 Subject: [PATCH 1/7] Fix race-conditions in resolving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../3rd-party-optimizer/src/yieldGTMCalls.ts | 98 +++++++++++-------- plugins/3rd-party-optimizer/yieldGTMCalls.md | 2 +- 2 files changed, 58 insertions(+), 42 deletions(-) diff --git a/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts b/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts index 5a6b24d97..1cfb4610f 100644 --- a/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts +++ b/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts @@ -4,17 +4,19 @@ // turns on override logs const DEBUG = false -/** A set to keep track of all unresolved yield promises */ -const pendingResolvers = new Set() +/** A set to keep track of all deferred callbacks that should run before the page is hidden/unloaded. */ +const pendingCallbacks = new Set() const pendingAnimationFrameCallbacks = new Set() let animationFrameScheduled = false function resolveAnimationFrameCallbacks() { animationFrameScheduled = false - for (const callback of pendingAnimationFrameCallbacks) { + const callbacks = Array.from(pendingAnimationFrameCallbacks) + // Clear before invoking callbacks so callbacks queued during this drain are scheduled for the next frame. + pendingAnimationFrameCallbacks.clear() + for (const callback of callbacks) { callback() } - pendingAnimationFrameCallbacks.clear() } function queueAfterPaintCallback(callback: VoidFunction) { @@ -26,10 +28,13 @@ function queueAfterPaintCallback(callback: VoidFunction) { requestAnimationFrame(resolveAnimationFrameCallbacks) } -/** Resolves all unresolved yield promises and clears the set. */ +/** Runs all callbacks that still need to complete before the page is hidden/unloaded. */ function resolvePendingPromises() { - for (const resolve of pendingResolvers) resolve() - pendingResolvers.clear() + while (pendingCallbacks.size) { + const callback = pendingCallbacks.values().next().value! + pendingCallbacks.delete(callback) + callback() + } } declare const scheduler: { @@ -39,8 +44,8 @@ declare const scheduler: { const lowPriorityCallback = "scheduler" in window && "postTask" in scheduler ? (cb: VoidFunction) => { - scheduler.postTask(cb, { priority: "background" }) - } + scheduler.postTask(cb, { priority: "background" }) + } : (cb: VoidFunction) => setTimeout(cb, 1) let loadPromise: Promise | undefined = new Promise(resolve => { @@ -58,7 +63,7 @@ const isInputPending = : () => false async function queueYieldCallback(callback: VoidFunction, shouldWaitForLoad: boolean) { - pendingResolvers.add(callback) + pendingCallbacks.add(callback) let timeStamp: number | undefined if (DEBUG) { @@ -77,14 +82,27 @@ async function queueYieldCallback(callback: VoidFunction, shouldWaitForLoad: boo } } + // Callback has already been run + if (!pendingCallbacks.has(callback)) return + + if (document.hidden) { + // The tab may have been hidden while we were waiting for load; don't leave this callback behind + // for a rAF that may never run. + pendingCallbacks.delete(callback) + callback() + return + } + const run = () => { lowPriorityCallback(() => { + // A visibility/pagehide flush may have already run this callback synchronously. + if (!pendingCallbacks.delete(callback)) return + if (DEBUG) // @ts-expect-error TS(2554): TS doesn't know about the new syntax yet // eslint-disable-next-line @typescript-eslint/restrict-template-expressions console.timeStamp(`yield-${timeStamp}`, timeStamp, performance.now(), "GTM yield", "GTM yield") - pendingResolvers.delete(callback) callback() }) } @@ -106,7 +124,6 @@ async function queueYieldCallback(callback: VoidFunction, shouldWaitForLoad: boo */ function yieldUnlessUrgent(callback: VoidFunction, shouldWaitForLoad = false) { if (document.hidden) { - resolvePendingPromises() callback() return } @@ -120,7 +137,7 @@ let globalWaitingForClickResolve: (() => void) | undefined async function getPromiseWithFallback() { return new Promise(resolve => { const resolveFn = () => { - pendingResolvers.delete(resolve) + pendingCallbacks.delete(resolve) resolve() } @@ -129,7 +146,7 @@ async function getPromiseWithFallback() { // Safety fallback in case `click` never fires, where 150ms ensures the delay isn't noticeable by users // TODO: Add a log here when `yieldOnTap` ships to stable, so we understand how often this occurs. setTimeout(resolveFn, 150) - pendingResolvers.add(resolve) // Ensure we resolve when the page becomes hidden + pendingCallbacks.add(resolve) // Ensure we resolve when the page becomes hidden }) } @@ -158,7 +175,7 @@ document.addEventListener( globalClickReceivedListener() } }, - true + true, ) document.addEventListener( "pagehide", @@ -166,7 +183,7 @@ document.addEventListener( globalClickReceivedListener() resolvePendingPromises() }, - true + true, ) type DataLayerPush = (...items: object[]) => boolean @@ -203,7 +220,7 @@ function logEventDiff(event: Event, newEvent: Event) { document.addEventListener = function ( type: string, listener: EventListenerOrEventListenerObject, - options: boolean | AddEventListenerOptions | undefined + options: boolean | AddEventListenerOptions | undefined, ) { if (typesToIntercept.includes(type as EventType)) { if (DEBUG) console.log(`Overriding ${type} listener`, listener) @@ -239,7 +256,7 @@ document.addEventListener = function ( } }) }, - options + options, ) return } @@ -331,30 +348,28 @@ const gtmObserver = new MutationObserver(() => { gtmObserver.observe(document.documentElement, { childList: true, subtree: true }) // #region History/submit wrapper override -function callOriginalMethod( - this: unknown, - originalMethod: Function, - args: unknown[], - callIfFirstArgIsntObject = false -) { - const firstArg = args[0] ?? this +const originalMethodsCalledInCurrentChain = new Set() - const argIsObject = firstArg != null && typeof firstArg === "object" - - if (argIsObject && !("__f" in firstArg)) { - originalMethod.apply(this, args) +function callOriginalMethod(this: unknown, originalMethod: Function, args: unknown[]) { + if (originalMethodsCalledInCurrentChain.has(originalMethod)) return + originalMethod.apply(this, args) +} - // @ts-expect-error TS(2339): Flag to indicate that the native method was called - firstArg.__f = true - } else if (!argIsObject && callIfFirstArgIsntObject) { - // If for some reason, we haven't called the original method yet, we call it here. - originalMethod.apply(this, args) +function withOriginalMethodAlreadyCalled(originalMethod: Function, callback: VoidFunction) { + const alreadyMarked = originalMethodsCalledInCurrentChain.has(originalMethod) + originalMethodsCalledInCurrentChain.add(originalMethod) + try { + callback() + } finally { + if (!alreadyMarked) { + originalMethodsCalledInCurrentChain.delete(originalMethod) + } } } function wrapListener(originalMethod: Function, value: Function) { // the function syntax is important here so we keep the correct `this`. - return function yieldingListener(this: unknown, ...args: [data: object, ...args: unknown[]]) { + return function yieldingListener(this: unknown, ...args: unknown[]) { if (DEBUG) { console.log("Yielding for", originalMethod) console.timeStamp(originalMethod as unknown as string) @@ -363,14 +378,15 @@ function wrapListener(originalMethod: Function, value: Function) { // We first call the original: This optimizes for UX & correctness of React components. // 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 // accurate, it might lead to wrong behavior. - // We don't want to call the underlying native method twice (or multiple times), so we add a - // flag to the data object or `this`. + // We don't want to call the underlying native method twice if an override calls a previously captured wrapper. callOriginalMethod.call(this, originalMethod, args) // If `method` is overriden N times, it creates N yield points (as overrides might be chained) yieldUnlessUrgent(() => { // The arrow FN is important here so we keep the correct `this`. - value.apply(this, args) + withOriginalMethodAlreadyCalled(originalMethod, () => { + value.apply(this, args) + }) }) } } @@ -381,9 +397,9 @@ function overrideListener(target: T, method: keyof T) { let mostRecentWrapper: Function | undefined = wrapListener( originalMethod, // The function syntax is important here so we keep the correct `this`. - function firstOverride(this: unknown, ...args: [data: object, ...args: unknown[]]) { - callOriginalMethod.call(this, originalMethod, args, true) - } + function firstOverride(this: unknown, ...args: unknown[]) { + callOriginalMethod.call(this, originalMethod, args) + }, ) Object.defineProperty(target, method, { diff --git a/plugins/3rd-party-optimizer/yieldGTMCalls.md b/plugins/3rd-party-optimizer/yieldGTMCalls.md index ed9d9b79f..04eebca88 100644 --- a/plugins/3rd-party-optimizer/yieldGTMCalls.md +++ b/plugins/3rd-party-optimizer/yieldGTMCalls.md @@ -20,7 +20,7 @@ When the inline script executes, it installs: #### `MutationObserver` that waits for `dataLayer` to appear - We need to wait for `dataLayer` to appear, so that the initial pushes happen as expected (e.g. `gtm.load`, `consent default`) -- Overrides `dataLayer.push` and `ga`/`gtag()` to yield first before calling the browser-native `push` function +- Overrides `dataLayer.push` and `gtag()` to yield first before calling the browser-native `push` function - The override makes sure any further override is overridden again - 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. - The real `push` is called last. From 79d2ad30d9e11718b6abd7152c1228f1e2b4935e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20Gro=C3=9F?= Date: Wed, 24 Jun 2026 18:02:48 +0200 Subject: [PATCH 2/7] bump to 1.0.3 --- plugins/3rd-party-optimizer/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/3rd-party-optimizer/package.json b/plugins/3rd-party-optimizer/package.json index ea4b00f17..4ba9790a7 100644 --- a/plugins/3rd-party-optimizer/package.json +++ b/plugins/3rd-party-optimizer/package.json @@ -1,7 +1,7 @@ { "name": "3rd-party-optimizer", "private": true, - "version": "1.0.2", + "version": "1.0.3", "type": "module", "scripts": { "dev": "VITE_CONFIG_PATH=$(pwd)/vite.config.ts run g:dev", From b5098e28e96d860b5a3a84a08f5267b5a5496972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20Gro=C3=9F?= Date: Wed, 24 Jun 2026 23:33:45 +0200 Subject: [PATCH 3/7] Allow independent re-entrant history calls --- .../3rd-party-optimizer/src/yieldGTMCalls.ts | 88 ++++++++++++------- 1 file changed, 57 insertions(+), 31 deletions(-) diff --git a/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts b/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts index 1cfb4610f..4353e1093 100644 --- a/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts +++ b/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts @@ -31,6 +31,7 @@ function queueAfterPaintCallback(callback: VoidFunction) { /** Runs all callbacks that still need to complete before the page is hidden/unloaded. */ function resolvePendingPromises() { while (pendingCallbacks.size) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const callback = pendingCallbacks.values().next().value! pendingCallbacks.delete(callback) callback() @@ -44,8 +45,8 @@ declare const scheduler: { const lowPriorityCallback = "scheduler" in window && "postTask" in scheduler ? (cb: VoidFunction) => { - scheduler.postTask(cb, { priority: "background" }) - } + scheduler.postTask(cb, { priority: "background" }) + } : (cb: VoidFunction) => setTimeout(cb, 1) let loadPromise: Promise | undefined = new Promise(resolve => { @@ -82,9 +83,9 @@ async function queueYieldCallback(callback: VoidFunction, shouldWaitForLoad: boo } } - // Callback has already been run + // Callback has already been run if (!pendingCallbacks.has(callback)) return - + if (document.hidden) { // The tab may have been hidden while we were waiting for load; don't leave this callback behind // for a rAF that may never run. @@ -175,7 +176,7 @@ document.addEventListener( globalClickReceivedListener() } }, - true, + true ) document.addEventListener( "pagehide", @@ -183,7 +184,7 @@ document.addEventListener( globalClickReceivedListener() resolvePendingPromises() }, - true, + true ) type DataLayerPush = (...items: object[]) => boolean @@ -220,7 +221,7 @@ function logEventDiff(event: Event, newEvent: Event) { document.addEventListener = function ( type: string, listener: EventListenerOrEventListenerObject, - options: boolean | AddEventListenerOptions | undefined, + options: boolean | AddEventListenerOptions | undefined ) { if (typesToIntercept.includes(type as EventType)) { if (DEBUG) console.log(`Overriding ${type} listener`, listener) @@ -256,7 +257,7 @@ document.addEventListener = function ( } }) }, - options, + options ) return } @@ -348,28 +349,51 @@ const gtmObserver = new MutationObserver(() => { gtmObserver.observe(document.documentElement, { childList: true, subtree: true }) // #region History/submit wrapper override -const originalMethodsCalledInCurrentChain = new Set() - -function callOriginalMethod(this: unknown, originalMethod: Function, args: unknown[]) { - if (originalMethodsCalledInCurrentChain.has(originalMethod)) return - originalMethod.apply(this, args) -} +/** + * History/form overrides usually chain by capturing the previous function: + * + * ```js + * const previousPushState = history.pushState + * history.pushState = function (...args) { + * previousPushState.apply(this, args) + * // 3p side effects + * } + * ``` + * + * Our wrapper calls the native method immediately, then yields before running the 3p override body. If the + * override body calls a captured older wrapper, that older wrapper must not call the native method again for + * the same top-level navigation/submit. Each wrapper therefore gets a generation number, and while wrapper N + * runs its override body we mark generations `< N` as "native already handled". + * + * Fresh nested calls still work: if an override intentionally calls `history.pushState(...)` again, it goes + * through the current wrapper, whose generation is `>= N`, so it still calls native. This preserves real nested + * navigations while suppressing duplicate native calls from captured older wrappers, including stale captures + * that are older than the immediately previous wrapper. + */ +const skippedWrappedListenerGenerations = new Map() +let nextWrappedListenerGeneration = 0 -function withOriginalMethodAlreadyCalled(originalMethod: Function, callback: VoidFunction) { - const alreadyMarked = originalMethodsCalledInCurrentChain.has(originalMethod) - originalMethodsCalledInCurrentChain.add(originalMethod) +function withOlderWrappedListenersSkipped(originalMethod: Function, generation: number, callback: VoidFunction) { + const previousSkippedGeneration = skippedWrappedListenerGenerations.get(originalMethod) + skippedWrappedListenerGenerations.set( + originalMethod, + previousSkippedGeneration === undefined ? generation : Math.max(previousSkippedGeneration, generation) + ) try { callback() } finally { - if (!alreadyMarked) { - originalMethodsCalledInCurrentChain.delete(originalMethod) + if (previousSkippedGeneration === undefined) { + skippedWrappedListenerGenerations.delete(originalMethod) + } else { + skippedWrappedListenerGenerations.set(originalMethod, previousSkippedGeneration) } } } -function wrapListener(originalMethod: Function, value: Function) { +function wrapListener(originalMethod: Function, value?: Function) { + const generation = nextWrappedListenerGeneration++ // the function syntax is important here so we keep the correct `this`. - return function yieldingListener(this: unknown, ...args: unknown[]) { + function yieldingListener(this: unknown, ...args: unknown[]) { if (DEBUG) { console.log("Yielding for", originalMethod) console.timeStamp(originalMethod as unknown as string) @@ -378,29 +402,31 @@ function wrapListener(originalMethod: Function, value: Function) { // We first call the original: This optimizes for UX & correctness of React components. // 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 // accurate, it might lead to wrong behavior. - // We don't want to call the underlying native method twice if an override calls a previously captured wrapper. - callOriginalMethod.call(this, originalMethod, args) + // If an override calls a previously captured wrapper, that older wrapper skips the native method; + // fresh calls through the current getter still update history/submit normally. + const skippedGeneration = skippedWrappedListenerGenerations.get(originalMethod) + if (skippedGeneration === undefined || generation >= skippedGeneration) { + originalMethod.apply(this, args) + } + + if (!value) return // If `method` is overriden N times, it creates N yield points (as overrides might be chained) yieldUnlessUrgent(() => { // The arrow FN is important here so we keep the correct `this`. - withOriginalMethodAlreadyCalled(originalMethod, () => { + withOlderWrappedListenersSkipped(originalMethod, generation, () => { value.apply(this, args) }) }) } + + return yieldingListener } function overrideListener(target: T, method: keyof T) { // @ts-expect-error TS(2339): Prototype chain call. We try __proto__ first, as this will usually be the original method. const originalMethod: Function = (target.__proto__ as unknown as T)[method] ?? (target[method] as Function) - let mostRecentWrapper: Function | undefined = wrapListener( - originalMethod, - // The function syntax is important here so we keep the correct `this`. - function firstOverride(this: unknown, ...args: unknown[]) { - callOriginalMethod.call(this, originalMethod, args) - }, - ) + let mostRecentWrapper: Function | undefined = wrapListener(originalMethod) Object.defineProperty(target, method, { enumerable: true, From 55abc7852c568f67203888e3d7af67f49628626f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20Gro=C3=9F?= Date: Thu, 25 Jun 2026 14:41:46 +0200 Subject: [PATCH 4/7] Preserve skip state for async wrapper bodies --- .../3rd-party-optimizer/src/yieldGTMCalls.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts b/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts index 4353e1093..3ef52ed28 100644 --- a/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts +++ b/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts @@ -363,7 +363,8 @@ gtmObserver.observe(document.documentElement, { childList: true, subtree: true } * Our wrapper calls the native method immediately, then yields before running the 3p override body. If the * override body calls a captured older wrapper, that older wrapper must not call the native method again for * the same top-level navigation/submit. Each wrapper therefore gets a generation number, and while wrapper N - * runs its override body we mark generations `< N` as "native already handled". + * runs its override body (including Promise-returning async work) we mark generations `< N` as "native already + * handled". * * Fresh nested calls still work: if an override intentionally calls `history.pushState(...)` again, it goes * through the current wrapper, whose generation is `>= N`, so it still calls native. This preserves real nested @@ -373,21 +374,36 @@ gtmObserver.observe(document.documentElement, { childList: true, subtree: true } const skippedWrappedListenerGenerations = new Map() let nextWrappedListenerGeneration = 0 -function withOlderWrappedListenersSkipped(originalMethod: Function, generation: number, callback: VoidFunction) { +function isPromiseLike(value: unknown): value is PromiseLike { + return value != null && typeof value === "object" && "then" in value && typeof value.then === "function" +} + +function withOlderWrappedListenersSkipped(originalMethod: Function, generation: number, callback: () => unknown) { const previousSkippedGeneration = skippedWrappedListenerGenerations.get(originalMethod) skippedWrappedListenerGenerations.set( originalMethod, previousSkippedGeneration === undefined ? generation : Math.max(previousSkippedGeneration, generation) ) - try { - callback() - } finally { + + const restoreSkippedGeneration = () => { if (previousSkippedGeneration === undefined) { skippedWrappedListenerGenerations.delete(originalMethod) } else { skippedWrappedListenerGenerations.set(originalMethod, previousSkippedGeneration) } } + + try { + const result = callback() + if (isPromiseLike(result)) { + void result.then(restoreSkippedGeneration, restoreSkippedGeneration) + } else { + restoreSkippedGeneration() + } + } catch (error) { + restoreSkippedGeneration() + throw error + } } function wrapListener(originalMethod: Function, value?: Function) { @@ -415,7 +431,7 @@ function wrapListener(originalMethod: Function, value?: Function) { yieldUnlessUrgent(() => { // The arrow FN is important here so we keep the correct `this`. withOlderWrappedListenersSkipped(originalMethod, generation, () => { - value.apply(this, args) + return value.apply(this, args) }) }) } From a7e30e904ff2451b79579ba7c42098bad4254f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20Gro=C3=9F?= Date: Mon, 29 Jun 2026 09:58:05 +0200 Subject: [PATCH 5/7] Keep overlapping async skip scopes isolated --- .../3rd-party-optimizer/src/yieldGTMCalls.ts | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts b/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts index 3ef52ed28..dd26bb6a6 100644 --- a/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts +++ b/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts @@ -362,34 +362,54 @@ gtmObserver.observe(document.documentElement, { childList: true, subtree: true } * * Our wrapper calls the native method immediately, then yields before running the 3p override body. If the * override body calls a captured older wrapper, that older wrapper must not call the native method again for - * the same top-level navigation/submit. Each wrapper therefore gets a generation number, and while wrapper N - * runs its override body (including Promise-returning async work) we mark generations `< N` as "native already - * handled". + * the same top-level navigation/submit. Each wrapper therefore gets a generation number, and each active + * override body (including Promise-returning async work) contributes one skip scope. The highest active + * generation for a native method marks generations below it as "native already handled". * * Fresh nested calls still work: if an override intentionally calls `history.pushState(...)` again, it goes * through the current wrapper, whose generation is `>= N`, so it still calls native. This preserves real nested * navigations while suppressing duplicate native calls from captured older wrappers, including stale captures * that are older than the immediately previous wrapper. */ -const skippedWrappedListenerGenerations = new Map() +const activeSkipGenerationsByMethod = new Map() let nextWrappedListenerGeneration = 0 function isPromiseLike(value: unknown): value is PromiseLike { return value != null && typeof value === "object" && "then" in value && typeof value.then === "function" } +function getHighestActiveSkipGeneration(originalMethod: Function) { + const generations = activeSkipGenerationsByMethod.get(originalMethod) + if (!generations) return undefined + + let highestActiveSkipGeneration: number | undefined + for (const generation of generations) { + if (highestActiveSkipGeneration === undefined || generation > highestActiveSkipGeneration) { + highestActiveSkipGeneration = generation + } + } + return highestActiveSkipGeneration +} + function withOlderWrappedListenersSkipped(originalMethod: Function, generation: number, callback: () => unknown) { - const previousSkippedGeneration = skippedWrappedListenerGenerations.get(originalMethod) - skippedWrappedListenerGenerations.set( - originalMethod, - previousSkippedGeneration === undefined ? generation : Math.max(previousSkippedGeneration, generation) - ) + const generations = activeSkipGenerationsByMethod.get(originalMethod) ?? [] + generations.push(generation) + activeSkipGenerationsByMethod.set(originalMethod, generations) + let didRestore = false const restoreSkippedGeneration = () => { - if (previousSkippedGeneration === undefined) { - skippedWrappedListenerGenerations.delete(originalMethod) - } else { - skippedWrappedListenerGenerations.set(originalMethod, previousSkippedGeneration) + if (didRestore) return + didRestore = true + + const activeGenerations = activeSkipGenerationsByMethod.get(originalMethod) + if (!activeGenerations) return + + const index = activeGenerations.lastIndexOf(generation) + if (index !== -1) { + activeGenerations.splice(index, 1) + } + if (!activeGenerations.length) { + activeSkipGenerationsByMethod.delete(originalMethod) } } @@ -420,8 +440,8 @@ function wrapListener(originalMethod: Function, value?: Function) { // accurate, it might lead to wrong behavior. // If an override calls a previously captured wrapper, that older wrapper skips the native method; // fresh calls through the current getter still update history/submit normally. - const skippedGeneration = skippedWrappedListenerGenerations.get(originalMethod) - if (skippedGeneration === undefined || generation >= skippedGeneration) { + const highestActiveSkipGeneration = getHighestActiveSkipGeneration(originalMethod) + if (highestActiveSkipGeneration === undefined || generation >= highestActiveSkipGeneration) { originalMethod.apply(this, args) } From 7b5203ad1bee641c3cf00a1885e6c38f0260a663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20Gro=C3=9F?= Date: Mon, 29 Jun 2026 10:18:15 +0200 Subject: [PATCH 6/7] Add scope tracking --- .../3rd-party-optimizer/src/yieldGTMCalls.ts | 121 +++++++++++++++++- 1 file changed, 114 insertions(+), 7 deletions(-) diff --git a/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts b/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts index dd26bb6a6..298bae7fe 100644 --- a/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts +++ b/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts @@ -363,16 +363,27 @@ gtmObserver.observe(document.documentElement, { childList: true, subtree: true } * Our wrapper calls the native method immediately, then yields before running the 3p override body. If the * override body calls a captured older wrapper, that older wrapper must not call the native method again for * the same top-level navigation/submit. Each wrapper therefore gets a generation number, and each active - * override body (including Promise-returning async work) contributes one skip scope. The highest active - * generation for a native method marks generations below it as "native already handled". + * override body contributes one skip scope. The highest active generation for a native method marks + * generations below it as "native already handled". Callback APIs scheduled by an override inherit the active + * skip scopes, so captured previous wrappers called later from setTimeout/rAF/queueMicrotask still see the + * marker without keeping it alive globally for a fixed timeout. * * Fresh nested calls still work: if an override intentionally calls `history.pushState(...)` again, it goes * through the current wrapper, whose generation is `>= N`, so it still calls native. This preserves real nested * navigations while suppressing duplicate native calls from captured older wrappers, including stale captures * that are older than the immediately previous wrapper. */ +interface SkipScope { + originalMethod: Function + generation: number +} + const activeSkipGenerationsByMethod = new Map() +const activeSchedulerSkipScopes: SkipScope[] = [] let nextWrappedListenerGeneration = 0 +let originalSetTimeout: typeof window.setTimeout | undefined +let originalRequestAnimationFrame: typeof window.requestAnimationFrame | undefined +let originalQueueMicrotask: typeof window.queueMicrotask | undefined function isPromiseLike(value: unknown): value is PromiseLike { return value != null && typeof value === "object" && "then" in value && typeof value.then === "function" @@ -391,13 +402,13 @@ function getHighestActiveSkipGeneration(originalMethod: Function) { return highestActiveSkipGeneration } -function withOlderWrappedListenersSkipped(originalMethod: Function, generation: number, callback: () => unknown) { +function addActiveSkipGeneration(originalMethod: Function, generation: number) { const generations = activeSkipGenerationsByMethod.get(originalMethod) ?? [] generations.push(generation) activeSkipGenerationsByMethod.set(originalMethod, generations) let didRestore = false - const restoreSkippedGeneration = () => { + return () => { if (didRestore) return didRestore = true @@ -412,20 +423,116 @@ function withOlderWrappedListenersSkipped(originalMethod: Function, generation: activeSkipGenerationsByMethod.delete(originalMethod) } } +} + +function installSchedulerSkipScopeWrappers() { + const setTimeoutBeforeInstall = window.setTimeout + const requestAnimationFrameBeforeInstall = window.requestAnimationFrame + const queueMicrotaskBeforeInstall = window.queueMicrotask + + originalSetTimeout = setTimeoutBeforeInstall + originalRequestAnimationFrame = requestAnimationFrameBeforeInstall + originalQueueMicrotask = queueMicrotaskBeforeInstall + + window.setTimeout = function skipScopedSetTimeout(handler: TimerHandler, timeout?: number, ...args: unknown[]) { + if (typeof handler !== "function") { + return setTimeoutBeforeInstall.call(window, handler, timeout, ...args) + } + + const capturedSkipScopes = activeSchedulerSkipScopes.slice() + return setTimeoutBeforeInstall.call( + window, + function skipScopedTimeoutHandler(this: unknown, ...handlerArgs: unknown[]) { + return runWithSkipScopes(capturedSkipScopes, () => handler.apply(this, handlerArgs)) + }, + timeout, + ...args + ) + } + + window.requestAnimationFrame = function skipScopedRequestAnimationFrame(callback: FrameRequestCallback) { + const capturedSkipScopes = activeSchedulerSkipScopes.slice() + return requestAnimationFrameBeforeInstall.call(window, time => { + runWithSkipScopes(capturedSkipScopes, () => callback(time)) + }) + } + + window.queueMicrotask = function skipScopedQueueMicrotask(callback: VoidFunction) { + const capturedSkipScopes = activeSchedulerSkipScopes.slice() + queueMicrotaskBeforeInstall.call(window, () => { + runWithSkipScopes(capturedSkipScopes, callback) + }) + } +} + +function restoreSchedulerSkipScopeWrappers() { + if (originalSetTimeout) { + window.setTimeout = originalSetTimeout + originalSetTimeout = undefined + } + if (originalRequestAnimationFrame) { + window.requestAnimationFrame = originalRequestAnimationFrame + originalRequestAnimationFrame = undefined + } + if (originalQueueMicrotask) { + window.queueMicrotask = originalQueueMicrotask + originalQueueMicrotask = undefined + } +} + +function addActiveSchedulerSkipScope(skipScope: SkipScope) { + if (!activeSchedulerSkipScopes.length) { + installSchedulerSkipScopeWrappers() + } + activeSchedulerSkipScopes.push(skipScope) + + let didRestore = false + return () => { + if (didRestore) return + didRestore = true + + const index = activeSchedulerSkipScopes.lastIndexOf(skipScope) + if (index !== -1) { + activeSchedulerSkipScopes.splice(index, 1) + } + if (!activeSchedulerSkipScopes.length) { + restoreSchedulerSkipScopeWrappers() + } + } +} + +function runWithSkipScopes(skipScopes: SkipScope[], callback: () => unknown) { + const restoreSkipGenerations = skipScopes.map(skipScope => + addActiveSkipGeneration(skipScope.originalMethod, skipScope.generation) + ) + const restoreSchedulerSkipScopes = skipScopes.map(addActiveSchedulerSkipScope) + + const restore = () => { + for (const restoreSchedulerSkipScope of restoreSchedulerSkipScopes) { + restoreSchedulerSkipScope() + } + for (const restoreSkipGeneration of restoreSkipGenerations) { + restoreSkipGeneration() + } + } try { const result = callback() if (isPromiseLike(result)) { - void result.then(restoreSkippedGeneration, restoreSkippedGeneration) + void result.then(restore, restore) } else { - restoreSkippedGeneration() + void Promise.resolve().then(restore) } } catch (error) { - restoreSkippedGeneration() + restore() throw error } } +function withOlderWrappedListenersSkipped(originalMethod: Function, generation: number, callback: () => unknown) { + return runWithSkipScopes([{ originalMethod, generation }], callback) +} + function wrapListener(originalMethod: Function, value?: Function) { const generation = nextWrappedListenerGeneration++ // the function syntax is important here so we keep the correct `this`. From 74df9a3e45377be0adee1b6c933bb19ce4b43028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20Gro=C3=9F?= Date: Mon, 29 Jun 2026 10:26:13 +0200 Subject: [PATCH 7/7] Simplify --- .../3rd-party-optimizer/src/yieldGTMCalls.ts | 245 ++++-------------- 1 file changed, 48 insertions(+), 197 deletions(-) diff --git a/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts b/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts index 298bae7fe..a8bcba87f 100644 --- a/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts +++ b/plugins/3rd-party-optimizer/src/yieldGTMCalls.ts @@ -362,214 +362,65 @@ gtmObserver.observe(document.documentElement, { childList: true, subtree: true } * * Our wrapper calls the native method immediately, then yields before running the 3p override body. If the * override body calls a captured older wrapper, that older wrapper must not call the native method again for - * the same top-level navigation/submit. Each wrapper therefore gets a generation number, and each active - * override body contributes one skip scope. The highest active generation for a native method marks - * generations below it as "native already handled". Callback APIs scheduled by an override inherit the active - * skip scopes, so captured previous wrappers called later from setTimeout/rAF/queueMicrotask still see the - * marker without keeping it alive globally for a fixed timeout. + * the same top-level navigation/submit. Each wrapper therefore gets a generation number, and while wrapper N + * runs its override body synchronously we mark generations `< N` as "native already handled". * * Fresh nested calls still work: if an override intentionally calls `history.pushState(...)` again, it goes * through the current wrapper, whose generation is `>= N`, so it still calls native. This preserves real nested * navigations while suppressing duplicate native calls from captured older wrappers, including stale captures * that are older than the immediately previous wrapper. + * + * This intentionally targets the observed GTM/router pattern where captured wrappers are called synchronously. + * Fire-and-forget delayed calls to captured wrappers are not covered; supporting them requires much more global + * scheduler patching and is not worth the complexity for this optimization. */ -interface SkipScope { - originalMethod: Function - generation: number -} - -const activeSkipGenerationsByMethod = new Map() -const activeSchedulerSkipScopes: SkipScope[] = [] -let nextWrappedListenerGeneration = 0 -let originalSetTimeout: typeof window.setTimeout | undefined -let originalRequestAnimationFrame: typeof window.requestAnimationFrame | undefined -let originalQueueMicrotask: typeof window.queueMicrotask | undefined - -function isPromiseLike(value: unknown): value is PromiseLike { - return value != null && typeof value === "object" && "then" in value && typeof value.then === "function" -} - -function getHighestActiveSkipGeneration(originalMethod: Function) { - const generations = activeSkipGenerationsByMethod.get(originalMethod) - if (!generations) return undefined - - let highestActiveSkipGeneration: number | undefined - for (const generation of generations) { - if (highestActiveSkipGeneration === undefined || generation > highestActiveSkipGeneration) { - highestActiveSkipGeneration = generation - } - } - return highestActiveSkipGeneration -} - -function addActiveSkipGeneration(originalMethod: Function, generation: number) { - const generations = activeSkipGenerationsByMethod.get(originalMethod) ?? [] - generations.push(generation) - activeSkipGenerationsByMethod.set(originalMethod, generations) - - let didRestore = false - return () => { - if (didRestore) return - didRestore = true - - const activeGenerations = activeSkipGenerationsByMethod.get(originalMethod) - if (!activeGenerations) return - - const index = activeGenerations.lastIndexOf(generation) - if (index !== -1) { - activeGenerations.splice(index, 1) - } - if (!activeGenerations.length) { - activeSkipGenerationsByMethod.delete(originalMethod) - } - } -} - -function installSchedulerSkipScopeWrappers() { - const setTimeoutBeforeInstall = window.setTimeout - const requestAnimationFrameBeforeInstall = window.requestAnimationFrame - const queueMicrotaskBeforeInstall = window.queueMicrotask - - originalSetTimeout = setTimeoutBeforeInstall - originalRequestAnimationFrame = requestAnimationFrameBeforeInstall - originalQueueMicrotask = queueMicrotaskBeforeInstall - - window.setTimeout = function skipScopedSetTimeout(handler: TimerHandler, timeout?: number, ...args: unknown[]) { - if (typeof handler !== "function") { - return setTimeoutBeforeInstall.call(window, handler, timeout, ...args) - } - - const capturedSkipScopes = activeSchedulerSkipScopes.slice() - return setTimeoutBeforeInstall.call( - window, - function skipScopedTimeoutHandler(this: unknown, ...handlerArgs: unknown[]) { - return runWithSkipScopes(capturedSkipScopes, () => handler.apply(this, handlerArgs)) - }, - timeout, - ...args - ) - } - - window.requestAnimationFrame = function skipScopedRequestAnimationFrame(callback: FrameRequestCallback) { - const capturedSkipScopes = activeSchedulerSkipScopes.slice() - return requestAnimationFrameBeforeInstall.call(window, time => { - runWithSkipScopes(capturedSkipScopes, () => callback(time)) - }) - } - - window.queueMicrotask = function skipScopedQueueMicrotask(callback: VoidFunction) { - const capturedSkipScopes = activeSchedulerSkipScopes.slice() - queueMicrotaskBeforeInstall.call(window, () => { - runWithSkipScopes(capturedSkipScopes, callback) - }) - } -} - -function restoreSchedulerSkipScopeWrappers() { - if (originalSetTimeout) { - window.setTimeout = originalSetTimeout - originalSetTimeout = undefined - } - if (originalRequestAnimationFrame) { - window.requestAnimationFrame = originalRequestAnimationFrame - originalRequestAnimationFrame = undefined - } - if (originalQueueMicrotask) { - window.queueMicrotask = originalQueueMicrotask - originalQueueMicrotask = undefined - } -} - -function addActiveSchedulerSkipScope(skipScope: SkipScope) { - if (!activeSchedulerSkipScopes.length) { - installSchedulerSkipScopeWrappers() - } - activeSchedulerSkipScopes.push(skipScope) - - let didRestore = false - return () => { - if (didRestore) return - didRestore = true - - const index = activeSchedulerSkipScopes.lastIndexOf(skipScope) - if (index !== -1) { - activeSchedulerSkipScopes.splice(index, 1) - } - if (!activeSchedulerSkipScopes.length) { - restoreSchedulerSkipScopeWrappers() - } - } -} - -function runWithSkipScopes(skipScopes: SkipScope[], callback: () => unknown) { - const restoreSkipGenerations = skipScopes.map(skipScope => - addActiveSkipGeneration(skipScope.originalMethod, skipScope.generation) - ) - const restoreSchedulerSkipScopes = skipScopes.map(addActiveSchedulerSkipScope) - - const restore = () => { - for (const restoreSchedulerSkipScope of restoreSchedulerSkipScopes) { - restoreSchedulerSkipScope() - } - for (const restoreSkipGeneration of restoreSkipGenerations) { - restoreSkipGeneration() - } - } - - try { - const result = callback() - if (isPromiseLike(result)) { - void result.then(restore, restore) - } else { - void Promise.resolve().then(restore) - } - } catch (error) { - restore() - throw error - } -} - -function withOlderWrappedListenersSkipped(originalMethod: Function, generation: number, callback: () => unknown) { - return runWithSkipScopes([{ originalMethod, generation }], callback) -} +function overrideListener(target: T, method: keyof T) { + // @ts-expect-error TS(2339): Prototype chain call. We try __proto__ first, as this will usually be the original method. + const originalMethod: Function = (target.__proto__ as unknown as T)[method] ?? (target[method] as Function) + let activeSkipGeneration: number | undefined + let nextWrappedListenerGeneration = 0 + + function wrapListener(value?: Function) { + const generation = nextWrappedListenerGeneration++ + // the function syntax is important here so we keep the correct `this`. + function yieldingListener(this: unknown, ...args: unknown[]) { + if (DEBUG) { + console.log("Yielding for", originalMethod) + console.timeStamp(originalMethod as unknown as string) + } -function wrapListener(originalMethod: Function, value?: Function) { - const generation = nextWrappedListenerGeneration++ - // the function syntax is important here so we keep the correct `this`. - function yieldingListener(this: unknown, ...args: unknown[]) { - if (DEBUG) { - console.log("Yielding for", originalMethod) - console.timeStamp(originalMethod as unknown as string) - } + // We first call the original: This optimizes for UX & correctness of React components. + // 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 + // accurate, it might lead to wrong behavior. + // If an override calls a previously captured wrapper, that older wrapper skips the native method; + // fresh calls through the current getter still update history/submit normally. + if (activeSkipGeneration === undefined || generation >= activeSkipGeneration) { + originalMethod.apply(this, args) + } - // We first call the original: This optimizes for UX & correctness of React components. - // 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 - // accurate, it might lead to wrong behavior. - // If an override calls a previously captured wrapper, that older wrapper skips the native method; - // fresh calls through the current getter still update history/submit normally. - const highestActiveSkipGeneration = getHighestActiveSkipGeneration(originalMethod) - if (highestActiveSkipGeneration === undefined || generation >= highestActiveSkipGeneration) { - originalMethod.apply(this, args) + if (!value) return + + // If `method` is overriden N times, it creates N yield points (as overrides might be chained) + yieldUnlessUrgent(() => { + // The arrow FN is important here so we keep the correct `this`. + const previousActiveSkipGeneration = activeSkipGeneration + activeSkipGeneration = + previousActiveSkipGeneration === undefined + ? generation + : Math.max(previousActiveSkipGeneration, generation) + + try { + value.apply(this, args) + } finally { + activeSkipGeneration = previousActiveSkipGeneration + } + }) } - if (!value) return - - // If `method` is overriden N times, it creates N yield points (as overrides might be chained) - yieldUnlessUrgent(() => { - // The arrow FN is important here so we keep the correct `this`. - withOlderWrappedListenersSkipped(originalMethod, generation, () => { - return value.apply(this, args) - }) - }) + return yieldingListener } - return yieldingListener -} -function overrideListener(target: T, method: keyof T) { - // @ts-expect-error TS(2339): Prototype chain call. We try __proto__ first, as this will usually be the original method. - const originalMethod: Function = (target.__proto__ as unknown as T)[method] ?? (target[method] as Function) - - let mostRecentWrapper: Function | undefined = wrapListener(originalMethod) + let mostRecentWrapper: Function | undefined = wrapListener() Object.defineProperty(target, method, { enumerable: true, @@ -580,7 +431,7 @@ function overrideListener(target: T, method: keyof T) { if (DEBUG) console.log(`set ${String(method)}`, target, value) if (value === mostRecentWrapper) return - mostRecentWrapper = value ? wrapListener(originalMethod, value as Function) : undefined + mostRecentWrapper = value ? wrapListener(value as Function) : undefined }, }) }