From a107dcb73268500e926856a224767788bcfa12fd Mon Sep 17 00:00:00 2001 From: Janicklas Ralph Date: Tue, 2 Mar 2021 11:17:33 -0800 Subject: [PATCH] Experimental script loader changes (#22038) Making experimental script work in _document.js - Fixes for server to client transition Adding additional test for _document.js --- packages/next/build/webpack-config.ts | 3 + packages/next/client/experimental-script.tsx | 56 +++++++-- packages/next/client/index.tsx | 5 + packages/next/next-server/lib/utils.ts | 1 + packages/next/pages/_document.tsx | 40 +++++- .../script-loader/pages/_document.js | 21 ++++ test/integration/script-loader/pages/index.js | 4 +- test/integration/script-loader/pages/page1.js | 4 +- test/integration/script-loader/pages/page2.js | 2 +- test/integration/script-loader/pages/page3.js | 4 +- .../script-loader/test/index.test.js | 119 ++++++++++-------- 11 files changed, 189 insertions(+), 70 deletions(-) diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index fb01175ee5..884d9ce2af 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -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 ), diff --git a/packages/next/client/experimental-script.tsx b/packages/next/client/experimental-script.tsx index 04f3cd382c..648a3c833a 100644 --- a/packages/next/client/experimental-script.tsx +++ b/packages/next/client/experimental-script.tsx @@ -7,7 +7,7 @@ import { requestIdleCallback } from './request-idle-callback' const ScriptCache = new Map() const LoadCache = new Set() -interface Props extends ScriptHTMLAttributes { +export interface Props extends ScriptHTMLAttributes { strategy?: 'defer' | 'lazy' | 'dangerouslyBlockRendering' | 'eager' id?: string onLoad?: () => void @@ -16,13 +16,22 @@ interface Props extends ScriptHTMLAttributes { 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) } - }, [strategy, props]) + }, [props, strategy]) + + if (!process.env.__NEXT_SCRIPT_LOADER) { + return null + } 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 diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index 9e5aa52f77..71366a52d5 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -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) diff --git a/packages/next/next-server/lib/utils.ts b/packages/next/next-server/lib/utils.ts index 2d41b00603..e09c3c0618 100644 --- a/packages/next/next-server/lib/utils.ts +++ b/packages/next/next-server/lib/utils.ts @@ -103,6 +103,7 @@ export type NEXT_DATA = { locales?: string[] defaultLocale?: string domainLocales?: DomainLocales + scriptLoader?: any[] isPreview?: boolean } diff --git a/packages/next/pages/_document.tsx b/packages/next/pages/_document.tsx index 5f5bf42ebe..882fe217c0 100644 --- a/packages/next/pages/_document.tsx +++ b/packages/next/pages/_document.tsx @@ -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 { getPreNextScripts() { const { scriptLoader } = this.context - return (scriptLoader.eager || []).map((file: string) => { + return (scriptLoader.eager || []).map((file: ScriptLoaderProps) => { + const { strategy, ...props } = file return ( + + +
diff --git a/test/integration/script-loader/pages/index.js b/test/integration/script-loader/pages/index.js index bac7e6eb91..0547af5020 100644 --- a/test/integration/script-loader/pages/index.js +++ b/test/integration/script-loader/pages/index.js @@ -4,8 +4,8 @@ const Page = () => { return (
index
diff --git a/test/integration/script-loader/pages/page1.js b/test/integration/script-loader/pages/page1.js index 9fea586b4a..728a77417d 100644 --- a/test/integration/script-loader/pages/page1.js +++ b/test/integration/script-loader/pages/page1.js @@ -4,8 +4,8 @@ const Page = () => { return (
page1
diff --git a/test/integration/script-loader/pages/page2.js b/test/integration/script-loader/pages/page2.js index cb779f0a90..947ec448e6 100644 --- a/test/integration/script-loader/pages/page2.js +++ b/test/integration/script-loader/pages/page2.js @@ -4,7 +4,7 @@ const Page = () => { return (
diff --git a/test/integration/script-loader/pages/page3.js b/test/integration/script-loader/pages/page3.js index 15cc558c94..fb965da75d 100644 --- a/test/integration/script-loader/pages/page3.js +++ b/test/integration/script-loader/pages/page3.js @@ -11,8 +11,8 @@ const Page = () => { })`}
page3
diff --git a/test/integration/script-loader/test/index.test.js b/test/integration/script-loader/test/index.test.js index 51b719f951..344944fe32 100644 --- a/test/integration/script-loader/test/index.test.js +++ b/test/integration/script-loader/test/index.test.js @@ -40,26 +40,28 @@ describe('Script Loader', () => { browser = await webdriver(appPort, '/') await waitFor(1000) - const script = await browser.elementById('script') - 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/"]' - ) - const endPreloads = await browser.elementsByCss( - `link[rel=preload][href="${src}"] ~ link[rel=preload][href^="/_next/static/"]` - ) + async function test(id) { + const script = await browser.elementById(id) + const src = await script.getAttribute('src') + const endScripts = await browser.elementsByCss( + `#${id} ~ script[src^="/_next/static/"]` + ) + const endPreloads = await browser.elementsByCss( + `link[rel=preload][href="${src}"] ~ link[rel=preload][href^="/_next/static/"]` + ) - // 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) + // Renders script tag + expect(script).toBeDefined() + // 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') - const endScripts = await browser.elementsByCss( - '#script ~ script[src^="/_next/static/"]' - ) + async function test(id) { + const script = await browser.elementById(id) + const endScripts = await browser.elementsByCss( + `#${id} ~ script[src^="/_next/static/"]` + ) - // Renders script tag - expect(script).toBeDefined() - // Script is inserted at the end - expect(endScripts.length).toBe(0) + // 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,33 +100,38 @@ describe('Script Loader', () => { const html = await renderViaHTTP(appPort, '/page1') const $ = cheerio.load(html) - const script = $('#script') - const src = script.attr('src') + function test(id) { + const script = $(`#${id}`) + const src = script.attr('src') - // Renders script tag - expect(script).toBeDefined() - // Preload is inserted at the beginning - expect( - $( - `link[rel=preload][href="${src}"] ~ link[rel=preload][href^="/_next/static/"]` - ).length && - !$( - `link[rel=preload][href^="/_next/static/chunks/main"] ~ link[rel=preload][href="${src}"]` + // Renders script tag + expect(script).toBeDefined() + // Preload is inserted at the beginning + expect( + $( + `link[rel=preload][href="${src}"] ~ link[rel=preload][href^="/_next/static/"]` + ).length && + !$( + `link[rel=preload][href^="/_next/static/chunks/main"] ~ link[rel=preload][href="${src}"]` + ).length + ).toBeTruthy() + + // Preload is inserted after fonts and CSS + expect( + $( + `link[rel=stylesheet][href^="/_next/static/css"] ~ link[rel=preload][href="${src}"]` ).length - ).toBeTruthy() + ).toBeGreaterThan(0) - // Preload is inserted after fonts and CSS - expect( - $( - `link[rel=stylesheet][href^="/_next/static/css"] ~ link[rel=preload][href="${src}"]` - ).length - ).toBeGreaterThan(0) + // Script is inserted before NextScripts + expect( + $(`#__NEXT_DATA__ ~ #${id} ~ script[src^="/_next/static/chunks/main"]`) + .length + ).toBeGreaterThan(0) + } - // Script is inserted before NextScripts - expect( - $('#__NEXT_DATA__ ~ #script ~ 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')