/** * Delay bootstrap: the inline script Delay JS prints once, on wp_footer. * * It waits for the visitor's first interaction (or the failsafe timeout), * then runs the replay: every delayed script is put back, in page order, * and each one hears DOMContentLoaded, readystatechange and load once, as * if it had run while the page loaded (#494). * * This is the readable source. `npm run build` minifies it into * assets/delay-bootstrap.min.js with scripts/minify-delay-bootstrap.mjs, * which only minifies: it adds no wrapper, helper or strict-mode line. * Minify_Filters::print_delay_bootstrap() inlines the built copy and puts * the timeout in place of XSPEED_DELAY_TIMEOUT at the very end. Edit this * file, rebuild, and commit both; a unit test fails when the built copy is * stale. Behaviour is pinned in a real browser by * tests/e2e/85-delay-js-replay-harness.spec.ts, and the choice to rename * listeners rather than fire the real events again is ADR 0001. * * Why the replay renames listeners. It runs after the page has loaded, so * DOMContentLoaded and load have already fired, and a delayed script that * sets itself up in one of those listeners is never called. Dispatching the * real events again would run every eager script's handlers a second time. * Instead, while the replay runs, addEventListener/removeEventListener on * document and window file the three lifecycle events under private xs-* * names. Only code running during the replay registers there, so * dispatching the private names reaches exactly the replayed scripts. The * dispatched event reports the real `type`: shared handlers and jQuery's * dispatcher look handlers up by it. * * Properties this sets on script elements (plain properties, so the * minifier leaves their names alone): * - _xs: one of our clones of a delayed script; * - _xe: on the page, and not delayed, when the replay started; * - _xf: written into the page by our document.write redirect; * - _xw: where the next write from that script goes. */ (function (timeout) { var TRIGGERS = ['mousemove', 'keydown', 'touchstart', 'scroll', 'wheel']; var PRIVATE = { DOMContentLoaded: 'xs-DOMContentLoaded', load: 'xs-load', readystatechange: 'xs-readystatechange', }; // The real readyState, read past any getter defined on document itself // (ours during the replay, or one the page defined). var nativeReadyState = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState'); function realReadyState() { return nativeReadyState ? nativeReadyState.get.call(document) : document.readyState; } // readyState cannot say whether DOMContentLoaded has fired: it turns // 'interactive' BEFORE the defer scripts and modules run. This runs // inline in the footer, before DOMContentLoaded, so a listener records // the real event; Navigation Timing covers a bootstrap that ran later. // The same for load. var started = false; var dclFired = realReadyState() === 'complete'; var loadFired = dclFired; var onRealDcl; document.addEventListener('DOMContentLoaded', function () { dclFired = true; if (onRealDcl) onRealDcl(); }); window.addEventListener('load', function () { loadFired = true; }); // Dispatch the private copy of a lifecycle event under its real type. // The dispatched load reports document as its target, as the real one // does. function firePrivate(target, type, bubbles) { var event = new Event(PRIVATE[type], { bubbles: !!bubbles }); Object.defineProperty(event, 'type', { value: type }); if (type === 'load') Object.defineProperty(event, 'target', { value: document }); target.dispatchEvent(event); } function callHandler(handler, target, type) { if (typeof handler !== 'function') return; try { handler.call(target, new Event(type)); } catch (e) {} } function typeOf(script) { return (script.getAttribute('type') || '').trim().toLowerCase(); } // Whether the browser will execute a script of this type. One it will // not run (text/plain, nomodule, text/babel) fires neither load nor // error, so counting it would hold the replay open. function willRun(script, type) { return !script.noModule && /^$|^module$|^(text|application)\/(x-)?(java|ecma|j|live)script$/.test(type); } function start() { if (started) return; started = true; TRIGGERS.forEach(function (name) { window.removeEventListener(name, start, { passive: true, capture: true }); }); var nav = window.performance && performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; if (nav && nav.domContentLoadedEventStart > 0) dclFired = true; // pageLoaded: the replay starts after load, the usual case ("late"). // phase: 0 until the synthetic DOMContentLoaded, then 1 until load. // pending: what the current phase still waits for; it starts at 1 for // the loop below. heldInlines: inline scripts still waiting behind an // external. modules: inline module index -> 1 inserted, 2 running // between its markers, 0 done. var pageLoaded = loadFired; var live = 1; var wrapped = []; var pending = 1; var deadline; var phase = 0; var heldInlines = 0; var modules = {}; // When the replay starts before the real DOMContentLoaded, wait for it // before sending our own copy, so a script that registered after it // still gets one. if (!dclFired) { pending++; onRealDcl = function () { onRealDcl = 0; Promise.resolve().then(function () { if (!phase) done(); }); }; } // A readystatechange 'complete' still to come is forwarded to the // private name when it happens. This is registered before our wrappers, // so it goes on the real name. A handler a delayed script set on // document.onreadystatechange before the real 'interactive' hears it // from the browser, so the synthetic one skips it. if (!pageLoaded) { document.addEventListener('readystatechange', function () { if (realReadyState() === 'interactive') currentRsc = document.onreadystatechange; if (realReadyState() === 'complete') firePrivate(document, 'readystatechange'); }); } // Scripts already on the page keep the real event names and the native // document.write for their own top-level code: a page defer script's // readystatechange listener hears only the real one, and the browser // ignores their writes after parsing as it always did. Array.prototype.forEach.call(document.scripts, function (script) { if (!script.hasAttribute('data-xs-delay')) script._xe = 1; }); // The wrappers. What is renamed is decided on each call, by which events // have really fired: DOMContentLoaded once it has, readystatechange once // parsing has finished, window load once the page has loaded. A listener // for an event still to come stays on the real name and hears the real // event, whoever adds it; one for an event already gone gets a private // copy. Renaming everything from the start was worse: eager scripts // still registering had their listeners renamed, jQuery's completed() // ran twice, and the Interactivity API's hydration was held until the // replay ended. // // load is renamed on window only. The page's load never reaches a // listener on document, so one there catches its descendants' loads // in the capture phase (image delegation); renaming it would cut the // delayed script off from every image after this. // // What renaming cannot tell apart: eager code in a timer or handler (no // currentScript) registering during the replay gets one synthetic event // where it would have got none. // // The wrappers look the prototype method up on every call: Sentry or // zone.js, delayed too, can patch EventTarget.prototype during the // replay, and listeners added after that must go through their patch. // A removal takes the listener off both the real and the private name, // so one added before an event fired and removed after (jQuery's // completed()) is still found. [document, window].forEach(function (target) { var proto = Object.getPrototypeOf(target); var ownAdd = target.addEventListener; var ownRemove = target.removeEventListener; var hadOwn = Object.prototype.hasOwnProperty.call(target, 'addEventListener'); var nameFor = function (type) { var current = document.currentScript; var gone = type === 'load' ? target === window && loadFired : type === 'DOMContentLoaded' ? dclFired : realReadyState() !== 'loading'; return live && !(current && current._xe) && PRIVATE.hasOwnProperty(type) && gone ? PRIVATE[type] : type; }; var add = function (type, listener, options) { return (hadOwn ? ownAdd : proto.addEventListener).call(this, nameFor(type), listener, options); }; target.addEventListener = add; target.removeEventListener = function (type, listener, options) { var remove = hadOwn ? ownRemove : proto.removeEventListener; if (PRIVATE.hasOwnProperty(type)) remove.call(this, PRIVATE[type], listener, options); return remove.call(this, type, listener, options); }; wrapped.push([target, ownAdd, ownRemove, hadOwn, add]); }); // window.onload / document.onreadystatechange set during the replay are // keyed on the real names, so they are called directly. A page handler // already there is swapped for an empty one first: the old addLoadEvent // chain (`var o=window.onload; window.onload=function(){o();mine();}`) // would otherwise run it a second time. It has run, and the browser // will not call it again. If nothing replaced the empty one, the page's // handler is put back at the end, so code that calls window.onload() // later (a PJAX re-init) still finds it. var pageOnload = window.onload; var pageRsc = document.onreadystatechange; var currentOnload = pageOnload; var currentRsc = pageRsc; var jq = window.jQuery; var ended; if (pageLoaded && pageOnload) currentOnload = window.onload = function () {}; if (pageLoaded && pageRsc) currentRsc = document.onreadystatechange = function () {}; // The replayed scripts read a fake document.readyState: 'loading' while // they run, then 'interactive' for the synthetic DOMContentLoaded, then // the real value once the replay ends. Left at the real value, a script // that starts at once when the page is past 'loading' and also adds an // unguarded DOMContentLoaded listener started twice, and a // readystatechange handler waiting for 'interactive' never ran. // // The fake is scoped: the getter answers with it only while one of our // clones runs (currentScript carries _xs), while an inline module runs // between its markers (modules have no currentScript), and while our own // dispatch runs (fakeDepth). Everything else reads the real value: eager // code, timers, callbacks, and scripts the page or the replayed ones // inject. So a delayed script that listens at top level and re-checks // for 'complete' in a timer starts twice. // // The define is configurable and in a try: WP Rocket's first fake threw // where Cloudflare Rocket Loader had locked the property (#5709), and a // locked readyState keeps the old behaviour here. A getter the page // defined itself (another optimizer, a polyfill) is read through and // put back, not deleted. var writtenPending = 0; var fakeState = 'loading'; var fakeDepth = 0; var ownReadyState = Object.getOwnPropertyDescriptor(document, 'readyState'); var faking = nativeReadyState && (function () { try { Object.defineProperty(document, 'readyState', { configurable: true, get: function () { var current = document.currentScript; if ((current && current._xs) || fakeDepth) return fakeState; return ownReadyState && ownReadyState.get ? ownReadyState.get.call(document) : realReadyState(); }, }); return 1; } catch (e) {} })(); // With readyState faked, a script may write into the page as if it were // still being parsed. From an inline script that would wipe the // document, so for the length of the replay document.write/writeln put // the markup in after the script that wrote it, in call order. A script // the parser is still running writes into the parser as always: while // the real readyState is 'loading', a caller that is not one of our // clones gets the native write. With no currentScript (a timer, a // callback) there is nowhere to put it, and the write is dropped rather // than wiping the page. // // A written