diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 6932e9ab92db818..e304f9347aea76f 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -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() let allPageInfos = new Map() @@ -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') { diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 110ef701c36ca1a..7ed0d77a61e4f1d 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -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 || [], diff --git a/packages/next/build/webpack/loaders/next-flight-server-loader.ts b/packages/next/build/webpack/loaders/next-flight-server-loader.ts index 693003fa64b6bfa..18e908e19190126 100644 --- a/packages/next/build/webpack/loaders/next-flight-server-loader.ts +++ b/packages/next/build/webpack/loaders/next-flight-server-loader.ts @@ -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) diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index cb97c6bd967e022..af4c6b9a7810041 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -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 ( - + ) } diff --git a/packages/next/client/page-loader.ts b/packages/next/client/page-loader.ts index 0ec4e6945827b0d..a601d96c285cb2a 100644 --- a/packages/next/client/page-loader.ts +++ b/packages/next/client/page-loader.ts @@ -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' } diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index afc48797a508fcf..eb729f66337b732 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -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 { diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index c4fb8055765680d..a8d4025b387d63a 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -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 || {}), diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index 93ff1c2525f92e9..4dac851e600f8b2 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -310,12 +310,19 @@ function checkRedirectValues( const rscCache = new Map() function createFlightHook() { - return ( - writable: WritableStream, - id: string, - req: ReadableStream, + return ({ + id, + req, + inlinedDataWritable, + staticDataWritable, + bootstrap, + }: { + id: string + req: ReadableStream bootstrap: boolean - ) => { + inlinedDataWritable: WritableStream + staticDataWritable: WritableStream | 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( `` ) ) + if (staticDataWriter) { + staticDataWriter.write(value) + } process() } }) @@ -367,11 +385,13 @@ function createServerComponentRenderer( ComponentMod: any, { cachePrefix, - transformStream, + inlinedTransformStream, + staticTransformStream, serverComponentManifest, }: { cachePrefix: string - transformStream: TransformStream + inlinedTransformStream: TransformStream + staticTransformStream: null | TransformStream serverComponentManifest: NonNullable } ) { @@ -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 = 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 = 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) | null> = ( generateStaticHTML ? [ diff --git a/packages/next/shared/lib/router/router.ts b/packages/next/shared/lib/router/router.ts index 164f9dfe2c65f92..331dc563b179044 100644 --- a/packages/next/shared/lib/router/router.ts +++ b/packages/next/shared/lib/router/router.ts @@ -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(() => - __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 } } ) } diff --git a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc-isr.server.js b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc-isr.server.js new file mode 100644 index 000000000000000..70a54e4368cf638 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc-isr.server.js @@ -0,0 +1,27 @@ +import Runtime from '../utils/runtime' +import Time from '../utils/time' + +export default function Page({ type }) { + return ( +
+ This is a {type} RSC page. +
+ +
+
+ ) +} + +export function getStaticProps() { + return { + props: { + type: 'ISR', + }, + revalidate: 3, + } +} + +export const config = { + runtime: 'nodejs', +} diff --git a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc-ssg.server.js b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc-ssg.server.js index 7f9804f683e8f3a..9be4283e092c94f 100644 --- a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc-ssg.server.js +++ b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc-ssg.server.js @@ -3,7 +3,7 @@ import Time from '../utils/time' export default function Page({ type }) { return ( -
+
This is a {type} RSC page.
diff --git a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc-ssr.server.js b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc-ssr.server.js index d064a5cbbfd5bdc..87a14d30b6f9ebe 100644 --- a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc-ssr.server.js +++ b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc-ssr.server.js @@ -3,7 +3,7 @@ import Time from '../utils/time' export default function Page({ type }) { return ( -
+
This is a {type} RSC page.
diff --git a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc.server.js b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc.server.js index 4c373d397b58c30..4a725cf2f07041e 100644 --- a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc.server.js +++ b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node-rsc.server.js @@ -3,7 +3,7 @@ import Time from '../utils/time' export default function Page() { return ( -
+
This is a static RSC page.
diff --git a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node.js b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node.js index b2186ca7ceb645d..d0857a09aebaaa8 100644 --- a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node.js +++ b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/node.js @@ -1,6 +1,8 @@ import Runtime from '../utils/runtime' import Time from '../utils/time' +import Link from 'next/link' + export default function Page() { return (
@@ -9,6 +11,30 @@ export default function Page() {
) } diff --git a/test/integration/react-streaming-and-server-components/test/switchable-runtime.test.js b/test/integration/react-streaming-and-server-components/test/switchable-runtime.test.js index 2c8810fccab3d6e..ff3375015cf193a 100644 --- a/test/integration/react-streaming-and-server-components/test/switchable-runtime.test.js +++ b/test/integration/react-streaming-and-server-components/test/switchable-runtime.test.js @@ -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.' + ) + }) })