Skip to content
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

Load beforeInteractive scripts properly without blocking hydration #41164

Merged
merged 16 commits into from Oct 9, 2022
61 changes: 61 additions & 0 deletions packages/next/client/app-bootstrap.js
@@ -0,0 +1,61 @@
/**
* Before starting the Next.js runtime and requiring any module, we need to make
* sure the following scripts are executed in the correct order:
* - Polyfills
* - next/script with `beforeInteractive` strategy
*/

const version = process.env.__NEXT_VERSION

window.next = {
version,
appDir: true,
}

function loadScriptsInSequence(scripts, hydrate) {
if (!scripts || !scripts.length) {
return hydrate()
}

return scripts
.reduce((promise, [src, props]) => {
return promise.then(() => {
return new Promise((resolve, reject) => {
const el = document.createElement('script')

if (props) {
for (const key in props) {
if (key !== 'children') {
el.setAttribute(key, props[key])
}
}
}

if (src) {
el.src = src
el.onload = resolve
el.onerror = reject
} else if (props) {
el.innerHTML = props.children
setTimeout(resolve)
}

document.head.appendChild(el)
})
})
}, Promise.resolve())
.then(() => {
hydrate()
})
.catch((err) => {
console.error(err)
// Still try to hydrate even if there's an error.
hydrate()
})
}

export function appBootstrap(callback) {
loadScriptsInSequence(self.__next_s, () => {
callback()
})
}
19 changes: 12 additions & 7 deletions packages/next/client/app-index.tsx
Expand Up @@ -7,6 +7,7 @@ import React, { experimental_use as use } from 'react'
import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack'

import measureWebVitals from './performance-relayer'
import { HeadManagerContext } from '../shared/lib/head-manager-context'

/// <reference types="react-dom/experimental" />

Expand Down Expand Up @@ -42,8 +43,6 @@ self.__next_require__ = __webpack_require__
return __webpack_chunk_load__(chunkId)
}

export const version = process.env.__NEXT_VERSION

const appElement: HTMLElement | Document | null = document

const getCacheKey = () => {
Expand Down Expand Up @@ -115,8 +114,8 @@ if (document.readyState === 'loading') {
DOMContentLoaded()
}

const nextServerDataLoadingGlobal = ((self as any).__next_s =
(self as any).__next_s || [])
const nextServerDataLoadingGlobal = ((self as any).__next_f =
(self as any).__next_f || [])
nextServerDataLoadingGlobal.forEach(nextServerDataCallback)
nextServerDataLoadingGlobal.push = nextServerDataCallback

Expand Down Expand Up @@ -177,9 +176,15 @@ function RSCComponent(props: any): JSX.Element {
export function hydrate() {
const reactEl = (
<React.StrictMode>
<Root>
<RSCComponent />
</Root>
<HeadManagerContext.Provider
value={{
appDir: true,
}}
>
<Root>
<RSCComponent />
</Root>
</HeadManagerContext.Provider>
</React.StrictMode>
)

Expand Down
12 changes: 5 additions & 7 deletions packages/next/client/app-next-dev.js
@@ -1,12 +1,10 @@
import { hydrate, version } from './app-index'

// TODO-APP: hydration warning

window.next = {
version,
appDir: true,
}
import { appBootstrap } from './app-bootstrap'

hydrate()
appBootstrap(() => {
const { hydrate } = require('./app-index')
hydrate()
})

// TODO-APP: build indicator
18 changes: 8 additions & 10 deletions packages/next/client/app-next.js
@@ -1,12 +1,10 @@
import { hydrate, version } from './app-index'
import { appBootstrap } from './app-bootstrap'

// Include app-router and layout-router in the main chunk
import 'next/dist/client/components/app-router.client.js'
import 'next/dist/client/components/layout-router.client.js'
appBootstrap(() => {
// Include app-router and layout-router in the main chunk
import('next/dist/client/components/app-router.client.js')
import('next/dist/client/components/layout-router.client.js')

window.next = {
version,
appDir: true,
}

hydrate()
const { hydrate } = require('./app-index')
hydrate()
})
59 changes: 58 additions & 1 deletion packages/next/client/script.tsx
@@ -1,5 +1,6 @@
'client'

import ReactDOM from 'react-dom'
import React, { useEffect, useContext, useRef } from 'react'
import { ScriptHTMLAttributes } from 'react'
import { HeadManagerContext } from '../shared/lib/head-manager-context'
Expand Down Expand Up @@ -177,7 +178,8 @@ function Script(props: ScriptProps): JSX.Element | null {
} = props

// Context is available only during SSR
const { updateScripts, scripts, getIsSsr } = useContext(HeadManagerContext)
const { updateScripts, scripts, getIsSsr, appDir, nonce } =
useContext(HeadManagerContext)

/**
* - First mount:
Expand Down Expand Up @@ -254,6 +256,61 @@ function Script(props: ScriptProps): JSX.Element | null {
}
}

// For the app directory, we need React Float to preload these scripts.
if (appDir) {
// Before interactive scripts need to be loaded by Next.js' runtime instead
// of native <script> tags, becasue they no longer have `defer`.
if (strategy === 'beforeInteractive') {
if (!src) {
// For inlined scripts, we put the content in `children`.
if (restProps.dangerouslySetInnerHTML) {
restProps.children = restProps.dangerouslySetInnerHTML.__html
delete restProps.dangerouslySetInnerHTML
}

return (
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `(self.__next_s=self.__next_s||[]).push(${JSON.stringify([
0,
{ ...restProps },
])})`,
}}
/>
)
}

// @ts-ignore
ReactDOM.preload(
src,
restProps.integrity
? { as: 'script', integrity: restProps.integrity }
: { as: 'script' }
)
return (
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `(self.__next_s=self.__next_s||[]).push(${JSON.stringify([
src,
])})`,
}}
/>
)
} else if (strategy === 'afterInteractive') {
if (src) {
// @ts-ignore
ReactDOM.preload(
src,
restProps.integrity
? { as: 'script', integrity: restProps.integrity }
: { as: 'script' }
)
}
}
}

return null
}

Expand Down
62 changes: 45 additions & 17 deletions packages/next/server/app-render.tsx
Expand Up @@ -34,6 +34,7 @@ import { REDIRECT_ERROR_CODE } from '../client/components/redirect'
import { NextCookies } from './web/spec-extension/cookies'
import { DYNAMIC_ERROR_CODE } from '../client/components/hooks-server-context'
import { NOT_FOUND_ERROR_CODE } from '../client/components/not-found'
import { HeadManagerContext } from '../shared/lib/head-manager-context'
import { Writable } from 'stream'

const INTERNAL_HEADERS_INSTANCE = Symbol('internal for headers readonly')
Expand Down Expand Up @@ -287,7 +288,7 @@ function useFlightResponse(
bootstrapped = true
writer.write(
encodeText(
`${startScriptTag}(self.__next_s=self.__next_s||[]).push(${htmlEscapeJsonString(
`${startScriptTag}(self.__next_f=self.__next_f||[]).push(${htmlEscapeJsonString(
JSON.stringify([0])
)})</script>`
)
Expand All @@ -298,7 +299,7 @@ function useFlightResponse(
writer.close()
} else {
const responsePartial = decodeText(value)
const scripts = `${startScriptTag}(self.__next_s=self.__next_s||[]).push(${htmlEscapeJsonString(
const scripts = `${startScriptTag}self.__next_f.push(${htmlEscapeJsonString(
JSON.stringify([1, responsePartial])
)})</script>`

Expand Down Expand Up @@ -1396,40 +1397,69 @@ export async function renderToHTMLOrFlight(
)

return (
<ServerInsertedHTMLContext.Provider value={addInsertedHtml}>
{children}
</ServerInsertedHTMLContext.Provider>
<HeadManagerContext.Provider
value={{
appDir: true,
nonce,
}}
>
<ServerInsertedHTMLContext.Provider value={addInsertedHtml}>
{children}
</ServerInsertedHTMLContext.Provider>
</HeadManagerContext.Provider>
)
}

const bodyResult = async () => {
const polyfills = buildManifest.polyfillFiles
.filter(
(polyfill) =>
polyfill.endsWith('.js') && !polyfill.endsWith('.module.js')
)
.map((polyfill) => ({
src: `${renderOpts.assetPrefix || ''}/_next/${polyfill}`,
integrity: subresourceIntegrityManifest?.[polyfill],
}))

const content = (
<InsertedHTML>
<ServerComponentsRenderer />
</InsertedHTML>
)

let polyfillsFlushed = false
const getServerInsertedHTML = (): Promise<string> => {
const flushed = renderToString(
<>
{Array.from(serverInsertedHTMLCallbacks).map((callback) =>
callback()
)}
{polyfillsFlushed
? null
: polyfills?.map((polyfill) => {
return (
<script
key={polyfill.src}
noModule={true}
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `(self.__next_s=self.__next_s||[]).push([${JSON.stringify(
polyfill.src
)},${
polyfill.integrity
? JSON.stringify({ integrity: polyfill.integrity })
: '{}'
}])`,
}}
/>
)
})}
</>
)
polyfillsFlushed = true
return flushed
}

const polyfills = buildManifest.polyfillFiles
.filter(
(polyfill) =>
polyfill.endsWith('.js') && !polyfill.endsWith('.module.js')
)
.map((polyfill) => ({
src: `${renderOpts.assetPrefix || ''}/_next/${polyfill}`,
integrity: subresourceIntegrityManifest?.[polyfill],
}))

try {
const renderStream = await renderToInitialStream({
ReactDOMServer,
Expand All @@ -1456,7 +1486,6 @@ export async function renderToHTMLOrFlight(
generateStaticHTML: isStaticGeneration,
getServerInsertedHTML,
serverInsertedHTMLToHead: true,
polyfills,
dev,
})
} catch (err: any) {
Expand Down Expand Up @@ -1488,7 +1517,6 @@ export async function renderToHTMLOrFlight(
generateStaticHTML: isStaticGeneration,
getServerInsertedHTML,
serverInsertedHTMLToHead: true,
polyfills,
dev,
})
}
Expand Down
17 changes: 1 addition & 16 deletions packages/next/server/node-web-streams-helper.ts
Expand Up @@ -306,15 +306,13 @@ export async function continueFromInitialStream(
generateStaticHTML,
getServerInsertedHTML,
serverInsertedHTMLToHead,
polyfills,
}: {
dev?: boolean
suffix?: string
dataStream?: ReadableStream<Uint8Array>
generateStaticHTML: boolean
getServerInsertedHTML?: () => Promise<string>
serverInsertedHTMLToHead: boolean
polyfills?: { src: string; integrity: string | undefined }[]
}
): Promise<ReadableStream<Uint8Array>> {
const closeTag = '</body></html>'
Expand All @@ -333,26 +331,13 @@ export async function continueFromInitialStream(
dataStream ? createInlineDataStream(dataStream) : null,
suffixUnclosed != null ? createSuffixStream(closeTag) : null,
createHeadInjectionTransformStream(async () => {
// Inject polyfills for browsers that don't support modules. It has to be
// blocking here and can't be `defer` because other scripts have `async`.
const polyfillScripts = polyfills
? polyfills
.map(
({ src, integrity }) =>
`<script src="${src}" nomodule=""${
integrity ? ` integrity="${integrity}"` : ''
}></script>`
)
.join('')
: ''

// TODO-APP: Insert server side html to end of head in app layout rendering, to avoid
// hydration errors. Remove this once it's ready to be handled by react itself.
const serverInsertedHTML =
getServerInsertedHTML && serverInsertedHTMLToHead
? await getServerInsertedHTML()
: ''
return polyfillScripts + serverInsertedHTML
return serverInsertedHTML
}),
dev ? createRootLayoutValidatorStream() : null,
].filter(nonNullable)
Expand Down