Skip to content

Commit

Permalink
[Script] Adds onReady prop to next/script (#38849)
Browse files Browse the repository at this point in the history
Closes: #30962

This PR adds a new `onReady` prop to `next/script` to handle shortcomings of the current `onLoad` prop. Some third-party providers and widgets require initialization code to run after the script's `load` event and every time the component is mounted. The `onReady` should solve that use case.

For more details, refer to the discussion in #30962.

CC @janicklas-ralph

## Bug

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


Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com>
  • Loading branch information
housseindjirdeh and ijjk committed Jul 28, 2022
1 parent 85b00b2 commit a818606
Show file tree
Hide file tree
Showing 6 changed files with 143 additions and 5 deletions.
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

0 comments on commit a818606

Please sign in to comment.