rsnext/packages/next/client/script.tsx
Shu Ding e933e1d211
Load beforeInteractive scripts properly without blocking hydration (#41164)
This PR ensures that for the app directory, `beforeInteractive`, `afterInteractive` and `lazyOnload` scripts via `next/script` are properly supported.

For both `beforeInteractive` and `afterInteractive` scripts, a preload link tag needs to be injected by Float. For `beforeInteractive` scripts and Next.js' polyfills, they need to be manually executed in order before starting the Next.js' runtime, without blocking the downloading of HTML and other scripts.

This PR doesn't include the `worker` type of scripts yet.

Note: in this PR I changed the inlined flight data `__next_s` to `__next_f`, and use `__next_s` for scripts data, because I can't find a better name for `next/script` that is also short at the same time.

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
2022-10-09 15:08:51 +00:00

319 lines
8.8 KiB
TypeScript

'client'
import ReactDOM from 'react-dom'
import React, { useEffect, useContext, useRef } from 'react'
import { ScriptHTMLAttributes } from 'react'
import { HeadManagerContext } from '../shared/lib/head-manager-context'
import { DOMAttributeNames } from './head-manager'
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
}
/**
* @deprecated Use `ScriptProps` instead.
*/
export type Props = ScriptProps
const ignoreProps = [
'onLoad',
'onReady',
'dangerouslySetInnerHTML',
'children',
'onError',
'strategy',
]
const loadScript = (props: ScriptProps): void => {
const {
src,
id,
onLoad = () => {},
onReady = null,
dangerouslySetInnerHTML,
children = '',
strategy = 'afterInteractive',
onError,
} = 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) {
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)
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()
}
function Script(props: ScriptProps): JSX.Element | null {
const {
id,
src = '',
onLoad = () => {},
onReady = null,
strategy = 'afterInteractive',
onError,
...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 <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,
},
])
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) {
// Before interactive scripts need to be loaded by Next.js' runtime instead
// of native <script> tags, becasue they no longer have `defer`.
if (strategy === 'beforeInteractive') {
if (!src) {
// For inlined scripts, we put the content in `children`.
if (restProps.dangerouslySetInnerHTML) {
restProps.children = restProps.dangerouslySetInnerHTML.__html
delete restProps.dangerouslySetInnerHTML
}
return (
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `(self.__next_s=self.__next_s||[]).push(${JSON.stringify([
0,
{ ...restProps },
])})`,
}}
/>
)
}
// @ts-ignore
ReactDOM.preload(
src,
restProps.integrity
? { as: 'script', integrity: restProps.integrity }
: { as: 'script' }
)
return (
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `(self.__next_s=self.__next_s||[]).push(${JSON.stringify([
src,
])})`,
}}
/>
)
} else if (strategy === 'afterInteractive') {
if (src) {
// @ts-ignore
ReactDOM.preload(
src,
restProps.integrity
? { as: 'script', integrity: restProps.integrity }
: { as: 'script' }
)
}
}
}
return null
}
Object.defineProperty(Script, '__nextScript', { value: true })
export default Script