Extract anyclip
This commit is contained in:
385
client/script.tsx
Normal file
385
client/script.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
'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
|
||||
Reference in New Issue
Block a user