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