Skip to content

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

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

Merged
merged 2 commits into from
Aug 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 28 additions & 38 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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>()
Expand Down Expand Up @@ -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={{
Expand Down Expand Up @@ -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],
},
})

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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],
},
})

Expand Down
56 changes: 56 additions & 0 deletions packages/next/src/server/app-render/required-scripts.tsx
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

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

Nit pick: since this is used four times, you could keep it DRY with a shared function:

const getSrc = (i: number) => `${assetPrefix}/_next/` + files[i] + qs

Then you can getSrc(0) or getSrc(i) later

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We don't bundle the server code all the time yet (I think) and I'm not sure what level of optimization we will end up applying to our bundle when we do. Absent an optimizing step that would inline these function calls it's more performant to just inline the string concatenation.

I realize this is a drop in the bucket compared to overhead in the rest of Next but I think in the long run we're going to want to start caring more about good perf by default and in aggregate little things like this will add up eventually.

There are a couple reasons this would be slower, first a function call itself has overhead. But second the allocation of a new function to create the closure for assetPrefix and qs. There's obviously a tradeoff (getRequireScripts itself is a function that won't be inlined) but since this repeated string concat is clear in it's intention and self contained I think the repetition is not particularly taxing mental cost and it's not a place we're likely to mistakenly update in a broken way in the future.

I could make a getSrc(prefix, filename, querystring) function that is only allocated once for the module but then it's not really saving much character-wise and still adds a function call overhead so I think it's good how it is

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]
}
8 changes: 8 additions & 0 deletions test/e2e/app-dir/app/app/bootstrap/page.js
Original file line number Diff line number Diff line change
@@ -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>
)
}
11 changes: 11 additions & 0 deletions test/e2e/app-dir/app/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
}
)