Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Script] Adds onReady prop to next/script #38849

Merged
merged 5 commits into from Jul 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
33 changes: 31 additions & 2 deletions docs/api-reference/next/script.md
Expand Up @@ -20,6 +20,7 @@ description: Optimize loading of third-party scripts with the built-in Script co

| Version | Changes |
| --------- | ------------------------- |
| `v12.2.4` | `onReady` prop added. |
| `v11.0.0` | `next/script` introduced. |

</details>
Expand Down Expand Up @@ -47,9 +48,9 @@ The loading strategy of the script.

### onLoad

A method that returns additional JavaScript that should be executed after the script has finished loading.
A method that returns additional JavaScript that should be executed once after the script has finished loading.

> **Note: `onLoad` can't be used with the `beforeInteractive` loading strategy.**
> **Note: `onLoad` can't be used with the `beforeInteractive` loading strategy. Consider using `onReady` instead.**

The following is an example of how to use the `onLoad` property:

Expand All @@ -74,6 +75,34 @@ export default function Home() {
}
```

### onReady

A method that returns additional JavaScript that should be executed after the script has finished loading and every time the component is mounted.

The following is an example of how to use the `onReady` property:

```jsx
import { useState } from 'react'
import Script from 'next/script'

export default function Home() {
return (
<>
<Script
id="google-maps"
src="https://maps.googleapis.com/maps/api/js"
onReady={() => {
new google.maps.Map(ref.current, {
center: { lat: -34.397, lng: 150.644 },
zoom: 8,
})
}}
/>
</>
)
}
```

### onError

A method that executes if the script fails to load.
Expand Down
34 changes: 32 additions & 2 deletions docs/basic-features/script.md
Expand Up @@ -21,6 +21,7 @@ description: Next.js helps you optimize loading third-party scripts with the bui

| Version | Changes |
| --------- | ------------------------- |
| `v12.2.4` | `onReady` prop added. |
| `v11.0.0` | `next/script` introduced. |

</details>
Expand Down Expand Up @@ -249,9 +250,9 @@ The `id` property is required for **inline scripts** in order for Next.js to tra

### Executing Code After Loading (`onLoad`)

> **Note: `onLoad` and `onError` cannot be used with the `beforeInteractive` loading strategy.**
> **Note: `onLoad` cannot be used with the `beforeInteractive` loading strategy. Consider using `onReady` instead.**

Some third-party scripts require users to run JavaScript code after the script has finished loading in order to instantiate content or call a function. If you are loading a script with either `afterInteractive` or `lazyOnload` as a loading strategy, you can execute code after it has loaded using the `onLoad` property:
Some third-party scripts require users to run JavaScript code once after the script has finished loading in order to instantiate content or call a function. If you are loading a script with either `afterInteractive` or `lazyOnload` as a loading strategy, you can execute code after it has loaded using the `onLoad` property:

```jsx
import { useState } from 'react'
Expand All @@ -274,6 +275,35 @@ export default function Home() {
}
```

### Executing Code After Mounting (`onReady`)

Some third-party scripts require users to run JavaScript code after the script has finished loading and every time the component is mounted (after a route navigation for example). You can execute code after the script's `load` event when it first loads and then after every subsequent component re-mount using the `onReady` property:

```jsx
import Script from 'next/script'

export default function Home() {
return (
<>
<Script
id="google-maps"
src="https://maps.googleapis.com/maps/api/js"
onReady={() => {
new google.maps.Map(ref.current, {
center: { lat: -34.397, lng: 150.644 },
zoom: 8,
})
}}
/>
</>
)
}
```

### Handling errors (`onError`)

> **Note: `onError` cannot be used with the `beforeInteractive` loading strategy.**

Sometimes it is helpful to catch when a script fails to load. These errors can be handled with the `onError` property:

```jsx
Expand Down
22 changes: 21 additions & 1 deletion packages/next/client/script.tsx
Expand Up @@ -11,6 +11,7 @@ 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
}
Expand All @@ -22,6 +23,7 @@ export type Props = ScriptProps

const ignoreProps = [
'onLoad',
'onReady',
'dangerouslySetInnerHTML',
'children',
'onError',
Expand All @@ -33,6 +35,7 @@ const loadScript = (props: ScriptProps): void => {
src,
id,
onLoad = () => {},
onReady = null,
dangerouslySetInnerHTML,
children = '',
strategy = 'afterInteractive',
Expand Down Expand Up @@ -62,6 +65,10 @@ const loadScript = (props: ScriptProps): void => {
if (onLoad) {
onLoad.call(this, e)
}
// Run onReady for the first time after load event
if (onReady) {
onReady()
}
})
el.addEventListener('error', function (e) {
reject(e)
Expand Down Expand Up @@ -147,8 +154,10 @@ export function initScriptLoader(scriptLoaderItems: ScriptProps[]) {

function Script(props: ScriptProps): JSX.Element | null {
const {
id,
src = '',
onLoad = () => {},
onReady = null,
strategy = 'afterInteractive',
onError,
...restProps
Expand All @@ -157,6 +166,15 @@ function Script(props: ScriptProps): JSX.Element | null {
// Context is available only during SSR
const { updateScripts, scripts, getIsSsr } = useContext(HeadManagerContext)

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])

useEffect(() => {
if (strategy === 'afterInteractive') {
loadScript(props)
Expand All @@ -169,16 +187,18 @@ function Script(props: ScriptProps): JSX.Element | null {
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(restProps.id || src)
LoadCache.add(id || src)
} else if (getIsSsr && !getIsSsr()) {
loadScript(props)
}
Expand Down
24 changes: 24 additions & 0 deletions test/integration/script-loader/base/pages/page8.js
@@ -0,0 +1,24 @@
import Script from 'next/script'
import Link from 'next/link'

const url =
'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js'

const Page = () => {
return (
<div class="container">
<Link href="/page9">Page 9</Link>
<div id="text"></div>
<Script
src={url}
id="script1"
onReady={() => {
// eslint-disable-next-line no-undef
document.getElementById('text').textContent += _.repeat('a', 3)
}}
></Script>
</div>
)
}

export default Page
11 changes: 11 additions & 0 deletions test/integration/script-loader/base/pages/page9.js
@@ -0,0 +1,11 @@
import Link from 'next/link'

const Page = () => {
return (
<>
<Link href="/page8">Page 8</Link>
</>
)
}

export default Page
24 changes: 24 additions & 0 deletions test/integration/script-loader/test/index.test.js
Expand Up @@ -190,6 +190,30 @@ describe('Next.js Script - Primary Strategies', () => {
}
})

it('onReady fires after load event and then on every subsequent re-mount', async () => {
let browser
try {
browser = await webdriver(appPort, '/page8')

const text = await browser.elementById('text').text()

expect(text).toBe('aaa')

// Navigate to different page and back
await browser.waitForElementByCss('[href="/page9"]')
await browser.click('[href="/page9"]')
await browser.waitForElementByCss('[href="/page8"]')
await browser.click('[href="/page8"]')

await browser.waitForElementByCss('.container')
const sameText = await browser.elementById('text').text()

expect(sameText).toBe('aaa') // onReady should fire again
} finally {
if (browser) await browser.close()
}
})

it('priority beforeInteractive with inline script', async () => {
const html = await renderViaHTTP(appPort, '/page5')
const $ = cheerio.load(html)
Expand Down