App Router Preinitialize all required scripts except one for bootstrap (#53705)

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

Co-authored-by: Steven <steven@ceriously.com>
This commit is contained in:
Josh Story 2023-08-08 17:28:17 -07:00 committed by GitHub
parent d58fd68f0a
commit 79b7c1493b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 103 additions and 38 deletions

View file

@ -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<string>()
const injectedFontPreloadTags = new Set<string>()
@ -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 = (
<HeadManagerContext.Provider
value={{
@ -1576,28 +1590,7 @@ export async function renderToHTMLOrFlight(
onError: htmlRendererErrorHandler,
nonce,
// Include hydration scripts in the HTML
bootstrapScripts: [
...(subresourceIntegrityManifest
? buildManifest.rootMainFiles.map((src) => ({
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],
},
})

View file

@ -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<string, string>,
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]
}

View file

@ -0,0 +1,8 @@
export default async function Page() {
return (
<div>
This fixture is to assert where the bootstrap scripts and other required
scripts emit during SSR
</div>
)
}

View file

@ -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)
})
})
}
)