|
// turns on override logs |
|
const DEBUG = false |
|
|
|
/** A set to keep track of all unresolved yield promises */ |
|
const pendingResolvers = new Set<VoidFunction>() |
|
|
|
/** Resolves all unresolved yield promises and clears the set. */ |
|
function resolvePendingPromises() { |
|
for (const resolve of pendingResolvers) resolve() |
|
pendingResolvers.clear() |
|
} |
|
|
|
// eslint-disable-next-line compat/compat -- we check for the existence of the properties |
|
const scheduler = window.scheduler |
|
const canUseYield = scheduler && "yield" in scheduler |
|
const canUsePostTask = scheduler && "postTask" in scheduler |
|
|
|
/** |
|
* Returns a promise that, if the document is visible, will resolve in a new |
|
* task in the next frame. If the document is not visible (or changes to |
|
* hidden prior to the promise resolving), the promise is resolved immediately. |
|
* |
|
* Modified from Philip Walton's original snippet. |
|
*/ |
|
function yieldUnlessUrgent(important = false) { |
|
return new Promise<void>(resolve => { |
|
pendingResolvers.add(resolve) |
|
if (document.visibilityState === "visible") { |
|
// visibilitychange + pagehide is needed to reliably (±97%) detect when the page is hidden cross-browser |
|
// see https://nicj.net/beaconing-in-practice-fetchlater/#beaconing-in-practice-fetchlater-onload-or-pagehide-or-visibilitychange |
|
document.addEventListener("visibilitychange", resolvePendingPromises) |
|
document.addEventListener("pagehide", resolvePendingPromises) |
|
|
|
// await-interaction-response without a setTimeout fallback, as the fallbacks are the event listeners above |
|
requestAnimationFrame(async () => { |
|
const resolveFn = () => { |
|
pendingResolvers.delete(resolve) |
|
resolve() |
|
} |
|
|
|
if (important) { |
|
if (canUseYield) { |
|
// @ts-expect-error TS(2353) |
|
await scheduler.yield() |
|
resolveFn() |
|
} else if (canUsePostTask) { |
|
// @ts-expect-error TS(2353) |
|
void scheduler.postTask(resolveFn) |
|
} else { |
|
setTimeout(resolveFn, 0) |
|
} |
|
} else { |
|
// if it isn't important, we can use a timeout with lowest prio (delay=1) instead. |
|
setTimeout(resolveFn, 1) |
|
} |
|
}) |
|
return |
|
} |
|
// Still here? Resolve immediately. |
|
resolvePendingPromises() |
|
}) |
|
} |
|
|
|
type DataLayerPush = (...items: object[]) => boolean |
|
type DataLayer = Omit<object[], "push"> & { |
|
push: DataLayerPush |
|
} |
|
|
|
function cloneEvent(event: Event) { |
|
// @ts-expect-error TS(2339): We already cloned this event |
|
if (event.__originalEvent) return event |
|
|
|
// @ts-expect-error TS(2351): TS doesn't know that event.constructor is a function |
|
const newEvent = new event.constructor(event.type, event) |
|
|
|
// the following need to be set to read-only or they get overridden when dispatching |
|
const keysToDefine = ["target", "srcElement", "offsetX", "offsetY", "layerX", "layerY", "toElement"] as const |
|
for (const key of keysToDefine) { |
|
if (key in event) { |
|
Object.defineProperty(newEvent, key, { |
|
writable: false, // need to prevent write, else this gets overriden when dispatching. |
|
value: event[key], |
|
}) |
|
} |
|
} |
|
|
|
newEvent.__originalEvent = event |
|
|
|
if (DEBUG) { |
|
// biome-ignore lint/suspicious/noConsole: debug |
|
console.log(event, newEvent) |
|
const readOnlyProps = [ |
|
"__originalEvent", |
|
"isTrusted", |
|
"currentTarget", |
|
"eventPhase", |
|
"defaultPrevented", |
|
"composed", |
|
"timeStamp", |
|
"bubbles", |
|
"cancelable", |
|
"cancelBubble", |
|
] |
|
const differentProps: string[] = [] |
|
const missingProps: string[] = [] |
|
for (const prop in event) { |
|
if (typeof event[prop] === "function" || readOnlyProps.includes(prop)) continue |
|
if (newEvent[prop] === undefined) { |
|
missingProps.push(prop) |
|
} else if (event[prop] !== newEvent[prop]) { |
|
differentProps.push(prop) |
|
} |
|
} |
|
if (differentProps.length || missingProps.length) { |
|
// biome-ignore lint/suspicious/noConsole: debug |
|
console.log(`Different: ${differentProps.join(", ")}`, `Missing: ${missingProps.join(", ")}`) |
|
} |
|
} |
|
|
|
return newEvent |
|
} |
|
|
|
function replaceGTM() { |
|
const origLayer = window.dataLayer as DataLayer | undefined |
|
const origPush = origLayer?.push |
|
if (!origPush) return |
|
|
|
// must be a (non-async) function, not an arrow function, because we need to bind the original `this` context |
|
// the GTM dataLayer push function returns `true` if the push was successful, we just assume it is. |
|
// the `...args` is needed so the call results in the exactly same result as the original |
|
function yieldingPush(...args: unknown[]) { |
|
void yieldUnlessUrgent().then(function innerPush() { |
|
// in case we override the native Array#push here, we need to set the original array as `this` to not cause runtime errors |
|
// biome-ignore lint/style/noNonNullAssertion: we know it exists from the lines above |
|
origPush!.apply(origLayer!, args) |
|
}) |
|
return true |
|
} |
|
|
|
origLayer.push = yieldingPush |
|
return yieldingPush |
|
} |
|
|
|
let yieldingGTMFn: Function | undefined |
|
|
|
function overrideDataLayer(eventName?: string) { |
|
const hasOverriddenGTM = yieldingGTMFn === window.dataLayer?.push |
|
if (DEBUG) { |
|
// biome-ignore lint/suspicious/noConsole: debug |
|
console.log(eventName, hasOverriddenGTM) |
|
} |
|
|
|
if (!hasOverriddenGTM) { |
|
yieldingGTMFn = replaceGTM() |
|
// we don't override GA/gtag as they are just wrappers around dataLayer.push |
|
} |
|
} |
|
|
|
// Intercept capturing event listeners: |
|
const originalAddEventListener = document.addEventListener |
|
const typesToIntercept = ["click", "auxclick", "mousedown", "keyup", "submit"] as const |
|
type EventType = (typeof typesToIntercept)[number] |
|
|
|
document.addEventListener = function ( |
|
type: string, |
|
listener: EventListenerOrEventListenerObject, |
|
options: boolean | AddEventListenerOptions | undefined |
|
) { |
|
if ( |
|
typesToIntercept.includes(type as EventType) && |
|
(options === true || (typeof options === "object" && options.capture)) |
|
) { |
|
// biome-ignore lint/suspicious/noConsole: debug |
|
if (DEBUG) console.log(`Overriding ${type} listener`, listener) |
|
|
|
// Note: This listener also receives dispatches from our document.body interceptor. |
|
// This means, sometimes it might yield twice. This is ok, as it can potentially split |
|
// 3rd-party-listeners across multiple frames. |
|
// Tiny "TODO": We could explicitly 'waterfall' those listeners: |
|
// yield -> schedule next task -> yield -> schedule next task instead of |
|
// yield -> all run in one task |
|
originalAddEventListener.call( |
|
this, |
|
type, |
|
async function overriddenEventListener(this: unknown, e) { |
|
const event = cloneEvent(e) // clone at the time of interception |
|
await yieldUnlessUrgent(true) // might be user visible and not only GTM listeners |
|
|
|
if (typeof listener === "function") { |
|
listener.call(this, event) |
|
} else { |
|
listener.handleEvent(event) |
|
} |
|
}, |
|
options |
|
) |
|
return |
|
} |
|
|
|
// if not capturing, we can just call the original addEventListener function |
|
originalAddEventListener.call(this, type, listener, options) |
|
} |
|
|
|
// readystatechange runs exactly before DOMContentLoaded, which is needed to ensure we call stopPropagation early (first listener added runs first) |
|
document.addEventListener("readystatechange", () => { |
|
if (document.readyState !== "interactive") return |
|
|
|
const body = document.body |
|
async function interceptEvent(e: Event) { |
|
e.stopPropagation() |
|
|
|
const event = cloneEvent(e) // clone at the time of interception |
|
await yieldUnlessUrgent(true) // might be user visible and not only GTM listeners |
|
document.dispatchEvent(event) |
|
} |
|
|
|
// Intercept `document` level listeners that are not capturing. |
|
for (const element of typesToIntercept) { |
|
body.addEventListener(element, interceptEvent) |
|
} |
|
|
|
// override dataLayer.push |
|
overrideDataLayer(DEBUG ? "readystatechange" : undefined) |
|
}) |
|
|
|
// check if something naughty has overridden them again |
|
// TODO: Those listeners are a bit naive, a better way would be to use a MutationObserver to check for script insertions, or |
|
// to at least add an event listener on all <script> tags |
|
document.addEventListener("DOMContentLoaded", () => { |
|
overrideDataLayer(DEBUG ? "DOMContentLoaded" : undefined) |
|
}) |
|
window.addEventListener("load", () => { |
|
overrideDataLayer(DEBUG ? "load" : undefined) |
|
}) |
This is fantastic, but could you also document what user issue this solves so the context is clear?