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:
Shu Ding 2022-04-01 18:13:38 +02:00 committed by GitHub
parent bcd2aa5c12
commit 0eb9f7e76d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 450 additions and 88 deletions

View file

@ -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') {

View file

@ -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 || [],

View file

@ -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)

View file

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

View file

@ -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'
}

View file

@ -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 {

View file

@ -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 || {}),

View file

@ -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
? [

View file

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

View file

@ -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',
}

View file

@ -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 />

View file

@ -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 />

View file

@ -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 />

View file

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

View file

@ -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.'
)
})
})