From e048a2f9122aedae14d0351a2105ec022133c98e Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 8 Aug 2023 10:11:41 -0700 Subject: [PATCH] Currently all scripts that are required for every page are loaded as part of the bootstrap scripts API in React. Unfortunately this loads them all as sync scripts and thus requires preloading which increases their priority higher than they might otherwise be causing things like images to load later than desired, blocking paint. We can improve this by only using one script for bootstrapping and having the rest pre-initialized. This only works because all of these scripts are webpack runtime or chunks and can be loaded in any order asynchronously. With this change we should see improvements in LCP and other metrics as preloads for images are favored over loading scripts --- .../next/src/server/app-render/app-render.tsx | 66 ++++++++----------- .../server/app-render/required-scripts.tsx | 56 ++++++++++++++++ test/e2e/app-dir/app/app/bootstrap/page.js | 8 +++ test/e2e/app-dir/app/index.test.ts | 11 ++++ 4 files changed, 103 insertions(+), 38 deletions(-) create mode 100644 packages/next/src/server/app-render/required-scripts.tsx create mode 100644 test/e2e/app-dir/app/app/bootstrap/page.js diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index cddec860eab1e..bd25eb2a5f83a 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -78,6 +78,7 @@ import { appendMutableCookies } from '../web/spec-extension/adapters/request-coo import { ComponentsType } from '../../build/webpack/loaders/next-app-loader' import { ModuleReference } from '../../build/webpack/loaders/metadata/types' import { createServerInsertedHTML } from './server-inserted-html' +import { getRequiredScripts } from './required-scripts' export type GetDynamicParamFromSegment = ( // [slug] / [[slug]] / [...slug] @@ -1387,11 +1388,15 @@ export async function renderToHTMLOrFlight( * A new React Component that renders the provided React Component * using Flight which can then be rendered to HTML. */ - const createServerComponentsRenderer = (loaderTreeToRender: LoaderTree) => + const createServerComponentsRenderer = ( + loaderTreeToRender: LoaderTree, + preinitScripts: () => void + ) => createServerComponentRenderer<{ asNotFound: boolean }>( async (props) => { + preinitScripts() // Create full component tree from root to leaf. const injectedCSS = new Set() const injectedFontPreloadTags = new Set() @@ -1490,7 +1495,16 @@ export async function renderToHTMLOrFlight( integrity: subresourceIntegrityManifest?.[polyfill], })) - const ServerComponentsRenderer = createServerComponentsRenderer(tree) + const [preinitScripts, bootstrapScript] = getRequiredScripts( + buildManifest, + assetPrefix, + subresourceIntegrityManifest, + getAssetQueryString(true) + ) + const ServerComponentsRenderer = createServerComponentsRenderer( + tree, + preinitScripts + ) const content = ( ({ - src: - `${assetPrefix}/_next/` + - src + - // Always include the timestamp query in development - // as Safari caches them during the same session, no - // matter what cache headers are set. - getAssetQueryString(true), - integrity: subresourceIntegrityManifest[src], - })) - : buildManifest.rootMainFiles.map( - (src) => - `${assetPrefix}/_next/` + - src + - // Always include the timestamp query in development - // as Safari caches them during the same session, no - // matter what cache headers are set. - getAssetQueryString(true) - )), - ], + bootstrapScripts: [bootstrapScript], }, }) @@ -1680,8 +1673,18 @@ export async function renderToHTMLOrFlight( )} ) + + const [errorPreinitScripts, errorBootstrapScript] = + getRequiredScripts( + buildManifest, + assetPrefix, + subresourceIntegrityManifest, + getAssetQueryString(false) + ) + const ErrorPage = createServerComponentRenderer( async () => { + errorPreinitScripts() const [MetadataTree, MetadataOutlet] = createMetadataComponents({ tree, // still use original tree with not-found boundaries to extract metadata pathname, @@ -1762,20 +1765,7 @@ export async function renderToHTMLOrFlight( streamOptions: { nonce, // Include hydration scripts in the HTML - bootstrapScripts: subresourceIntegrityManifest - ? buildManifest.rootMainFiles.map((src) => ({ - src: - `${assetPrefix}/_next/` + - src + - getAssetQueryString(false), - integrity: subresourceIntegrityManifest[src], - })) - : buildManifest.rootMainFiles.map( - (src) => - `${assetPrefix}/_next/` + - src + - getAssetQueryString(false) - ), + bootstrapScripts: [errorBootstrapScript], }, }) diff --git a/packages/next/src/server/app-render/required-scripts.tsx b/packages/next/src/server/app-render/required-scripts.tsx new file mode 100644 index 0000000000000..8faa76b060a33 --- /dev/null +++ b/packages/next/src/server/app-render/required-scripts.tsx @@ -0,0 +1,56 @@ +import type { BuildManifest } from '../get-page-files' + +import ReactDOM from 'react-dom' + +export function getRequiredScripts( + buildManifest: BuildManifest, + assetPrefix: string, + SRIManifest: undefined | Record, + qs: string +): [() => void, string | { src: string; integrity: string }] { + let preinitScripts: () => void + let preinitScriptCommands: string[] = [] + let bootstrapScript: string | { src: string; integrity: string } = '' + const files = buildManifest.rootMainFiles + if (files.length === 0) { + throw new Error( + 'Invariant: missing bootstrap script. This is a bug in Next.js' + ) + } + if (SRIManifest) { + bootstrapScript = { + src: `${assetPrefix}/_next/` + files[0] + qs, + integrity: SRIManifest[files[0]], + } + for (let i = 1; i < files.length; i++) { + const src = `${assetPrefix}/_next/` + files[i] + qs + const integrity = SRIManifest[files[i]] + preinitScriptCommands.push(src, integrity) + } + preinitScripts = () => { + // preinitScriptCommands is a double indexed array of src/integrity pairs + for (let i = 0; i < preinitScriptCommands.length; i += 2) { + ReactDOM.preinit(preinitScriptCommands[i], { + as: 'script', + integrity: preinitScriptCommands[i + 1], + }) + } + } + } else { + bootstrapScript = `${assetPrefix}/_next/` + files[0] + qs + for (let i = 1; i < files.length; i++) { + const src = `${assetPrefix}/_next/` + files[i] + qs + preinitScriptCommands.push(src) + } + preinitScripts = () => { + // preinitScriptCommands is a singled indexed array of src values + for (let i = 0; i < preinitScriptCommands.length; i++) { + ReactDOM.preinit(preinitScriptCommands[i], { + as: 'script', + }) + } + } + } + + return [preinitScripts, bootstrapScript] +} diff --git a/test/e2e/app-dir/app/app/bootstrap/page.js b/test/e2e/app-dir/app/app/bootstrap/page.js new file mode 100644 index 0000000000000..d83b335ed8698 --- /dev/null +++ b/test/e2e/app-dir/app/app/bootstrap/page.js @@ -0,0 +1,8 @@ +export default async function Page() { + return ( +
+ This fixture is to assert where the bootstrap scripts and other required + scripts emit during SSR +
+ ) +} diff --git a/test/e2e/app-dir/app/index.test.ts b/test/e2e/app-dir/app/index.test.ts index 66e77aad46ff6..735656550c9f7 100644 --- a/test/e2e/app-dir/app/index.test.ts +++ b/test/e2e/app-dir/app/index.test.ts @@ -1859,5 +1859,16 @@ createNextDescribe( expect(await browser.elementByCss('p').text()).toBe('item count 128000') }) }) + + describe('bootstrap scripts', () => { + it('should only bootstrap with one script, prinitializing the rest', async () => { + const html = await next.render('/bootstrap') + const $ = cheerio.load(html) + + // We assume a minimum of 2 scripts, webpack runtime + main-app + expect($('script[async]').length).toBeGreaterThan(1) + expect($('body').find('script[async]').length).toBe(1) + }) + }) } )