Initial implementation of statically optimized flight data of server component pages (#35619)
Part of #31506 and https://github.com/vercel/next.js/discussions/34179. This PR ensures that in the `nodejs` runtime, the flight data is statically stored as a JSON file if possible. Most of the touched code is related to conditions of static/SSG/SSR when runtime and/or RSC is involved. ## Bug - [ ] Related issues linked using `fixes #number` - [x] Integration tests added - [ ] Errors have 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` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `yarn lint`
This commit is contained in:
parent
bcd2aa5c12
commit
0eb9f7e76d
15 changed files with 450 additions and 88 deletions
|
@ -99,6 +99,7 @@ import {
|
|||
copyTracedFiles,
|
||||
isReservedPage,
|
||||
isCustomErrorPage,
|
||||
isFlightPage,
|
||||
} from './utils'
|
||||
import getBaseWebpackConfig from './webpack-config'
|
||||
import { PagesManifest } from './webpack/plugins/pages-manifest-plugin'
|
||||
|
@ -162,7 +163,6 @@ export default async function build(
|
|||
// using React 18 or experimental.
|
||||
const hasReactRoot = shouldUseReactRoot()
|
||||
const hasConcurrentFeatures = hasReactRoot
|
||||
|
||||
const hasServerComponents =
|
||||
hasReactRoot && !!config.experimental.serverComponents
|
||||
|
||||
|
@ -288,6 +288,7 @@ export default async function build(
|
|||
.traceAsyncFn(() => collectPages(pagesDir, config.pageExtensions))
|
||||
// needed for static exporting since we want to replace with HTML
|
||||
// files
|
||||
|
||||
const allStaticPages = new Set<string>()
|
||||
let allPageInfos = new Map<string, PageInfo>()
|
||||
|
||||
|
@ -963,6 +964,7 @@ export default async function build(
|
|||
|
||||
let isSsg = false
|
||||
let isStatic = false
|
||||
let isServerComponent = false
|
||||
let isHybridAmp = false
|
||||
let ssgPageRoutes: string[] | null = null
|
||||
let isMiddlewareRoute = !!page.match(MIDDLEWARE_ROUTE)
|
||||
|
@ -976,6 +978,12 @@ export default async function build(
|
|||
? await getPageRuntime(join(pagesDir, pagePath), config)
|
||||
: undefined
|
||||
|
||||
if (hasServerComponents && pagePath) {
|
||||
if (isFlightPage(config, pagePath)) {
|
||||
isServerComponent = true
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isMiddlewareRoute &&
|
||||
!isReservedPage(page) &&
|
||||
|
@ -1045,11 +1053,16 @@ export default async function build(
|
|||
serverPropsPages.add(page)
|
||||
} else if (
|
||||
workerResult.isStatic &&
|
||||
!workerResult.hasFlightData &&
|
||||
!isServerComponent &&
|
||||
(await customAppGetInitialPropsPromise) === false
|
||||
) {
|
||||
staticPages.add(page)
|
||||
isStatic = true
|
||||
} else if (isServerComponent) {
|
||||
// This is a static server component page that doesn't have
|
||||
// gSP or gSSP. We still treat it as a SSG page.
|
||||
ssgPages.add(page)
|
||||
isSsg = true
|
||||
}
|
||||
|
||||
if (hasPages404 && page === '/404') {
|
||||
|
|
|
@ -859,7 +859,6 @@ export async function isPageStatic(
|
|||
isStatic?: boolean
|
||||
isAmpOnly?: boolean
|
||||
isHybridAmp?: boolean
|
||||
hasFlightData?: boolean
|
||||
hasServerProps?: boolean
|
||||
hasStaticProps?: boolean
|
||||
prerenderRoutes?: string[]
|
||||
|
@ -882,7 +881,6 @@ export async function isPageStatic(
|
|||
throw new Error('INVALID_DEFAULT_EXPORT')
|
||||
}
|
||||
|
||||
const hasFlightData = !!(mod as any).__next_rsc__
|
||||
const hasGetInitialProps = !!(Comp as any).getInitialProps
|
||||
const hasStaticProps = !!mod.getStaticProps
|
||||
const hasStaticPaths = !!mod.getStaticPaths
|
||||
|
@ -970,11 +968,7 @@ export async function isPageStatic(
|
|||
const isNextImageImported = (global as any).__NEXT_IMAGE_IMPORTED
|
||||
const config: PageConfig = mod.pageConfig
|
||||
return {
|
||||
isStatic:
|
||||
!hasStaticProps &&
|
||||
!hasGetInitialProps &&
|
||||
!hasServerProps &&
|
||||
!hasFlightData,
|
||||
isStatic: !hasStaticProps && !hasGetInitialProps && !hasServerProps,
|
||||
isHybridAmp: config.amp === 'hybrid',
|
||||
isAmpOnly: config.amp === true,
|
||||
prerenderRoutes,
|
||||
|
@ -982,7 +976,6 @@ export async function isPageStatic(
|
|||
encodedPrerenderRoutes,
|
||||
hasStaticProps,
|
||||
hasServerProps,
|
||||
hasFlightData,
|
||||
isNextImageImported,
|
||||
traceIncludes: config.unstable_includeFiles || [],
|
||||
traceExcludes: config.unstable_excludeFiles || [],
|
||||
|
|
|
@ -41,6 +41,8 @@ async function parseModuleInfo({
|
|||
source: string
|
||||
imports: string
|
||||
isEsm: boolean
|
||||
__N_SSP: boolean
|
||||
pageRuntime: 'edge' | 'nodejs' | null
|
||||
}> {
|
||||
const ast = await parse(source, {
|
||||
filename: resourcePath,
|
||||
|
@ -50,12 +52,15 @@ async function parseModuleInfo({
|
|||
let transformedSource = ''
|
||||
let lastIndex = 0
|
||||
let imports = ''
|
||||
let __N_SSP = false
|
||||
let pageRuntime = null
|
||||
|
||||
const isEsm = type === 'Module'
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const node = body[i]
|
||||
switch (node.type) {
|
||||
case 'ImportDeclaration': {
|
||||
case 'ImportDeclaration':
|
||||
const importSource = node.source.value
|
||||
if (!isClientCompilation) {
|
||||
// Server compilation for .server.js.
|
||||
|
@ -112,7 +117,32 @@ async function parseModuleInfo({
|
|||
|
||||
lastIndex = node.source.span.end
|
||||
break
|
||||
}
|
||||
case 'ExportDeclaration':
|
||||
if (isClientCompilation) {
|
||||
// Keep `__N_SSG` and `__N_SSP` exports.
|
||||
if (node.declaration?.type === 'VariableDeclaration') {
|
||||
for (const declaration of node.declaration.declarations) {
|
||||
if (declaration.type === 'VariableDeclarator') {
|
||||
if (declaration.id?.type === 'Identifier') {
|
||||
const value = declaration.id.value
|
||||
if (value === '__N_SSP') {
|
||||
__N_SSP = true
|
||||
} else if (value === 'config') {
|
||||
const props = declaration.init.properties
|
||||
const runtimeKeyValue = props.find(
|
||||
(prop: any) => prop.key.value === 'runtime'
|
||||
)
|
||||
const runtime = runtimeKeyValue?.value?.value
|
||||
if (runtime === 'nodejs' || runtime === 'edge') {
|
||||
pageRuntime = runtime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
@ -122,7 +152,7 @@ async function parseModuleInfo({
|
|||
transformedSource += source.substring(lastIndex)
|
||||
}
|
||||
|
||||
return { source: transformedSource, imports, isEsm }
|
||||
return { source: transformedSource, imports, isEsm, __N_SSP, pageRuntime }
|
||||
}
|
||||
|
||||
export default async function transformSource(
|
||||
|
@ -161,6 +191,8 @@ export default async function transformSource(
|
|||
source: transformedSource,
|
||||
imports,
|
||||
isEsm,
|
||||
__N_SSP,
|
||||
pageRuntime,
|
||||
} = await parseModuleInfo({
|
||||
resourcePath,
|
||||
source,
|
||||
|
@ -190,7 +222,20 @@ export default async function transformSource(
|
|||
}
|
||||
|
||||
if (isClientCompilation) {
|
||||
rscExports['default'] = 'function RSC() {}'
|
||||
rscExports.default = 'function RSC() {}'
|
||||
|
||||
if (pageRuntime === 'edge') {
|
||||
// Currently for the Edge runtime, we treat all RSC pages as SSR pages.
|
||||
rscExports.__N_SSP = 'true'
|
||||
} else {
|
||||
if (__N_SSP) {
|
||||
rscExports.__N_SSP = 'true'
|
||||
} else {
|
||||
// Server component pages are always considered as SSG by default because
|
||||
// the flight data is needed for client navigation.
|
||||
rscExports.__N_SSG = 'true'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = transformedSource + '\n' + buildExports(rscExports, isEsm)
|
||||
|
|
|
@ -547,14 +547,17 @@ function renderReactElement(
|
|||
|
||||
const reactEl = fn(shouldHydrate ? markHydrateComplete : markRenderComplete)
|
||||
if (process.env.__NEXT_REACT_ROOT) {
|
||||
const ReactDOMClient = require('react-dom/client')
|
||||
if (!reactRoot) {
|
||||
// Unlike with createRoot, you don't need a separate root.render() call here
|
||||
reactRoot = (ReactDOMClient as any).hydrateRoot(domEl, reactEl)
|
||||
const ReactDOMClient = require('react-dom/client')
|
||||
reactRoot = ReactDOMClient.hydrateRoot(domEl, reactEl)
|
||||
// TODO: Remove shouldHydrate variable when React 18 is stable as it can depend on `reactRoot` existing
|
||||
shouldHydrate = false
|
||||
} else {
|
||||
reactRoot.render(reactEl)
|
||||
const startTransition = (React as any).startTransition
|
||||
startTransition(() => {
|
||||
reactRoot.render(reactEl)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// The check for `.hydrate` is there to support React alternatives like preact
|
||||
|
@ -675,6 +678,7 @@ if (process.env.__NEXT_RSC) {
|
|||
|
||||
const {
|
||||
createFromFetch,
|
||||
createFromReadableStream,
|
||||
} = require('next/dist/compiled/react-server-dom-webpack')
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
@ -769,20 +773,19 @@ if (process.env.__NEXT_RSC) {
|
|||
nextServerDataRegisterWriter(controller)
|
||||
},
|
||||
})
|
||||
response = createFromFetch(Promise.resolve({ body: readable }))
|
||||
response = createFromReadableStream(readable)
|
||||
} else {
|
||||
const fetchPromise = serialized
|
||||
? (() => {
|
||||
const readable = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(serialized))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return Promise.resolve({ body: readable })
|
||||
})()
|
||||
: fetchFlight(getCacheKey())
|
||||
response = createFromFetch(fetchPromise)
|
||||
if (serialized) {
|
||||
const readable = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(serialized))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
response = createFromReadableStream(readable)
|
||||
} else {
|
||||
response = createFromFetch(fetchFlight(getCacheKey()))
|
||||
}
|
||||
}
|
||||
|
||||
rscCache.set(cacheKey, response)
|
||||
|
@ -800,16 +803,16 @@ if (process.env.__NEXT_RSC) {
|
|||
rscCache.delete(cacheKey)
|
||||
})
|
||||
const response = useServerResponse(cacheKey, serialized)
|
||||
const root = response.readRoot()
|
||||
return root
|
||||
return response.readRoot()
|
||||
}
|
||||
|
||||
RSCComponent = (props: any) => {
|
||||
const cacheKey = getCacheKey()
|
||||
const { __flight_serialized__ } = props
|
||||
const { __flight__ } = props
|
||||
const [, dispatch] = useState({})
|
||||
const startTransition = (React as any).startTransition
|
||||
const rerender = () => dispatch({})
|
||||
|
||||
// If there is no cache, or there is serialized data already
|
||||
function refreshCache(nextProps?: any) {
|
||||
startTransition(() => {
|
||||
|
@ -825,7 +828,7 @@ if (process.env.__NEXT_RSC) {
|
|||
|
||||
return (
|
||||
<RefreshContext.Provider value={refreshCache}>
|
||||
<ServerRoot cacheKey={cacheKey} serialized={__flight_serialized__} />
|
||||
<ServerRoot cacheKey={cacheKey} serialized={__flight__} />
|
||||
</RefreshContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -133,13 +133,13 @@ export default class PageLoader {
|
|||
href,
|
||||
asPath,
|
||||
ssg,
|
||||
rsc,
|
||||
flight,
|
||||
locale,
|
||||
}: {
|
||||
href: string
|
||||
asPath: string
|
||||
ssg?: boolean
|
||||
rsc?: boolean
|
||||
flight?: boolean
|
||||
locale?: string | false
|
||||
}): string {
|
||||
const { pathname: hrefPathname, query, search } = parseRelativeUrl(href)
|
||||
|
@ -147,7 +147,7 @@ export default class PageLoader {
|
|||
const route = normalizeRoute(hrefPathname)
|
||||
|
||||
const getHrefForSlug = (path: string) => {
|
||||
if (rsc) {
|
||||
if (flight) {
|
||||
return path + search + (search ? `&` : '?') + '__flight__=1'
|
||||
}
|
||||
|
||||
|
|
|
@ -1124,14 +1124,25 @@ export default abstract class Server {
|
|||
const isLikeServerless =
|
||||
typeof components.ComponentMod === 'object' &&
|
||||
typeof (components.ComponentMod as any).renderReqToHTML === 'function'
|
||||
const isSSG = !!components.getStaticProps
|
||||
const hasServerProps = !!components.getServerSideProps
|
||||
const hasStaticPaths = !!components.getStaticPaths
|
||||
const hasGetInitialProps = !!components.Component?.getInitialProps
|
||||
const isServerComponent = !!components.ComponentMod?.__next_rsc__
|
||||
const isSSG =
|
||||
!!components.getStaticProps ||
|
||||
// For static server component pages, we currently always consider them
|
||||
// as SSG since we also need to handle the next data (flight JSON).
|
||||
(isServerComponent &&
|
||||
!hasServerProps &&
|
||||
!hasGetInitialProps &&
|
||||
!process.browser)
|
||||
|
||||
// Toggle whether or not this is a Data request
|
||||
const isDataReq = !!query._nextDataReq && (isSSG || hasServerProps)
|
||||
const isDataReq =
|
||||
!!query._nextDataReq && (isSSG || hasServerProps || isServerComponent)
|
||||
|
||||
delete query._nextDataReq
|
||||
|
||||
// Don't delete query.__flight__ yet, it still needs to be used in renderToHTML later
|
||||
const isFlightRequest = Boolean(
|
||||
this.serverComponentManifest && query.__flight__
|
||||
|
@ -1290,8 +1301,8 @@ export default abstract class Server {
|
|||
}
|
||||
|
||||
let ssgCacheKey =
|
||||
isPreviewMode || !isSSG || opts.supportsDynamicHTML
|
||||
? null // Preview mode and manual revalidate bypasses the cache
|
||||
isPreviewMode || !isSSG || opts.supportsDynamicHTML || isFlightRequest
|
||||
? null // Preview mode, manual revalidate, flight request can bypass the cache
|
||||
: `${locale ? `/${locale}` : ''}${
|
||||
(pathname === '/' || resolvedUrlPathname === '/') && locale
|
||||
? ''
|
||||
|
@ -1602,7 +1613,10 @@ export default abstract class Server {
|
|||
if (isDataReq) {
|
||||
return {
|
||||
type: 'json',
|
||||
body: RenderResult.fromStatic(JSON.stringify(cachedData.props)),
|
||||
body: RenderResult.fromStatic(
|
||||
// @TODO: Handle flight data.
|
||||
JSON.stringify(cachedData.props)
|
||||
),
|
||||
revalidateOptions,
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -680,6 +680,7 @@ export default class NextNodeServer extends BaseServer {
|
|||
_nextDataReq: query._nextDataReq,
|
||||
__nextLocale: query.__nextLocale,
|
||||
__nextDefaultLocale: query.__nextDefaultLocale,
|
||||
__flight__: query.__flight__,
|
||||
} as NextParsedUrlQuery)
|
||||
: query),
|
||||
...(params || {}),
|
||||
|
|
|
@ -310,12 +310,19 @@ function checkRedirectValues(
|
|||
const rscCache = new Map()
|
||||
|
||||
function createFlightHook() {
|
||||
return (
|
||||
writable: WritableStream<Uint8Array>,
|
||||
id: string,
|
||||
req: ReadableStream<Uint8Array>,
|
||||
return ({
|
||||
id,
|
||||
req,
|
||||
inlinedDataWritable,
|
||||
staticDataWritable,
|
||||
bootstrap,
|
||||
}: {
|
||||
id: string
|
||||
req: ReadableStream<Uint8Array>
|
||||
bootstrap: boolean
|
||||
) => {
|
||||
inlinedDataWritable: WritableStream<Uint8Array>
|
||||
staticDataWritable: WritableStream<Uint8Array> | null
|
||||
}) => {
|
||||
let entry = rscCache.get(id)
|
||||
if (!entry) {
|
||||
const [renderStream, forwardStream] = readableStreamTee(req)
|
||||
|
@ -323,13 +330,18 @@ function createFlightHook() {
|
|||
rscCache.set(id, entry)
|
||||
|
||||
let bootstrapped = false
|
||||
|
||||
const forwardReader = forwardStream.getReader()
|
||||
const writer = writable.getWriter()
|
||||
const inlinedDataWriter = inlinedDataWritable.getWriter()
|
||||
const staticDataWriter = staticDataWritable
|
||||
? staticDataWritable.getWriter()
|
||||
: null
|
||||
|
||||
function process() {
|
||||
forwardReader.read().then(({ done, value }) => {
|
||||
if (bootstrap && !bootstrapped) {
|
||||
bootstrapped = true
|
||||
writer.write(
|
||||
inlinedDataWriter.write(
|
||||
encodeText(
|
||||
`<script>(self.__next_s=self.__next_s||[]).push(${JSON.stringify(
|
||||
[0, id]
|
||||
|
@ -339,15 +351,21 @@ function createFlightHook() {
|
|||
}
|
||||
if (done) {
|
||||
rscCache.delete(id)
|
||||
writer.close()
|
||||
inlinedDataWriter.close()
|
||||
if (staticDataWriter) {
|
||||
staticDataWriter.close()
|
||||
}
|
||||
} else {
|
||||
writer.write(
|
||||
inlinedDataWriter.write(
|
||||
encodeText(
|
||||
`<script>(self.__next_s=self.__next_s||[]).push(${JSON.stringify(
|
||||
[1, id, decodeText(value)]
|
||||
)})</script>`
|
||||
)
|
||||
)
|
||||
if (staticDataWriter) {
|
||||
staticDataWriter.write(value)
|
||||
}
|
||||
process()
|
||||
}
|
||||
})
|
||||
|
@ -367,11 +385,13 @@ function createServerComponentRenderer(
|
|||
ComponentMod: any,
|
||||
{
|
||||
cachePrefix,
|
||||
transformStream,
|
||||
inlinedTransformStream,
|
||||
staticTransformStream,
|
||||
serverComponentManifest,
|
||||
}: {
|
||||
cachePrefix: string
|
||||
transformStream: TransformStream<Uint8Array, Uint8Array>
|
||||
inlinedTransformStream: TransformStream<Uint8Array, Uint8Array>
|
||||
staticTransformStream: null | TransformStream<Uint8Array, Uint8Array>
|
||||
serverComponentManifest: NonNullable<RenderOpts['serverComponentManifest']>
|
||||
}
|
||||
) {
|
||||
|
@ -380,7 +400,6 @@ function createServerComponentRenderer(
|
|||
// @ts-ignore
|
||||
globalThis.__webpack_require__ = ComponentMod.__next_rsc__.__webpack_require__
|
||||
|
||||
const writable = transformStream.writable
|
||||
const ServerComponentWrapper = (props: any) => {
|
||||
const id = (React as any).useId()
|
||||
const reqStream: ReadableStream<Uint8Array> = renderToReadableStream(
|
||||
|
@ -388,12 +407,16 @@ function createServerComponentRenderer(
|
|||
serverComponentManifest
|
||||
)
|
||||
|
||||
const response = useFlightResponse(
|
||||
writable,
|
||||
cachePrefix + ',' + id,
|
||||
reqStream,
|
||||
true
|
||||
)
|
||||
const response = useFlightResponse({
|
||||
id: cachePrefix + ',' + id,
|
||||
req: reqStream,
|
||||
inlinedDataWritable: inlinedTransformStream.writable,
|
||||
staticDataWritable: staticTransformStream
|
||||
? staticTransformStream.writable
|
||||
: null,
|
||||
bootstrap: true,
|
||||
})
|
||||
|
||||
const root = response.readRoot()
|
||||
rscCache.delete(id)
|
||||
return root
|
||||
|
@ -481,6 +504,11 @@ export async function renderToHTML(
|
|||
Uint8Array,
|
||||
Uint8Array
|
||||
> | null = null
|
||||
let serverComponentsPageDataTransformStream: TransformStream<
|
||||
Uint8Array,
|
||||
Uint8Array
|
||||
> | null =
|
||||
isServerComponent && !process.browser ? new TransformStream() : null
|
||||
|
||||
if (isServerComponent) {
|
||||
serverComponentsInlinedTransformStream = new TransformStream()
|
||||
|
@ -491,7 +519,8 @@ export async function renderToHTML(
|
|||
ComponentMod,
|
||||
{
|
||||
cachePrefix: pathname + (search ? `?${search}` : ''),
|
||||
transformStream: serverComponentsInlinedTransformStream,
|
||||
inlinedTransformStream: serverComponentsInlinedTransformStream,
|
||||
staticTransformStream: serverComponentsPageDataTransformStream,
|
||||
serverComponentManifest,
|
||||
}
|
||||
)
|
||||
|
@ -1169,7 +1198,11 @@ export async function renderToHTML(
|
|||
// Avoid rendering page un-necessarily for getServerSideProps data request
|
||||
// and getServerSideProps/getStaticProps redirects
|
||||
if ((isDataReq && !isSSG) || (renderOpts as any).isRedirect) {
|
||||
return RenderResult.fromStatic(JSON.stringify(props))
|
||||
// For server components, we still need to render the page to get the flight
|
||||
// data.
|
||||
if (!serverComponentsPageDataTransformStream) {
|
||||
return RenderResult.fromStatic(JSON.stringify(props))
|
||||
}
|
||||
}
|
||||
|
||||
// We don't call getStaticProps or getServerSideProps while generating
|
||||
|
@ -1187,16 +1220,17 @@ export async function renderToHTML(
|
|||
if (isResSent(res) && !isSSG) return null
|
||||
|
||||
if (renderServerComponentData) {
|
||||
const stream: ReadableStream<Uint8Array> = renderToReadableStream(
|
||||
renderFlight(AppMod, OriginalComponent, {
|
||||
...props.pageProps,
|
||||
...serverComponentProps,
|
||||
}),
|
||||
serverComponentManifest
|
||||
)
|
||||
|
||||
return new RenderResult(
|
||||
pipeThrough(stream, createBufferedTransformStream())
|
||||
pipeThrough(
|
||||
renderToReadableStream(
|
||||
renderFlight(AppMod, OriginalComponent, {
|
||||
...props.pageProps,
|
||||
...serverComponentProps,
|
||||
}),
|
||||
serverComponentManifest
|
||||
),
|
||||
createBufferedTransformStream()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1422,6 +1456,35 @@ export async function renderToHTML(
|
|||
return flushed
|
||||
}
|
||||
|
||||
// Handle static data for server components.
|
||||
async function generateStaticFlightDataIfNeeded() {
|
||||
if (serverComponentsPageDataTransformStream) {
|
||||
// If it's a server component with the Node.js runtime, we also
|
||||
// statically generate the page data.
|
||||
let data = ''
|
||||
const readable = serverComponentsPageDataTransformStream.readable
|
||||
const reader = readable.getReader()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
data += decodeText(value)
|
||||
}
|
||||
;(renderOpts as any).pageData = {
|
||||
...(renderOpts as any).pageData,
|
||||
__flight__: data,
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// @TODO: A potential improvement would be to reuse the inlined
|
||||
// data stream, or pass a callback inside as this doesn't need to
|
||||
// be streamed.
|
||||
// Do not use `await` here.
|
||||
generateStaticFlightDataIfNeeded()
|
||||
|
||||
return await continueFromInitialStream({
|
||||
renderStream,
|
||||
suffix,
|
||||
|
@ -1626,6 +1689,14 @@ export async function renderToHTML(
|
|||
await documentResult.bodyResult(renderTargetSuffix),
|
||||
]
|
||||
|
||||
if (
|
||||
serverComponentsPageDataTransformStream &&
|
||||
((isDataReq && !isSSG) || (renderOpts as any).isRedirect)
|
||||
) {
|
||||
await streamToString(streams[1])
|
||||
return RenderResult.fromStatic((renderOpts as any).pageData)
|
||||
}
|
||||
|
||||
const postProcessors: Array<((html: string) => Promise<string>) | null> = (
|
||||
generateStaticHTML
|
||||
? [
|
||||
|
|
|
@ -1546,18 +1546,25 @@ export default class Router implements BaseRouter {
|
|||
|
||||
let dataHref: string | undefined
|
||||
|
||||
// For server components, non-SSR pages will have statically optimized
|
||||
// flight data in a production build.
|
||||
// So only development and SSR pages will always have the real-time
|
||||
// generated and streamed flight data.
|
||||
const useStreamedFlightData =
|
||||
(process.env.NODE_ENV !== 'production' || __N_SSP) && __N_RSC
|
||||
|
||||
if (__N_SSG || __N_SSP || __N_RSC) {
|
||||
dataHref = this.pageLoader.getDataHref({
|
||||
href: formatWithValidation({ pathname, query }),
|
||||
asPath: resolvedAs,
|
||||
ssg: __N_SSG,
|
||||
rsc: __N_RSC,
|
||||
flight: useStreamedFlightData,
|
||||
locale,
|
||||
})
|
||||
}
|
||||
|
||||
const props = await this._getData<CompletePrivateRouteInfo>(() =>
|
||||
__N_SSG || __N_SSP
|
||||
(__N_SSG || __N_SSP || __N_RSC) && !useStreamedFlightData
|
||||
? fetchNextData(
|
||||
dataHref!,
|
||||
this.isSsr,
|
||||
|
@ -1580,13 +1587,23 @@ export default class Router implements BaseRouter {
|
|||
)
|
||||
|
||||
if (__N_RSC) {
|
||||
const { fresh, data } = (await this._getData(() =>
|
||||
this._getFlightData(dataHref!)
|
||||
)) as { fresh: boolean; data: string }
|
||||
;(props as any).pageProps = Object.assign((props as any).pageProps, {
|
||||
__flight_serialized__: data,
|
||||
__flight_fresh__: fresh,
|
||||
})
|
||||
if (useStreamedFlightData) {
|
||||
const { data } = (await this._getData(() =>
|
||||
this._getFlightData(dataHref!)
|
||||
)) as { data: string }
|
||||
;(props as any).pageProps = Object.assign((props as any).pageProps, {
|
||||
__flight__: data,
|
||||
})
|
||||
} else {
|
||||
const { __flight__ } = props as any
|
||||
;(props as any).pageProps = Object.assign(
|
||||
{},
|
||||
(props as any).pageProps,
|
||||
{
|
||||
__flight__,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
routeInfo.props = props
|
||||
|
@ -1851,7 +1868,7 @@ export default class Router implements BaseRouter {
|
|||
// Do not cache RSC flight response since it's not a static resource
|
||||
return fetchNextData(dataHref, true, true, this.sdc, false).then(
|
||||
(serialized) => {
|
||||
return { fresh: true, data: serialized }
|
||||
return { data: serialized }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import Runtime from '../utils/runtime'
|
||||
import Time from '../utils/time'
|
||||
|
||||
export default function Page({ type }) {
|
||||
return (
|
||||
<div>
|
||||
This is a {type} RSC page.
|
||||
<br />
|
||||
<Runtime />
|
||||
<br />
|
||||
<Time />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function getStaticProps() {
|
||||
return {
|
||||
props: {
|
||||
type: 'ISR',
|
||||
},
|
||||
revalidate: 3,
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
runtime: 'nodejs',
|
||||
}
|
|
@ -3,7 +3,7 @@ import Time from '../utils/time'
|
|||
|
||||
export default function Page({ type }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="node-rsc-ssg">
|
||||
This is a {type} RSC page.
|
||||
<br />
|
||||
<Runtime />
|
||||
|
|
|
@ -3,7 +3,7 @@ import Time from '../utils/time'
|
|||
|
||||
export default function Page({ type }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="node-rsc-ssr">
|
||||
This is a {type} RSC page.
|
||||
<br />
|
||||
<Runtime />
|
||||
|
|
|
@ -3,7 +3,7 @@ import Time from '../utils/time'
|
|||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<div className="node-rsc">
|
||||
This is a static RSC page.
|
||||
<br />
|
||||
<Runtime />
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import Runtime from '../utils/runtime'
|
||||
import Time from '../utils/time'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
|
@ -9,6 +11,30 @@ export default function Page() {
|
|||
<Runtime />
|
||||
<br />
|
||||
<Time />
|
||||
<br />
|
||||
<Link href="/node-rsc">
|
||||
<a id="link-node-rsc">to /node-rsc</a>
|
||||
</Link>
|
||||
<br />
|
||||
<Link href="/node-rsc-ssg">
|
||||
<a id="link-node-rsc-ssg">to /node-rsc-ssg</a>
|
||||
</Link>
|
||||
<br />
|
||||
<Link href="/node-rsc-ssr">
|
||||
<a id="link-node-rsc-ssr">to /node-rsc-ssr</a>
|
||||
</Link>
|
||||
<br />
|
||||
<Link href="/node-rsc-isr">
|
||||
<a id="link-node-rsc-isr">to /node-rsc-isr</a>
|
||||
</Link>
|
||||
<br />
|
||||
<Link href="/node-ssg">
|
||||
<a id="link-node-ssg">to /node-ssg</a>
|
||||
</Link>
|
||||
<br />
|
||||
<Link href="/node-ssr">
|
||||
<a id="link-node-ssr">to /node-ssr</a>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
/* eslint-env jest */
|
||||
|
||||
import webdriver from 'next-webdriver'
|
||||
import { join } from 'path'
|
||||
import { findPort, killApp, renderViaHTTP } from 'next-test-utils'
|
||||
import { nextBuild, nextStart } from './utils'
|
||||
import { nextBuild, nextDev, nextStart } from './utils'
|
||||
|
||||
const appDir = join(__dirname, '../switchable-runtime')
|
||||
|
||||
|
@ -31,7 +31,7 @@ async function testRoute(appPort, url, { isStatic, isEdge }) {
|
|||
}
|
||||
}
|
||||
|
||||
describe('Without global runtime configuration', () => {
|
||||
describe('Switchable runtime (prod)', () => {
|
||||
const context = { appDir }
|
||||
|
||||
beforeAll(async () => {
|
||||
|
@ -94,6 +94,28 @@ describe('Without global runtime configuration', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('should build /node-rsc-isr as an isr page with the nodejs runtime', async () => {
|
||||
const html1 = await renderViaHTTP(context.appPort, '/node-rsc-isr')
|
||||
const renderedAt1 = +html1.match(/Time: (\d+)/)[1]
|
||||
expect(html1).toContain('Runtime: Node.js')
|
||||
|
||||
const html2 = await renderViaHTTP(context.appPort, '/node-rsc-isr')
|
||||
const renderedAt2 = +html2.match(/Time: (\d+)/)[1]
|
||||
expect(html2).toContain('Runtime: Node.js')
|
||||
|
||||
expect(renderedAt1).toBe(renderedAt2)
|
||||
|
||||
// Trigger a revalidation after 3s.
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000))
|
||||
await renderViaHTTP(context.appPort, '/node-rsc-isr')
|
||||
|
||||
const html3 = await renderViaHTTP(context.appPort, '/node-rsc-isr')
|
||||
const renderedAt3 = +html3.match(/Time: (\d+)/)[1]
|
||||
expect(html3).toContain('Runtime: Node.js')
|
||||
|
||||
expect(renderedAt2).toBeLessThan(renderedAt3)
|
||||
})
|
||||
|
||||
it('should build /edge as a dynamic page with the edge runtime', async () => {
|
||||
await testRoute(context.appPort, '/edge', {
|
||||
isStatic: false,
|
||||
|
@ -117,7 +139,8 @@ describe('Without global runtime configuration', () => {
|
|||
├ ℇ /edge
|
||||
├ ℇ /edge-rsc
|
||||
├ ○ /node
|
||||
├ ○ /node-rsc
|
||||
├ ● /node-rsc
|
||||
├ ● /node-rsc-isr
|
||||
├ ● /node-rsc-ssg
|
||||
├ λ /node-rsc-ssr
|
||||
├ ● /node-ssg
|
||||
|
@ -129,4 +152,133 @@ describe('Without global runtime configuration', () => {
|
|||
)
|
||||
expect(isMatched).toBe(true)
|
||||
})
|
||||
|
||||
it('should prefetch data for static pages', async () => {
|
||||
const dataRequests = []
|
||||
|
||||
const browser = await webdriver(context.appPort, '/node', {
|
||||
beforePageLoad(page) {
|
||||
page.on('request', (request) => {
|
||||
const url = request.url()
|
||||
if (/\.json$/.test(url)) {
|
||||
dataRequests.push(url.split('/').pop())
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await browser.eval('window.beforeNav = 1')
|
||||
|
||||
for (const data of [
|
||||
'node-rsc.json',
|
||||
'node-rsc-ssg.json',
|
||||
'node-rsc-isr.json',
|
||||
'node-ssg.json',
|
||||
]) {
|
||||
expect(dataRequests).toContain(data)
|
||||
}
|
||||
})
|
||||
|
||||
it('should support client side navigation to ssr rsc pages', async () => {
|
||||
let flightRequest = null
|
||||
|
||||
const browser = await webdriver(context.appPort, '/node', {
|
||||
beforePageLoad(page) {
|
||||
page.on('request', (request) => {
|
||||
const url = request.url()
|
||||
if (/\?__flight__=1/.test(url)) {
|
||||
flightRequest = url
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await browser.waitForElementByCss('#link-node-rsc-ssr').click()
|
||||
|
||||
expect(await browser.elementByCss('body').text()).toContain(
|
||||
'This is a SSR RSC page.'
|
||||
)
|
||||
expect(flightRequest).toContain('/node-rsc-ssr?__flight__=1')
|
||||
})
|
||||
|
||||
it('should support client side navigation to ssg rsc pages', async () => {
|
||||
const browser = await webdriver(context.appPort, '/node')
|
||||
|
||||
await browser.waitForElementByCss('#link-node-rsc-ssg').click()
|
||||
expect(await browser.elementByCss('body').text()).toContain(
|
||||
'This is a SSG RSC page.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should support client side navigation to static rsc pages', async () => {
|
||||
const browser = await webdriver(context.appPort, '/node')
|
||||
|
||||
await browser.waitForElementByCss('#link-node-rsc').click()
|
||||
expect(await browser.elementByCss('body').text()).toContain(
|
||||
'This is a static RSC page.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Switchable runtime (dev)', () => {
|
||||
const context = { appDir }
|
||||
|
||||
beforeAll(async () => {
|
||||
context.appPort = await findPort()
|
||||
context.server = await nextDev(context.appDir, context.appPort)
|
||||
})
|
||||
afterAll(async () => {
|
||||
await killApp(context.server)
|
||||
})
|
||||
|
||||
it('should support client side navigation to ssr rsc pages', async () => {
|
||||
let flightRequest = null
|
||||
|
||||
const browser = await webdriver(context.appPort, '/node', {
|
||||
beforePageLoad(page) {
|
||||
page.on('request', (request) => {
|
||||
const url = request.url()
|
||||
if (/\?__flight__=1/.test(url)) {
|
||||
flightRequest = url
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await browser
|
||||
.waitForElementByCss('#link-node-rsc-ssr')
|
||||
.click()
|
||||
.waitForElementByCss('.node-rsc-ssr')
|
||||
|
||||
expect(await browser.elementByCss('body').text()).toContain(
|
||||
'This is a SSR RSC page.'
|
||||
)
|
||||
expect(flightRequest).toContain('/node-rsc-ssr?__flight__=1')
|
||||
})
|
||||
|
||||
it('should support client side navigation to ssg rsc pages', async () => {
|
||||
const browser = await webdriver(context.appPort, '/node')
|
||||
|
||||
await browser
|
||||
.waitForElementByCss('#link-node-rsc-ssg')
|
||||
.click()
|
||||
.waitForElementByCss('.node-rsc-ssg')
|
||||
|
||||
expect(await browser.elementByCss('body').text()).toContain(
|
||||
'This is a SSG RSC page.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should support client side navigation to static rsc pages', async () => {
|
||||
const browser = await webdriver(context.appPort, '/node')
|
||||
|
||||
await browser
|
||||
.waitForElementByCss('#link-node-rsc')
|
||||
.click()
|
||||
.waitForElementByCss('.node-rsc')
|
||||
|
||||
expect(await browser.elementByCss('body').text()).toContain(
|
||||
'This is a static RSC page.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue