e933e1d211
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)
200 lines
5.9 KiB
TypeScript
200 lines
5.9 KiB
TypeScript
/* global location */
|
|
import '../build/polyfills/polyfill-module'
|
|
// @ts-ignore react-dom/client exists when using React 18
|
|
import ReactDOMClient from 'react-dom/client'
|
|
// TODO-APP: change to React.use once it becomes stable
|
|
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" />
|
|
|
|
// Override chunk URL mapping in the webpack runtime
|
|
// https://github.com/webpack/webpack/blob/2738eebc7880835d88c727d364ad37f3ec557593/lib/RuntimeGlobals.js#L204
|
|
|
|
declare global {
|
|
const __webpack_require__: any
|
|
}
|
|
|
|
// eslint-disable-next-line no-undef
|
|
const getChunkScriptFilename = __webpack_require__.u
|
|
const chunkFilenameMap: any = {}
|
|
|
|
// eslint-disable-next-line no-undef
|
|
__webpack_require__.u = (chunkId: any) => {
|
|
return chunkFilenameMap[chunkId] || getChunkScriptFilename(chunkId)
|
|
}
|
|
|
|
// Ignore the module ID transform in client.
|
|
// eslint-disable-next-line no-undef
|
|
// @ts-expect-error TODO: fix type
|
|
self.__next_require__ = __webpack_require__
|
|
|
|
// eslint-disable-next-line no-undef
|
|
;(self as any).__next_chunk_load__ = (chunk: string) => {
|
|
if (!chunk) return Promise.resolve()
|
|
const [chunkId, chunkFileName] = chunk.split(':')
|
|
chunkFilenameMap[chunkId] = `static/chunks/${chunkFileName}.js`
|
|
|
|
// @ts-ignore
|
|
// eslint-disable-next-line no-undef
|
|
return __webpack_chunk_load__(chunkId)
|
|
}
|
|
|
|
const appElement: HTMLElement | Document | null = document
|
|
|
|
const getCacheKey = () => {
|
|
const { pathname, search } = location
|
|
return pathname + search
|
|
}
|
|
|
|
const encoder = new TextEncoder()
|
|
|
|
let initialServerDataBuffer: string[] | undefined = undefined
|
|
let initialServerDataWriter: ReadableStreamDefaultController | undefined =
|
|
undefined
|
|
let initialServerDataLoaded = false
|
|
let initialServerDataFlushed = false
|
|
|
|
function nextServerDataCallback(
|
|
seg: [isBootStrap: 0] | [isNotBootstrap: 1, responsePartial: string]
|
|
): void {
|
|
if (seg[0] === 0) {
|
|
initialServerDataBuffer = []
|
|
} else {
|
|
if (!initialServerDataBuffer)
|
|
throw new Error('Unexpected server data: missing bootstrap script.')
|
|
|
|
if (initialServerDataWriter) {
|
|
initialServerDataWriter.enqueue(encoder.encode(seg[1]))
|
|
} else {
|
|
initialServerDataBuffer.push(seg[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
// There might be race conditions between `nextServerDataRegisterWriter` and
|
|
// `DOMContentLoaded`. The former will be called when React starts to hydrate
|
|
// the root, the latter will be called when the DOM is fully loaded.
|
|
// For streaming, the former is called first due to partial hydration.
|
|
// For non-streaming, the latter can be called first.
|
|
// Hence, we use two variables `initialServerDataLoaded` and
|
|
// `initialServerDataFlushed` to make sure the writer will be closed and
|
|
// `initialServerDataBuffer` will be cleared in the right time.
|
|
function nextServerDataRegisterWriter(ctr: ReadableStreamDefaultController) {
|
|
if (initialServerDataBuffer) {
|
|
initialServerDataBuffer.forEach((val) => {
|
|
ctr.enqueue(encoder.encode(val))
|
|
})
|
|
if (initialServerDataLoaded && !initialServerDataFlushed) {
|
|
ctr.close()
|
|
initialServerDataFlushed = true
|
|
initialServerDataBuffer = undefined
|
|
}
|
|
}
|
|
|
|
initialServerDataWriter = ctr
|
|
}
|
|
|
|
// When `DOMContentLoaded`, we can close all pending writers to finish hydration.
|
|
const DOMContentLoaded = function () {
|
|
if (initialServerDataWriter && !initialServerDataFlushed) {
|
|
initialServerDataWriter.close()
|
|
initialServerDataFlushed = true
|
|
initialServerDataBuffer = undefined
|
|
}
|
|
initialServerDataLoaded = true
|
|
}
|
|
// It's possible that the DOM is already loaded.
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', DOMContentLoaded, false)
|
|
} else {
|
|
DOMContentLoaded()
|
|
}
|
|
|
|
const nextServerDataLoadingGlobal = ((self as any).__next_f =
|
|
(self as any).__next_f || [])
|
|
nextServerDataLoadingGlobal.forEach(nextServerDataCallback)
|
|
nextServerDataLoadingGlobal.push = nextServerDataCallback
|
|
|
|
function createResponseCache() {
|
|
return new Map<string, any>()
|
|
}
|
|
const rscCache = createResponseCache()
|
|
|
|
function useInitialServerResponse(cacheKey: string): Promise<JSX.Element> {
|
|
const response = rscCache.get(cacheKey)
|
|
if (response) return response
|
|
|
|
const readable = new ReadableStream({
|
|
start(controller) {
|
|
nextServerDataRegisterWriter(controller)
|
|
},
|
|
})
|
|
|
|
const newResponse = createFromReadableStream(readable)
|
|
|
|
rscCache.set(cacheKey, newResponse)
|
|
return newResponse
|
|
}
|
|
|
|
function ServerRoot({ cacheKey }: { cacheKey: string }): JSX.Element {
|
|
React.useEffect(() => {
|
|
rscCache.delete(cacheKey)
|
|
})
|
|
const response = useInitialServerResponse(cacheKey)
|
|
const root = use(response)
|
|
return root
|
|
}
|
|
|
|
function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement {
|
|
React.useEffect(() => {
|
|
measureWebVitals()
|
|
}, [])
|
|
|
|
if (process.env.__NEXT_TEST_MODE) {
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
React.useEffect(() => {
|
|
window.__NEXT_HYDRATED = true
|
|
|
|
if (window.__NEXT_HYDRATED_CB) {
|
|
window.__NEXT_HYDRATED_CB()
|
|
}
|
|
}, [])
|
|
}
|
|
|
|
return children as React.ReactElement
|
|
}
|
|
|
|
function RSCComponent(props: any): JSX.Element {
|
|
const cacheKey = getCacheKey()
|
|
return <ServerRoot {...props} cacheKey={cacheKey} />
|
|
}
|
|
|
|
export function hydrate() {
|
|
const reactEl = (
|
|
<React.StrictMode>
|
|
<HeadManagerContext.Provider
|
|
value={{
|
|
appDir: true,
|
|
}}
|
|
>
|
|
<Root>
|
|
<RSCComponent />
|
|
</Root>
|
|
</HeadManagerContext.Provider>
|
|
</React.StrictMode>
|
|
)
|
|
|
|
const isError = document.documentElement.id === '__next_error__'
|
|
const reactRoot = isError
|
|
? (ReactDOMClient as any).createRoot(appElement)
|
|
: (React as any).startTransition(() =>
|
|
(ReactDOMClient as any).hydrateRoot(appElement, reactEl)
|
|
)
|
|
if (isError) {
|
|
reactRoot.render(reactEl)
|
|
}
|
|
}
|