Skip to content

Commit

Permalink
Load beforeInteractive scripts properly without blocking hydration (v…
Browse files Browse the repository at this point in the history
…ercel#41164)

This PR ensures that for the app directory, `beforeInteractive`, `afterInteractive` and `lazyOnload` scripts via `next/script` are properly supported.

For both `beforeInteractive` and `afterInteractive` scripts, a preload link tag needs to be injected by Float. For `beforeInteractive` scripts and Next.js' polyfills, they need to be manually executed in order before starting the Next.js' runtime, without blocking the downloading of HTML and other scripts.

This PR doesn't include the `worker` type of scripts yet.

Note: in this PR I changed the inlined flight data `__next_s` to `__next_f`, and use `__next_s` for scripts data, because I can't find a better name for `next/script` that is also short at the same time.

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
  • Loading branch information
shuding authored and Kikobeats committed Oct 24, 2022
1 parent 54f8c65 commit 20e302d
Show file tree
Hide file tree
Showing 16 changed files with 286 additions and 62 deletions.
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

0 comments on commit 20e302d

Please sign in to comment.