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

next/script: Correctly apply async and defer props #52939

Merged
merged 23 commits into from
Apr 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 2 additions & 24 deletions packages/next/src/client/head-manager.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,8 @@
export const DOMAttributeNames: Record<string, string> = {
acceptCharset: 'accept-charset',
className: 'class',
htmlFor: 'for',
httpEquiv: 'http-equiv',
noModule: 'noModule',
}
import { setAttributesFromProps } from './set-attributes-from-props'

function reactElementToDOM({ type, props }: JSX.Element): HTMLElement {
const el: HTMLElement = document.createElement(type)
for (const p in props) {
if (!props.hasOwnProperty(p)) continue
if (p === 'children' || p === 'dangerouslySetInnerHTML') continue

// we don't render undefined props to the DOM
if (props[p] === undefined) continue

const attr = DOMAttributeNames[p] || p.toLowerCase()
if (
type === 'script' &&
(attr === 'async' || attr === 'defer' || attr === 'noModule')
) {
;(el as HTMLScriptElement)[attr] = !!props[p]
} else {
el.setAttribute(attr, props[p])
}
}
setAttributesFromProps(el, props)

const { children, dangerouslySetInnerHTML } = props
if (dangerouslySetInnerHTML) {
Expand Down
21 changes: 2 additions & 19 deletions packages/next/src/client/script.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import ReactDOM from 'react-dom'
import React, { useEffect, useContext, useRef } from 'react'
import type { ScriptHTMLAttributes } from 'react'
import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime'
import { DOMAttributeNames } from './head-manager'
import { setAttributesFromProps } from './set-attributes-from-props'
import { requestIdleCallback } from './request-idle-callback'

const ScriptCache = new Map()
Expand All @@ -25,16 +25,6 @@ export interface ScriptProps extends ScriptHTMLAttributes<HTMLScriptElement> {
*/
export type Props = ScriptProps

const ignoreProps = [
'onLoad',
'onReady',
'dangerouslySetInnerHTML',
'children',
'onError',
'strategy',
'stylesheets',
]

const insertStylesheets = (stylesheets: string[]) => {
// Case 1: Styles for afterInteractive/lazyOnload with appDir injected via handleClientScriptLoad
//
Expand Down Expand Up @@ -148,14 +138,7 @@ const loadScript = (props: ScriptProps): void => {
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)
}
setAttributesFromProps(el, props)

if (strategy === 'worker') {
el.setAttribute('type', 'text/partytown')
Expand Down
59 changes: 59 additions & 0 deletions packages/next/src/client/set-attributes-from-props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const DOMAttributeNames: Record<string, string> = {
acceptCharset: 'accept-charset',
className: 'class',
htmlFor: 'for',
httpEquiv: 'http-equiv',
noModule: 'noModule',
}

const ignoreProps = [
'onLoad',
'onReady',
'dangerouslySetInnerHTML',
'children',
'onError',
'strategy',
'stylesheets',
]

function isBooleanScriptAttribute(
attr: string
): attr is 'async' | 'defer' | 'noModule' {
return ['async', 'defer', 'noModule'].includes(attr)
}

export function setAttributesFromProps(el: HTMLElement, props: object) {
for (const [p, value] of Object.entries(props)) {
if (!props.hasOwnProperty(p)) continue
if (ignoreProps.includes(p)) continue

// we don't render undefined props to the DOM
if (value === undefined) {
continue
}

const attr = DOMAttributeNames[p] || p.toLowerCase()

if (el.tagName === 'SCRIPT' && isBooleanScriptAttribute(attr)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (el.tagName === 'SCRIPT' && isBooleanScriptAttribute(attr)) {
if (el.tagName === 'SCRIPT' && isBooleanScriptAttribute(attr) && value !== 'false') {

Couldn't we avoid setting the initial value here for the 'false' case and then that should remove the need for the setAttribute(attr, ''); removeAttribute(attr) condition below?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, this will have the same problem that this PR is trying to fix:

  • false, this will enter this if condition and set the attribute to false, which HTML casts to "false" which is interpreted as true (non-empty string)
  • "false", this will enter the else condition and set the attribute to "false" which is interpreted as true (non-empty string)

This also breaks for script elements, which when inserted by JavaScript automatically default to async=true. So for async=false we need to do this setAttribute/removeAttribute trick explicitly. See https://html.spec.whatwg.org/multipage/scripting.html#script-force-async

// Correctly assign boolean script attributes
// https://github.com/vercel/next.js/pull/20748
;(el as HTMLScriptElement)[attr] = !!value
} else {
el.setAttribute(attr, String(value))
}

// Remove falsy non-zero boolean attributes so they are correctly interpreted
// (e.g. if we set them to false, this coerces to the string "false", which the browser interprets as true)
if (
value === false ||
(el.tagName === 'SCRIPT' &&
isBooleanScriptAttribute(attr) &&
(!value || value === 'false'))
) {
// Call setAttribute before, as we need to set and unset the attribute to override force async:
// https://html.spec.whatwg.org/multipage/scripting.html#script-force-async
el.setAttribute(attr, '')
el.removeAttribute(attr)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ export default () => (
<link rel="stylesheet" href="dedupe-style.css" key="my-style" />

{/* this should not execute twice on the client */}
<script src="/test-async.js" async></script>
<script src="/test-async-true.js" async></script>
{/* this should have async set to false on the client */}
<script src="/test-async-false.js" async={false}></script>
{/* this should not execute twice on the client (intentionally sets defer to `yas` to test boolean coercion) */}
<script src="/test-defer.js" defer="yas"></script>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react'
import Script from 'next/script'

export default () => (
<div>
<h1>I am a page to test next/script</h1>
<Script src="/test-async-true.js" async />
<Script src="/test-async-false.js" async={false} />
<Script src="/test-defer.js" defer />
</div>
)
48 changes: 48 additions & 0 deletions test/development/pages-dir/client-navigation/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1773,6 +1773,54 @@ createNextDescribe(
expect(value).toBe(false)
})

it.each([true, false])(
'should handle boolean async prop in next/head client-side: %s',
async (bool) => {
const browser = await webdriver(next.appPort, '/head')
const value = await browser.eval(
`document.querySelector('script[src="/test-async-${JSON.stringify(
bool
)}.js"]').async`
)

expect(value).toBe(bool)
}
)

it.each([true, false])(
'should handle boolean async prop in next/script client-side: %s',
async (bool) => {
const browser = await webdriver(next.appPort, '/script')
const value = await browser.eval(
`document.querySelector('script[src="/test-async-${JSON.stringify(
bool
)}.js"]').async`
)

expect(value).toBe(bool)
}
)

it('should only execute async and defer scripts with next/script once', async () => {
let browser
try {
browser = await webdriver(next.appPort, '/script')

await browser.waitForElementByCss('h1')
await waitFor(2000)
expect(
Number(await browser.eval('window.__test_async_executions'))
).toBe(1)
expect(
Number(await browser.eval('window.__test_defer_executions'))
).toBe(1)
} finally {
if (browser) {
await browser.close()
}
}
})

it('should emit routeChangeError on hash change cancel', async () => {
const browser = await webdriver(next.appPort, '/')

Expand Down
3 changes: 2 additions & 1 deletion test/development/pages-dir/client-navigation/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ export default function (next: NextInstance, render, fetch, ctx) {

test('header helper renders boolean attributes correctly children', async () => {
const html = await render('/head')
expect(html).toContain('<script src="/test-async.js" async="">')
expect(html).toContain('<script src="/test-async-true.js" async="">')
expect(html).toContain('<script src="/test-async-false.js">')
expect(html).toContain('<script src="/test-defer.js" defer="">')
})

Expand Down
1 change: 1 addition & 0 deletions tsec-exemptions.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"ban-element-setattribute": [
"packages/next/src/client/head-manager.ts",
"packages/next/src/client/script.tsx",
"packages/next/src/client/set-attributes-from-props.ts",
"packages/next/src/build/webpack/loaders/next-style-loader/runtime/injectStylesIntoLinkTag.ts",
"packages/next/src/build/webpack/loaders/next-style-loader/runtime/injectStylesIntoStyleTag.ts",
"packages/next/src/client/app-bootstrap.ts"
Expand Down