'use client' import ReactDOM from 'react-dom' import React, { useEffect, useContext, useRef, type JSX } from 'react' import type { ScriptHTMLAttributes } from 'react' import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime' import { setAttributesFromProps } from './set-attributes-from-props' import { requestIdleCallback } from './request-idle-callback' const ScriptCache = new Map() const LoadCache = new Set() export interface ScriptProps extends ScriptHTMLAttributes { strategy?: 'afterInteractive' | 'lazyOnload' | 'beforeInteractive' | 'worker' id?: string onLoad?: (e: any) => void onReady?: () => void | null onError?: (e: any) => void children?: React.ReactNode stylesheets?: string[] } /** * @deprecated Use `ScriptProps` instead. */ export type Props = ScriptProps const insertStylesheets = (stylesheets: string[]) => { // 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: string) => { 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: string) => { let link = document.createElement('link') link.type = 'text/css' link.rel = 'stylesheet' link.href = stylesheet head.appendChild(link) }) } } const loadScript = (props: ScriptProps): void => { 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 as string) || '' 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) } setAttributesFromProps(el, props) 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: ScriptProps) { const { strategy = 'afterInteractive' } = props if (strategy === 'lazyOnload') { window.addEventListener('load', () => { requestIdleCallback(() => loadScript(props)) }) } else { loadScript(props) } } function loadLazyScript(props: ScriptProps) { 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: ScriptProps[]) { scriptLoaderItems.forEach(handleClientScriptLoad) addBeforeInteractiveToCache() } /** * Load a third-party scripts in an optimized way. * * Read more: [Next.js Docs: `next/script`](https://nextjs.org/docs/app/api-reference/components/script) */ function Script(props: ScriptProps): JSX.Element | null { const { id, src = '', onLoad = () => {}, onReady = null, strategy = 'afterInteractive', onError, stylesheets, ...restProps } = props // Context is available only during SSR let { updateScripts, scripts, getIsSsr, appDir, nonce } = useContext(HeadManagerContext) // if a nonce is explicitly passed to the script tag, favor that over the automatic handling nonce = restProps.nonce || nonce /** * - 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, nonce, }, ]) updateScripts(scripts) } else if (getIsSsr && getIsSsr()) { // Script has already loaded during SSR LoadCache.add(id || src) } else if (getIsSsr && !getIsSsr()) { loadScript({ ...props, nonce, }) } } // 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