2020-12-01 19:10:16 +01:00
|
|
|
import React, { useEffect, useContext } from 'react'
|
|
|
|
import { ScriptHTMLAttributes } from 'react'
|
2021-06-30 11:43:31 +02:00
|
|
|
import { HeadManagerContext } from '../shared/lib/head-manager-context'
|
2020-12-01 19:10:16 +01:00
|
|
|
import { DOMAttributeNames } from './head-manager'
|
2021-02-11 19:51:41 +01:00
|
|
|
import { requestIdleCallback } from './request-idle-callback'
|
2020-12-01 19:10:16 +01:00
|
|
|
|
|
|
|
const ScriptCache = new Map()
|
|
|
|
const LoadCache = new Set()
|
|
|
|
|
2021-07-07 18:35:50 +02:00
|
|
|
export interface ScriptProps extends ScriptHTMLAttributes<HTMLScriptElement> {
|
2022-03-11 23:26:46 +01:00
|
|
|
strategy?: 'afterInteractive' | 'lazyOnload' | 'beforeInteractive' | 'worker'
|
2020-12-01 19:10:16 +01:00
|
|
|
id?: string
|
2021-08-10 19:41:26 +02:00
|
|
|
onLoad?: (e: any) => void
|
2022-07-28 22:42:52 +02:00
|
|
|
onReady?: () => void | null
|
2021-08-10 19:41:26 +02:00
|
|
|
onError?: (e: any) => void
|
2020-12-01 19:10:16 +01:00
|
|
|
children?: React.ReactNode
|
|
|
|
}
|
|
|
|
|
2021-07-07 18:35:50 +02:00
|
|
|
/**
|
|
|
|
* @deprecated Use `ScriptProps` instead.
|
|
|
|
*/
|
|
|
|
export type Props = ScriptProps
|
|
|
|
|
2021-03-02 20:17:33 +01:00
|
|
|
const ignoreProps = [
|
|
|
|
'onLoad',
|
2022-07-28 22:42:52 +02:00
|
|
|
'onReady',
|
2021-03-02 20:17:33 +01:00
|
|
|
'dangerouslySetInnerHTML',
|
|
|
|
'children',
|
|
|
|
'onError',
|
|
|
|
'strategy',
|
|
|
|
]
|
|
|
|
|
2021-07-07 18:35:50 +02:00
|
|
|
const loadScript = (props: ScriptProps): void => {
|
2020-12-01 19:10:16 +01:00
|
|
|
const {
|
2021-03-02 20:17:33 +01:00
|
|
|
src,
|
|
|
|
id,
|
2020-12-01 19:10:16 +01:00
|
|
|
onLoad = () => {},
|
2022-07-28 22:42:52 +02:00
|
|
|
onReady = null,
|
2020-12-01 19:10:16 +01:00
|
|
|
dangerouslySetInnerHTML,
|
|
|
|
children = '',
|
2021-08-20 22:48:48 +02:00
|
|
|
strategy = 'afterInteractive',
|
2020-12-01 19:10:16 +01:00
|
|
|
onError,
|
|
|
|
} = props
|
|
|
|
|
|
|
|
const cacheKey = id || src
|
2021-07-16 20:58:34 +02:00
|
|
|
|
|
|
|
// Script has already loaded
|
|
|
|
if (cacheKey && LoadCache.has(cacheKey)) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Contents of this script are already loading/loaded
|
2020-12-01 19:10:16 +01:00
|
|
|
if (ScriptCache.has(src)) {
|
2021-07-16 20:58:34 +02:00
|
|
|
LoadCache.add(cacheKey)
|
|
|
|
// Execute onLoad since the script loading has begun
|
|
|
|
ScriptCache.get(src).then(onLoad, onError)
|
2020-12-01 19:10:16 +01:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
const el = document.createElement('script')
|
|
|
|
|
2021-06-18 00:43:25 +02:00
|
|
|
const loadPromise = new Promise<void>((resolve, reject) => {
|
2021-08-10 19:41:26 +02:00
|
|
|
el.addEventListener('load', function (e) {
|
fix(#39993): avoid race condition for next/script onReady (#40002)
Fixes #39993.
Before the PR:
- `next/script` component mount, `useEffect` for `onReady` executes
- The script's cacheKey is not added to `LoadCache`, skip `onReady`
- The second `useEffect` for `loadScript` executes
- The script's cacheKey is added to `LoadCache` even if it might not fully load yet
- Because of React's strict mode, `useEffect` for `onReady` executes again
- Since the script's cacheKey is in `LoadCache`, `onReady` is called (even when the script is not loaded yet)
- After the script is actually loaded, inside the `script.onload` event handler the `onReady` is called again
After the PR:
- `next/script` component mount, `useEffect` for `onReady` executes
- The script's cacheKey is not added to `LoadCache`, `useEffect` skips `onReady`
- The second `useEffect` for `loadScript` executes
- The script's cacheKey is added to `LoadCache` only if it is an inline script
- Because of React's strict mode, `useEffect` for `onReady` executes again
- The script is not yet loaded, its cacheKey is not in `LoadCache`, `useEffect` skips `onReady` again
- After the script is actually loaded, inside the `script.onload` event handler the `onReady` is finally called
In short, the PR resolves a race condition that only occurs under React strict mode (and makes the `next/script` component more concurrent rendering resilient).
## Bug
- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`
2022-08-28 10:47:55 +02:00
|
|
|
// add cacheKey to LoadCache when load successfully
|
|
|
|
LoadCache.add(cacheKey)
|
|
|
|
|
2020-12-01 19:10:16 +01:00
|
|
|
resolve()
|
|
|
|
if (onLoad) {
|
2021-08-10 19:41:26 +02:00
|
|
|
onLoad.call(this, e)
|
2020-12-01 19:10:16 +01:00
|
|
|
}
|
2022-07-28 22:42:52 +02:00
|
|
|
// Run onReady for the first time after load event
|
|
|
|
if (onReady) {
|
|
|
|
onReady()
|
|
|
|
}
|
2020-12-01 19:10:16 +01:00
|
|
|
})
|
2021-08-10 19:41:26 +02:00
|
|
|
el.addEventListener('error', function (e) {
|
|
|
|
reject(e)
|
2020-12-01 19:10:16 +01:00
|
|
|
})
|
2021-08-10 19:41:26 +02:00
|
|
|
}).catch(function (e) {
|
|
|
|
if (onError) {
|
|
|
|
onError(e)
|
|
|
|
}
|
2020-12-01 19:10:16 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
if (dangerouslySetInnerHTML) {
|
|
|
|
el.innerHTML = dangerouslySetInnerHTML.__html || ''
|
fix(#39993): avoid race condition for next/script onReady (#40002)
Fixes #39993.
Before the PR:
- `next/script` component mount, `useEffect` for `onReady` executes
- The script's cacheKey is not added to `LoadCache`, skip `onReady`
- The second `useEffect` for `loadScript` executes
- The script's cacheKey is added to `LoadCache` even if it might not fully load yet
- Because of React's strict mode, `useEffect` for `onReady` executes again
- Since the script's cacheKey is in `LoadCache`, `onReady` is called (even when the script is not loaded yet)
- After the script is actually loaded, inside the `script.onload` event handler the `onReady` is called again
After the PR:
- `next/script` component mount, `useEffect` for `onReady` executes
- The script's cacheKey is not added to `LoadCache`, `useEffect` skips `onReady`
- The second `useEffect` for `loadScript` executes
- The script's cacheKey is added to `LoadCache` only if it is an inline script
- Because of React's strict mode, `useEffect` for `onReady` executes again
- The script is not yet loaded, its cacheKey is not in `LoadCache`, `useEffect` skips `onReady` again
- After the script is actually loaded, inside the `script.onload` event handler the `onReady` is finally called
In short, the PR resolves a race condition that only occurs under React strict mode (and makes the `next/script` component more concurrent rendering resilient).
## Bug
- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`
2022-08-28 10:47:55 +02:00
|
|
|
|
|
|
|
// add cacheKey to LoadCache for inline script
|
|
|
|
LoadCache.add(cacheKey)
|
2020-12-01 19:10:16 +01:00
|
|
|
} else if (children) {
|
|
|
|
el.textContent =
|
|
|
|
typeof children === 'string'
|
|
|
|
? children
|
|
|
|
: Array.isArray(children)
|
|
|
|
? children.join('')
|
|
|
|
: ''
|
fix(#39993): avoid race condition for next/script onReady (#40002)
Fixes #39993.
Before the PR:
- `next/script` component mount, `useEffect` for `onReady` executes
- The script's cacheKey is not added to `LoadCache`, skip `onReady`
- The second `useEffect` for `loadScript` executes
- The script's cacheKey is added to `LoadCache` even if it might not fully load yet
- Because of React's strict mode, `useEffect` for `onReady` executes again
- Since the script's cacheKey is in `LoadCache`, `onReady` is called (even when the script is not loaded yet)
- After the script is actually loaded, inside the `script.onload` event handler the `onReady` is called again
After the PR:
- `next/script` component mount, `useEffect` for `onReady` executes
- The script's cacheKey is not added to `LoadCache`, `useEffect` skips `onReady`
- The second `useEffect` for `loadScript` executes
- The script's cacheKey is added to `LoadCache` only if it is an inline script
- Because of React's strict mode, `useEffect` for `onReady` executes again
- The script is not yet loaded, its cacheKey is not in `LoadCache`, `useEffect` skips `onReady` again
- After the script is actually loaded, inside the `script.onload` event handler the `onReady` is finally called
In short, the PR resolves a race condition that only occurs under React strict mode (and makes the `next/script` component more concurrent rendering resilient).
## Bug
- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`
2022-08-28 10:47:55 +02:00
|
|
|
|
|
|
|
// add cacheKey to LoadCache for inline script
|
|
|
|
LoadCache.add(cacheKey)
|
2020-12-01 19:10:16 +01:00
|
|
|
} else if (src) {
|
|
|
|
el.src = src
|
fix(#39993): avoid race condition for next/script onReady (#40002)
Fixes #39993.
Before the PR:
- `next/script` component mount, `useEffect` for `onReady` executes
- The script's cacheKey is not added to `LoadCache`, skip `onReady`
- The second `useEffect` for `loadScript` executes
- The script's cacheKey is added to `LoadCache` even if it might not fully load yet
- Because of React's strict mode, `useEffect` for `onReady` executes again
- Since the script's cacheKey is in `LoadCache`, `onReady` is called (even when the script is not loaded yet)
- After the script is actually loaded, inside the `script.onload` event handler the `onReady` is called again
After the PR:
- `next/script` component mount, `useEffect` for `onReady` executes
- The script's cacheKey is not added to `LoadCache`, `useEffect` skips `onReady`
- The second `useEffect` for `loadScript` executes
- The script's cacheKey is added to `LoadCache` only if it is an inline script
- Because of React's strict mode, `useEffect` for `onReady` executes again
- The script is not yet loaded, its cacheKey is not in `LoadCache`, `useEffect` skips `onReady` again
- After the script is actually loaded, inside the `script.onload` event handler the `onReady` is finally called
In short, the PR resolves a race condition that only occurs under React strict mode (and makes the `next/script` component more concurrent rendering resilient).
## Bug
- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`
2022-08-28 10:47:55 +02:00
|
|
|
// 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)
|
2020-12-01 19:10:16 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
for (const [k, value] of Object.entries(props)) {
|
2021-03-02 20:17:33 +01:00
|
|
|
if (value === undefined || ignoreProps.includes(k)) {
|
2020-12-01 19:10:16 +01:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
const attr = DOMAttributeNames[k] || k.toLowerCase()
|
|
|
|
el.setAttribute(attr, value)
|
|
|
|
}
|
|
|
|
|
2022-03-11 23:26:46 +01:00
|
|
|
if (strategy === 'worker') {
|
|
|
|
el.setAttribute('type', 'text/partytown')
|
|
|
|
}
|
|
|
|
|
2021-08-20 22:48:48 +02:00
|
|
|
el.setAttribute('data-nscript', strategy)
|
|
|
|
|
2020-12-01 19:10:16 +01:00
|
|
|
document.body.appendChild(el)
|
|
|
|
}
|
|
|
|
|
2022-04-21 23:15:53 +02:00
|
|
|
export function handleClientScriptLoad(props: ScriptProps) {
|
2021-05-12 13:37:57 +02:00
|
|
|
const { strategy = 'afterInteractive' } = props
|
2022-04-21 23:15:53 +02:00
|
|
|
if (strategy === 'lazyOnload') {
|
2021-03-02 20:17:33 +01:00
|
|
|
window.addEventListener('load', () => {
|
|
|
|
requestIdleCallback(() => loadScript(props))
|
|
|
|
})
|
2022-04-21 23:15:53 +02:00
|
|
|
} else {
|
|
|
|
loadScript(props)
|
2021-03-02 20:17:33 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-07-07 18:35:50 +02:00
|
|
|
function loadLazyScript(props: ScriptProps) {
|
2021-03-02 20:17:33 +01:00
|
|
|
if (document.readyState === 'complete') {
|
|
|
|
requestIdleCallback(() => loadScript(props))
|
|
|
|
} else {
|
|
|
|
window.addEventListener('load', () => {
|
|
|
|
requestIdleCallback(() => loadScript(props))
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-04-21 23:15:53 +02:00
|
|
|
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)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2021-07-07 18:35:50 +02:00
|
|
|
export function initScriptLoader(scriptLoaderItems: ScriptProps[]) {
|
2021-03-02 20:17:33 +01:00
|
|
|
scriptLoaderItems.forEach(handleClientScriptLoad)
|
2022-04-21 23:15:53 +02:00
|
|
|
addBeforeInteractiveToCache()
|
2021-03-02 20:17:33 +01:00
|
|
|
}
|
|
|
|
|
2021-07-07 18:35:50 +02:00
|
|
|
function Script(props: ScriptProps): JSX.Element | null {
|
2020-12-01 19:10:16 +01:00
|
|
|
const {
|
2022-07-28 22:42:52 +02:00
|
|
|
id,
|
2020-12-01 19:10:16 +01:00
|
|
|
src = '',
|
|
|
|
onLoad = () => {},
|
2022-07-28 22:42:52 +02:00
|
|
|
onReady = null,
|
2021-05-12 13:37:57 +02:00
|
|
|
strategy = 'afterInteractive',
|
2020-12-01 19:10:16 +01:00
|
|
|
onError,
|
|
|
|
...restProps
|
|
|
|
} = props
|
|
|
|
|
|
|
|
// Context is available only during SSR
|
2021-08-24 18:07:38 +02:00
|
|
|
const { updateScripts, scripts, getIsSsr } = useContext(HeadManagerContext)
|
2020-12-01 19:10:16 +01:00
|
|
|
|
2022-07-28 22:42:52 +02:00
|
|
|
useEffect(() => {
|
|
|
|
const cacheKey = id || src
|
|
|
|
|
|
|
|
// Run onReady if script has loaded before but component is re-mounted
|
|
|
|
if (onReady && cacheKey && LoadCache.has(cacheKey)) {
|
|
|
|
onReady()
|
|
|
|
}
|
|
|
|
}, [onReady, id, src])
|
|
|
|
|
2020-12-01 19:10:16 +01:00
|
|
|
useEffect(() => {
|
2021-05-12 13:37:57 +02:00
|
|
|
if (strategy === 'afterInteractive') {
|
2020-12-01 19:10:16 +01:00
|
|
|
loadScript(props)
|
2021-05-12 13:37:57 +02:00
|
|
|
} else if (strategy === 'lazyOnload') {
|
2021-03-02 20:17:33 +01:00
|
|
|
loadLazyScript(props)
|
2020-12-01 19:10:16 +01:00
|
|
|
}
|
2021-03-02 20:17:33 +01:00
|
|
|
}, [props, strategy])
|
|
|
|
|
2022-03-11 23:26:46 +01:00
|
|
|
if (strategy === 'beforeInteractive' || strategy === 'worker') {
|
2020-12-01 19:10:16 +01:00
|
|
|
if (updateScripts) {
|
2022-03-11 23:26:46 +01:00
|
|
|
scripts[strategy] = (scripts[strategy] || []).concat([
|
2020-12-01 19:10:16 +01:00
|
|
|
{
|
2022-07-28 22:42:52 +02:00
|
|
|
id,
|
2020-12-01 19:10:16 +01:00
|
|
|
src,
|
|
|
|
onLoad,
|
2022-07-28 22:42:52 +02:00
|
|
|
onReady,
|
2020-12-01 19:10:16 +01:00
|
|
|
onError,
|
|
|
|
...restProps,
|
|
|
|
},
|
|
|
|
])
|
|
|
|
updateScripts(scripts)
|
2021-08-24 18:07:38 +02:00
|
|
|
} else if (getIsSsr && getIsSsr()) {
|
|
|
|
// Script has already loaded during SSR
|
2022-07-28 22:42:52 +02:00
|
|
|
LoadCache.add(id || src)
|
2021-08-24 18:07:38 +02:00
|
|
|
} else if (getIsSsr && !getIsSsr()) {
|
2021-07-16 00:51:01 +02:00
|
|
|
loadScript(props)
|
2020-12-01 19:10:16 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return null
|
|
|
|
}
|
2021-03-02 20:17:33 +01:00
|
|
|
|
2022-08-28 20:36:42 +02:00
|
|
|
Object.defineProperty(Script, '__nextScript', { value: true })
|
|
|
|
|
2021-03-02 20:17:33 +01:00
|
|
|
export default Script
|