"use client"; import ReactDOM from "react-dom"; import React, { useEffect, useContext, useRef } from "react"; import { HeadManagerContext } from "../shared/lib/head-manager-context.shared-runtime"; import { DOMAttributeNames } from "./head-manager"; import { requestIdleCallback } from "./request-idle-callback"; const ScriptCache = new Map(); const LoadCache = new Set(); const ignoreProps = [ "onLoad", "onReady", "dangerouslySetInnerHTML", "children", "onError", "strategy", "stylesheets" ]; const insertStylesheets = (stylesheets)=>{ // Case 1: Styles for afterInteractive/lazyOnload with appDir injected via handleClientScriptLoad // // Using ReactDOM.preinit to feature detect appDir and inject styles // Stylesheets might have already been loaded if initialized with Script component // Re-inject styles here to handle scripts loaded via handleClientScriptLoad // ReactDOM.preinit handles dedup and ensures the styles are loaded only once if (ReactDOM.preinit) { stylesheets.forEach((stylesheet)=>{ ReactDOM.preinit(stylesheet, { as: "style" }); }); return; } // Case 2: Styles for afterInteractive/lazyOnload with pages injected via handleClientScriptLoad // // We use this function to load styles when appdir is not detected // TODO: Use React float APIs to load styles once available for pages dir if (typeof window !== "undefined") { let head = document.head; stylesheets.forEach((stylesheet)=>{ let link = document.createElement("link"); link.type = "text/css"; link.rel = "stylesheet"; link.href = stylesheet; head.appendChild(link); }); } }; const loadScript = (props)=>{ const { src, id, onLoad = ()=>{}, onReady = null, dangerouslySetInnerHTML, children = "", strategy = "afterInteractive", onError, stylesheets } = props; const cacheKey = id || src; // Script has already loaded if (cacheKey && LoadCache.has(cacheKey)) { return; } // Contents of this script are already loading/loaded if (ScriptCache.has(src)) { LoadCache.add(cacheKey); // It is possible that multiple `next/script` components all have same "src", but has different "onLoad" // This is to make sure the same remote script will only load once, but "onLoad" are executed in order ScriptCache.get(src).then(onLoad, onError); return; } /** Execute after the script first loaded */ const afterLoad = ()=>{ // Run onReady for the first time after load event if (onReady) { onReady(); } // add cacheKey to LoadCache when load successfully LoadCache.add(cacheKey); }; const el = document.createElement("script"); const loadPromise = new Promise((resolve, reject)=>{ el.addEventListener("load", function(e) { resolve(); if (onLoad) { onLoad.call(this, e); } afterLoad(); }); el.addEventListener("error", function(e) { reject(e); }); }).catch(function(e) { if (onError) { onError(e); } }); if (dangerouslySetInnerHTML) { // Casting since lib.dom.d.ts doesn't have TrustedHTML yet. el.innerHTML = dangerouslySetInnerHTML.__html || ""; afterLoad(); } else if (children) { el.textContent = typeof children === "string" ? children : Array.isArray(children) ? children.join("") : ""; afterLoad(); } else if (src) { el.src = src; // do not add cacheKey into LoadCache for remote script here // cacheKey will be added to LoadCache when it is actually loaded (see loadPromise above) ScriptCache.set(src, loadPromise); } for (const [k, value] of Object.entries(props)){ if (value === undefined || ignoreProps.includes(k)) { continue; } const attr = DOMAttributeNames[k] || k.toLowerCase(); el.setAttribute(attr, value); } if (strategy === "worker") { el.setAttribute("type", "text/partytown"); } el.setAttribute("data-nscript", strategy); // Load styles associated with this script if (stylesheets) { insertStylesheets(stylesheets); } document.body.appendChild(el); }; export function handleClientScriptLoad(props) { const { strategy = "afterInteractive" } = props; if (strategy === "lazyOnload") { window.addEventListener("load", ()=>{ requestIdleCallback(()=>loadScript(props)); }); } else { loadScript(props); } } function loadLazyScript(props) { if (document.readyState === "complete") { requestIdleCallback(()=>loadScript(props)); } else { window.addEventListener("load", ()=>{ requestIdleCallback(()=>loadScript(props)); }); } } function addBeforeInteractiveToCache() { const scripts = [ ...document.querySelectorAll('[data-nscript="beforeInteractive"]'), ...document.querySelectorAll('[data-nscript="beforePageRender"]') ]; scripts.forEach((script)=>{ const cacheKey = script.id || script.getAttribute("src"); LoadCache.add(cacheKey); }); } export function initScriptLoader(scriptLoaderItems) { scriptLoaderItems.forEach(handleClientScriptLoad); addBeforeInteractiveToCache(); } function Script(props) { const { id, src = "", onLoad = ()=>{}, onReady = null, strategy = "afterInteractive", onError, stylesheets, ...restProps } = props; // Context is available only during SSR const { updateScripts, scripts, getIsSsr, appDir, nonce } = useContext(HeadManagerContext); /** * - First mount: * 1. The useEffect for onReady executes * 2. hasOnReadyEffectCalled.current is false, but the script hasn't loaded yet (not in LoadCache) * onReady is skipped, set hasOnReadyEffectCalled.current to true * 3. The useEffect for loadScript executes * 4. hasLoadScriptEffectCalled.current is false, loadScript executes * Once the script is loaded, the onLoad and onReady will be called by then * [If strict mode is enabled / is wrapped in component] * 5. The useEffect for onReady executes again * 6. hasOnReadyEffectCalled.current is true, so entire effect is skipped * 7. The useEffect for loadScript executes again * 8. hasLoadScriptEffectCalled.current is true, so entire effect is skipped * * - Second mount: * 1. The useEffect for onReady executes * 2. hasOnReadyEffectCalled.current is false, but the script has already loaded (found in LoadCache) * onReady is called, set hasOnReadyEffectCalled.current to true * 3. The useEffect for loadScript executes * 4. The script is already loaded, loadScript bails out * [If strict mode is enabled / is wrapped in component] * 5. The useEffect for onReady executes again * 6. hasOnReadyEffectCalled.current is true, so entire effect is skipped * 7. The useEffect for loadScript executes again * 8. hasLoadScriptEffectCalled.current is true, so entire effect is skipped */ const hasOnReadyEffectCalled = useRef(false); useEffect(()=>{ const cacheKey = id || src; if (!hasOnReadyEffectCalled.current) { // Run onReady if script has loaded before but component is re-mounted if (onReady && cacheKey && LoadCache.has(cacheKey)) { onReady(); } hasOnReadyEffectCalled.current = true; } }, [ onReady, id, src ]); const hasLoadScriptEffectCalled = useRef(false); useEffect(()=>{ if (!hasLoadScriptEffectCalled.current) { if (strategy === "afterInteractive") { loadScript(props); } else if (strategy === "lazyOnload") { loadLazyScript(props); } hasLoadScriptEffectCalled.current = true; } }, [ props, strategy ]); if (strategy === "beforeInteractive" || strategy === "worker") { if (updateScripts) { scripts[strategy] = (scripts[strategy] || []).concat([ { id, src, onLoad, onReady, onError, ...restProps } ]); updateScripts(scripts); } else if (getIsSsr && getIsSsr()) { // Script has already loaded during SSR LoadCache.add(id || src); } else if (getIsSsr && !getIsSsr()) { loadScript(props); } } // For the app directory, we need React Float to preload these scripts. if (appDir) { // Injecting stylesheets here handles beforeInteractive and worker scripts correctly // For other strategies injecting here ensures correct stylesheet order // ReactDOM.preinit handles loading the styles in the correct order, // also ensures the stylesheet is loaded only once and in a consistent manner // // Case 1: Styles for beforeInteractive/worker with appDir - handled here // Case 2: Styles for beforeInteractive/worker with pages dir - Not handled yet // Case 3: Styles for afterInteractive/lazyOnload with appDir - handled here // Case 4: Styles for afterInteractive/lazyOnload with pages dir - handled in insertStylesheets function if (stylesheets) { stylesheets.forEach((styleSrc)=>{ ReactDOM.preinit(styleSrc, { as: "style" }); }); } // Before interactive scripts need to be loaded by Next.js' runtime instead // of native