Experimental script loader changes (#22038)

Making experimental script work in _document.js - Fixes for server to client transition
Adding additional test for _document.js
This commit is contained in:
Janicklas Ralph 2021-03-02 11:17:33 -08:00 committed by GitHub
parent 103422ce70
commit a107dcb732
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 189 additions and 70 deletions

View file

@ -1031,6 +1031,9 @@ export default async function getBaseWebpackConfig(
'process.env.__NEXT_OPTIMIZE_CSS': JSON.stringify(
config.experimental.optimizeCss && !dev
),
'process.env.__NEXT_SCRIPT_LOADER': JSON.stringify(
!!config.experimental.scriptLoader
),
'process.env.__NEXT_SCROLL_RESTORATION': JSON.stringify(
config.experimental.scrollRestoration
),

View file

@ -7,7 +7,7 @@ import { requestIdleCallback } from './request-idle-callback'
const ScriptCache = new Map()
const LoadCache = new Set()
interface Props extends ScriptHTMLAttributes<HTMLScriptElement> {
export interface Props extends ScriptHTMLAttributes<HTMLScriptElement> {
strategy?: 'defer' | 'lazy' | 'dangerouslyBlockRendering' | 'eager'
id?: string
onLoad?: () => void
@ -16,13 +16,22 @@ interface Props extends ScriptHTMLAttributes<HTMLScriptElement> {
preload?: boolean
}
const ignoreProps = [
'onLoad',
'dangerouslySetInnerHTML',
'children',
'onError',
'strategy',
'preload',
]
const loadScript = (props: Props): void => {
const {
src = '',
src,
id,
onLoad = () => {},
dangerouslySetInnerHTML,
children = '',
id,
onError,
} = props
@ -72,7 +81,7 @@ const loadScript = (props: Props): void => {
}
for (const [k, value] of Object.entries(props)) {
if (value === undefined) {
if (value === undefined || ignoreProps.includes(k)) {
continue
}
@ -83,7 +92,32 @@ const loadScript = (props: Props): void => {
document.body.appendChild(el)
}
export default function Script(props: Props): JSX.Element | null {
function handleClientScriptLoad(props: Props) {
const { strategy = 'defer' } = props
if (strategy === 'defer') {
loadScript(props)
} else if (strategy === 'lazy') {
window.addEventListener('load', () => {
requestIdleCallback(() => loadScript(props))
})
}
}
function loadLazyScript(props: Props) {
if (document.readyState === 'complete') {
requestIdleCallback(() => loadScript(props))
} else {
window.addEventListener('load', () => {
requestIdleCallback(() => loadScript(props))
})
}
}
export function initScriptLoader(scriptLoaderItems: Props[]) {
scriptLoaderItems.forEach(handleClientScriptLoad)
}
function Script(props: Props): JSX.Element | null {
const {
src = '',
onLoad = () => {},
@ -102,11 +136,13 @@ export default function Script(props: Props): JSX.Element | null {
if (strategy === 'defer') {
loadScript(props)
} else if (strategy === 'lazy') {
window.addEventListener('load', () => {
requestIdleCallback(() => loadScript(props))
})
loadLazyScript(props)
}
}, [props, strategy])
if (!process.env.__NEXT_SCRIPT_LOADER) {
return null
}
}, [strategy, props])
if (strategy === 'dangerouslyBlockRendering') {
const syncProps: Props = { ...restProps }
@ -157,3 +193,5 @@ export default function Script(props: Props): JSX.Element | null {
return null
}
export default Script

View file

@ -139,6 +139,11 @@ if (process.env.__NEXT_I18N_SUPPORT) {
}
}
if (process.env.__NEXT_SCRIPT_LOADER && data.scriptLoader) {
const { initScriptLoader } = require('./experimental-script')
initScriptLoader(data.scriptLoader)
}
type RegisterFn = (input: [string, () => void]) => void
const pageLoader: PageLoader = new PageLoader(buildId, prefix)

View file

@ -103,6 +103,7 @@ export type NEXT_DATA = {
locales?: string[]
defaultLocale?: string
domainLocales?: DomainLocales
scriptLoader?: any[]
isPreview?: boolean
}

View file

@ -17,6 +17,9 @@ import {
} from '../next-server/server/get-page-files'
import { cleanAmpPath } from '../next-server/server/utils'
import { htmlEscapeJsonString } from '../server/htmlescape'
import Script, {
Props as ScriptLoaderProps,
} from '../client/experimental-script'
export { DocumentContext, DocumentInitialProps, DocumentProps }
@ -313,6 +316,34 @@ export class Head extends Component<
]
}
handleDocumentScriptLoaderItems(children: React.ReactNode): ReactNode[] {
const { scriptLoader } = this.context
const scriptLoaderItems: ScriptLoaderProps[] = []
const filteredChildren: ReactNode[] = []
React.Children.forEach(children, (child: any) => {
if (child.type === Script) {
if (child.props.strategy === 'eager') {
scriptLoader.eager = (scriptLoader.eager || []).concat([
{
...child.props,
},
])
return
} else if (['lazy', 'defer'].includes(child.props.strategy)) {
scriptLoaderItems.push(child.props)
return
}
}
filteredChildren.push(child)
})
this.context.__NEXT_DATA__.scriptLoader = scriptLoaderItems
return filteredChildren
}
makeStylesheetInert(node: ReactNode): ReactNode[] {
return React.Children.map(node, (c: any) => {
if (
@ -402,6 +433,10 @@ export class Head extends Component<
children = this.makeStylesheetInert(children)
}
if (process.env.__NEXT_SCRIPT_LOADER) {
children = this.handleDocumentScriptLoaderItems(children)
}
let hasAmphtmlRel = false
let hasCanonicalRel = false
@ -649,10 +684,11 @@ export class NextScript extends Component<OriginProps> {
getPreNextScripts() {
const { scriptLoader } = this.context
return (scriptLoader.eager || []).map((file: string) => {
return (scriptLoader.eager || []).map((file: ScriptLoaderProps) => {
const { strategy, ...props } = file
return (
<script
{...file}
{...props}
nonce={this.props.nonce}
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN

View file

@ -1,6 +1,7 @@
import * as React from 'react'
/// @ts-ignore
import Document, { Main, NextScript, Head } from 'next/document'
import Script from 'next/experimental-script'
export default class MyDocument extends Document {
constructor(props) {
@ -19,6 +20,26 @@ export default class MyDocument extends Document {
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Voces"
/>
<Script
id="documentDefer"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=documentDefer"
strategy="defer"
></Script>
<Script
id="documentLazy"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=documentLazy"
strategy="lazy"
></Script>
<Script
id="documentBlock"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=documentBlock"
strategy="dangerouslyBlockRendering"
></Script>
<Script
id="documentEager"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=documentEager"
strategy="eager"
></Script>
</Head>
<body>
<Main />

View file

@ -4,8 +4,8 @@ const Page = () => {
return (
<div class="container">
<Script
id="script"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=defer"
id="scriptDefer"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptDefer"
preload
></Script>
<div>index</div>

View file

@ -4,8 +4,8 @@ const Page = () => {
return (
<div class="container">
<Script
id="script"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=eager"
id="scriptEager"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptEager"
strategy="eager"
></Script>
<div>page1</div>

View file

@ -4,7 +4,7 @@ const Page = () => {
return (
<div class="container">
<Script
id="script"
id="scriptBlock"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=dangerouslyBlockRendering"
strategy="dangerouslyBlockRendering"
></Script>

View file

@ -11,8 +11,8 @@ const Page = () => {
})`}
</Script>
<Script
id="script"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=lazy"
id="scriptLazy"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptLazy"
strategy="lazy"
></Script>
<div>page3</div>

View file

@ -40,13 +40,11 @@ describe('Script Loader', () => {
browser = await webdriver(appPort, '/')
await waitFor(1000)
const script = await browser.elementById('script')
async function test(id) {
const script = await browser.elementById(id)
const src = await script.getAttribute('src')
const scriptPreload = await browser.elementsByCss(
`link[rel=preload][href="${src}"]`
)
const endScripts = await browser.elementsByCss(
'#script ~ script[src^="/_next/static/"]'
`#${id} ~ script[src^="/_next/static/"]`
)
const endPreloads = await browser.elementsByCss(
`link[rel=preload][href="${src}"] ~ link[rel=preload][href^="/_next/static/"]`
@ -54,12 +52,16 @@ describe('Script Loader', () => {
// Renders script tag
expect(script).toBeDefined()
// Renders preload
expect(scriptPreload.length).toBeGreaterThan(0)
// Script is inserted at the end
expect(endScripts.length).toBe(0)
//Preload is defined at the end
expect(endPreloads.length).toBe(0)
}
// Defer script in page
await test('scriptDefer')
// Defer script in _document
await test('documentDefer')
} finally {
if (browser) await browser.close()
}
@ -73,15 +75,22 @@ describe('Script Loader', () => {
await browser.waitForElementByCss('#onload-div')
await waitFor(1000)
const script = await browser.elementById('script')
async function test(id) {
const script = await browser.elementById(id)
const endScripts = await browser.elementsByCss(
'#script ~ script[src^="/_next/static/"]'
`#${id} ~ script[src^="/_next/static/"]`
)
// Renders script tag
expect(script).toBeDefined()
// Script is inserted at the end
expect(endScripts.length).toBe(0)
}
// Lazy script in page
await test('scriptLazy')
// Lazy script in _document
await test('documentLazy')
} finally {
if (browser) await browser.close()
}
@ -91,7 +100,8 @@ describe('Script Loader', () => {
const html = await renderViaHTTP(appPort, '/page1')
const $ = cheerio.load(html)
const script = $('#script')
function test(id) {
const script = $(`#${id}`)
const src = script.attr('src')
// Renders script tag
@ -115,9 +125,13 @@ describe('Script Loader', () => {
// Script is inserted before NextScripts
expect(
$('#__NEXT_DATA__ ~ #script ~ script[src^="/_next/static/chunks/main"]')
$(`#__NEXT_DATA__ ~ #${id} ~ script[src^="/_next/static/chunks/main"]`)
.length
).toBeGreaterThan(0)
}
test('scriptEager')
test('documentEager')
})
it('priority dangerouslyBlockRendering', async () => {
@ -125,10 +139,11 @@ describe('Script Loader', () => {
const $ = cheerio.load(html)
// Script is inserted in place
expect($('.container #script').length).toBeGreaterThan(0)
expect($('.container #scriptBlock').length).toBeGreaterThan(0)
expect($('head #documentBlock').length).toBeGreaterThan(0)
})
it('onloads fire correctly', async () => {
it('onload fires correctly', async () => {
let browser
try {
browser = await webdriver(appPort, '/page4')