next.js/packages/next/src/client/script.tsx
script.tsx386 lines11.6 KB
'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<HTMLScriptElement> {
  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<void>((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 <OffScreen /> 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 <OffScreen /> 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 <script> tags, because they no longer have `defer`.
    if (strategy === 'beforeInteractive') {
      if (!src) {
        // For inlined scripts, we put the content in `children`.
        if (restProps.dangerouslySetInnerHTML) {
          // Casting since lib.dom.d.ts doesn't have TrustedHTML yet.
          restProps.children = restProps.dangerouslySetInnerHTML
            .__html as string
          delete restProps.dangerouslySetInnerHTML
        }

        return (
          <script
            nonce={nonce}
            dangerouslySetInnerHTML={{
              __html: `(self.__next_s=self.__next_s||[]).push(${JSON.stringify([
                0,
                { ...restProps, id },
              ])})`,
            }}
          />
        )
      } else {
        // @ts-ignore
        ReactDOM.preload(
          src,
          restProps.integrity
            ? {
                as: 'script',
                integrity: restProps.integrity,
                nonce,
                crossOrigin: restProps.crossOrigin,
              }
            : { as: 'script', nonce, crossOrigin: restProps.crossOrigin }
        )
        return (
          <script
            nonce={nonce}
            dangerouslySetInnerHTML={{
              __html: `(self.__next_s=self.__next_s||[]).push(${JSON.stringify([
                src,
                { ...restProps, id },
              ])})`,
            }}
          />
        )
      }
    } else if (strategy === 'afterInteractive') {
      if (src) {
        // @ts-ignore
        ReactDOM.preload(
          src,
          restProps.integrity
            ? {
                as: 'script',
                integrity: restProps.integrity,
                nonce,
                crossOrigin: restProps.crossOrigin,
              }
            : { as: 'script', nonce, crossOrigin: restProps.crossOrigin }
        )
      }
    }
  }

  return null
}

Object.defineProperty(Script, '__nextScript', { value: true })

export default Script
Quest for Codev2.0.0
/
SIGN IN