From 67d25a58a4ca932f7f8a128bb0adb7677286066b Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Tue, 5 Apr 2022 21:46:17 +0200 Subject: [PATCH] Custom app for switchable runtime (#35666) x-ref: #33149 RFCs: - #30996 - #31506 ## Feature - [x] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [x] Related issues linked using `fixes #number` - [x] Integration tests added - [x] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` --- packages/next/build/entries.ts | 29 ++- packages/next/build/utils.ts | 10 +- packages/next/build/webpack-config.ts | 14 +- .../loaders/next-flight-client-loader.ts | 2 +- .../loaders/next-flight-server-loader.ts | 28 ++- .../next-middleware-ssr-loader/index.ts | 18 +- .../next-middleware-ssr-loader/render.ts | 5 +- .../loaders/next-serverless-loader/index.ts | 1 + .../next-serverless-loader/page-handler.ts | 7 +- .../webpack/plugins/flight-manifest-plugin.ts | 13 +- .../webpack/plugins/pages-manifest-plugin.ts | 4 + packages/next/client/index.tsx | 6 +- packages/next/export/index.ts | 7 +- packages/next/pages/_app.server.tsx | 3 + packages/next/server/dev/hot-reloader.ts | 1 + packages/next/server/dev/next-dev-server.ts | 4 +- packages/next/server/load-components.ts | 9 +- packages/next/server/next-server.ts | 3 +- packages/next/server/render.tsx | 62 ++++--- packages/next/taskfile.js | 12 +- .../switchable-runtime/pages/_app.server.js | 8 + .../test/basic.js | 7 + .../test/index.test.js | 168 ++++++++---------- .../test/rsc.js | 1 - .../test/switchable-runtime.test.js | 39 +++- 25 files changed, 275 insertions(+), 186 deletions(-) create mode 100644 packages/next/pages/_app.server.tsx create mode 100644 test/integration/react-streaming-and-server-components/switchable-runtime/pages/_app.server.js diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index cde116766a2f0..9945adf1cf6b7 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -19,7 +19,12 @@ import { ClientPagesLoaderOptions } from './webpack/loaders/next-client-pages-lo import { ServerlessLoaderQuery } from './webpack/loaders/next-serverless-loader' import { LoadedEnvFiles } from '@next/env' import { parse } from '../build/swc' -import { isCustomErrorPage, isFlightPage, isReservedPage } from './utils' +import { + getRawPageExtensions, + isCustomErrorPage, + isFlightPage, + isReservedPage, +} from './utils' import { ssrEntries } from './webpack/plugins/middleware-plugin' import { MIDDLEWARE_RUNTIME_WEBPACK, @@ -32,7 +37,14 @@ export type PagesMapping = { } export function getPageFromPath(pagePath: string, extensions: string[]) { - let page = pagePath.replace(new RegExp(`\\.+(${extensions.join('|')})$`), '') + const rawExtensions = getRawPageExtensions(extensions) + const pickedExtensions = pagePath.includes('/_app.server.') + ? rawExtensions + : extensions + let page = pagePath.replace( + new RegExp(`\\.+(${pickedExtensions.join('|')})$`), + '' + ) page = page.replace(/\\/g, '/').replace(/\/index$/, '') return page === '' ? '/' : page } @@ -86,13 +98,20 @@ export function createPagesMapping( // allow falling back to the correct source file so // that HMR can work properly when a file is added/removed if (isDev) { + if (hasServerComponents) { + pages['/_app.server'] = `${PAGES_DIR_ALIAS}/_app.server` + } pages['/_app'] = `${PAGES_DIR_ALIAS}/_app` pages['/_error'] = `${PAGES_DIR_ALIAS}/_error` pages['/_document'] = `${PAGES_DIR_ALIAS}/_document` } else { + if (hasServerComponents) { + pages['/_app.server'] = + pages['/_app.server'] || 'next/dist/pages/_app.server' + } pages['/_app'] = pages['/_app'] || 'next/dist/pages/_app' pages['/_error'] = pages['/_error'] || 'next/dist/pages/_error' - pages['/_document'] = pages['/_document'] || `next/dist/pages/_document` + pages['/_document'] = pages['/_document'] || 'next/dist/pages/_document' } return pages } @@ -229,6 +248,7 @@ export async function createEntrypoints( const defaultServerlessOptions = { absoluteAppPath: pages['/_app'], + absoluteAppServerPath: pages['/_app.server'], absoluteDocumentPath: pages['/_document'], absoluteErrorPath: pages['/_error'], absolute404Path: pages['/404'] || '', @@ -320,6 +340,7 @@ export async function createEntrypoints( } else if ( isLikeServerless && page !== '/_app' && + page !== '/_app.server' && page !== '/_document' && !isEdgeRuntime ) { @@ -333,7 +354,7 @@ export async function createEntrypoints( )}!` } - if (page === '/_document') { + if (page === '/_document' || page === '/_app.server') { return } diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 7ed0d77a61e4f..4fa14e0815a97 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -144,6 +144,11 @@ export async function printTreeView( ] const hasCustomApp = await findPageFile(pagesDir, '/_app', pageExtensions) + const hasCustomAppServer = await findPageFile( + pagesDir, + '/_app.server', + pageExtensions + ) pageInfos.set('/404', { ...(pageInfos.get('/404') || pageInfos.get('/_error')), @@ -170,7 +175,8 @@ export async function printTreeView( !( e === '/_document' || e === '/_error' || - (!hasCustomApp && e === '/_app') + (!hasCustomApp && e === '/_app') || + (!hasCustomAppServer && e === '/_app.server') ) ) .sort((a, b) => a.localeCompare(b)) @@ -192,7 +198,7 @@ export async function printTreeView( (pageInfo?.ssgPageDurations?.reduce((a, b) => a + (b || 0), 0) || 0) const symbol = - item === '/_app' + item === '/_app' || item === '/_app.server' ? ' ' : item.endsWith('/_middleware') ? 'ƒ' diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 5e8dd5048ca26..420a34f324074 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -477,9 +477,6 @@ export default async function getBaseWebpackConfig( const serverComponentsRegex = new RegExp( `\\.server\\.(${rawPageExtensions.join('|')})$` ) - const clientComponentsRegex = new RegExp( - `\\.client\\.(${rawPageExtensions.join('|')})$` - ) const babelIncludeRegexes: RegExp[] = [ /next[\\/]dist[\\/]shared[\\/]lib/, @@ -554,12 +551,19 @@ export default async function getBaseWebpackConfig( if (dev) { customAppAliases[`${PAGES_DIR_ALIAS}/_app`] = [ - ...config.pageExtensions.reduce((prev, ext) => { + ...rawPageExtensions.reduce((prev, ext) => { prev.push(path.join(pagesDir, `_app.${ext}`)) return prev }, [] as string[]), 'next/dist/pages/_app.js', ] + customAppAliases[`${PAGES_DIR_ALIAS}/_app.server`] = [ + ...rawPageExtensions.reduce((prev, ext) => { + prev.push(path.join(pagesDir, `_app.server.${ext}`)) + return prev + }, [] as string[]), + 'next/dist/pages/_app.server.js', + ] customAppAliases[`${PAGES_DIR_ALIAS}/_error`] = [ ...config.pageExtensions.reduce((prev, ext) => { prev.push(path.join(pagesDir, `_error.${ext}`)) @@ -1509,7 +1513,7 @@ export default async function getBaseWebpackConfig( }), hasServerComponents && !isServer && - new FlightManifestPlugin({ dev, clientComponentsRegex }), + new FlightManifestPlugin({ dev, pageExtensions: rawPageExtensions }), !dev && !isServer && new TelemetryPlugin( diff --git a/packages/next/build/webpack/loaders/next-flight-client-loader.ts b/packages/next/build/webpack/loaders/next-flight-client-loader.ts index d4343ad5048d2..6106ae6992077 100644 --- a/packages/next/build/webpack/loaders/next-flight-client-loader.ts +++ b/packages/next/build/webpack/loaders/next-flight-client-loader.ts @@ -115,7 +115,7 @@ export default async function transformSource( const names: string[] = [] await parseModuleInfo(resourcePath, transformedSource, names) - // next.js/packages/next/.js + // Next.js built-in client components if (/[\\/]next[\\/](link|image)\.js$/.test(resourcePath)) { names.push('default') } 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 18e908e191901..29bec71067076 100644 --- a/packages/next/build/webpack/loaders/next-flight-server-loader.ts +++ b/packages/next/build/webpack/loaders/next-flight-server-loader.ts @@ -1,18 +1,17 @@ import { parse } from '../../swc' -import { getRawPageExtensions } from '../../utils' import { buildExports } from './utils' const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'avif'] -const createClientComponentFilter = (pageExtensions: string[]) => { +export const createClientComponentFilter = (pageExtensions: string[]) => { // Special cases for Next.js APIs that are considered as client components: // - .client.[ext] - // - next/link, next/image + // - next built-in client components // - .[imageExt] const regex = new RegExp( '(' + `\\.client(\\.(${pageExtensions.join('|')}))?|` + - `next/link|next/image|` + + `next/(link|image)(\\.js)?|` + `\\.(${imageExtensions.join('|')})` + ')$' ) @@ -20,7 +19,7 @@ const createClientComponentFilter = (pageExtensions: string[]) => { return (importSource: string) => regex.test(importSource) } -const createServerComponentFilter = (pageExtensions: string[]) => { +export const createServerComponentFilter = (pageExtensions: string[]) => { const regex = new RegExp(`\\.server(\\.(${pageExtensions.join('|')}))?$`) return (importSource: string) => regex.test(importSource) } @@ -166,15 +165,8 @@ export default async function transformSource( throw new Error('Expected source to have been transformed to a string.') } - // We currently assume that all components are shared components (unsuffixed) - // from node_modules. - if (resourcePath.includes('/node_modules/')) { - return source - } - - const rawRawPageExtensions = getRawPageExtensions(pageExtensions) - const isServerComponent = createServerComponentFilter(rawRawPageExtensions) - const isClientComponent = createClientComponentFilter(rawRawPageExtensions) + const isServerComponent = createServerComponentFilter(pageExtensions) + const isClientComponent = createClientComponentFilter(pageExtensions) if (!isClientCompilation) { // We only apply the loader to server components, or shared components that @@ -206,19 +198,19 @@ export default async function transformSource( * * Server compilation output: * (The content of the Server Component module will be kept.) - * export const __next_rsc__ = { __webpack_require__, _: () => { ... } } + * export const __next_rsc__ = { __webpack_require__, _: () => { ... }, server: true } * * Client compilation output: * (The content of the Server Component module will be removed.) - * export const __next_rsc__ = { __webpack_require__, _: () => { ... } } + * export const __next_rsc__ = { __webpack_require__, _: () => { ... }, server: false } */ const rscExports: any = { __next_rsc__: `{ __webpack_require__, - _: () => {\n${imports}\n} + _: () => {\n${imports}\n}, + server: ${isServerComponent(resourcePath) ? 'true' : 'false'} }`, - __next_rsc_server__: isServerComponent(resourcePath) ? 'true' : 'false', } if (isClientCompilation) { diff --git a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts index 3e07e9ebb9334..1a5957629e9d6 100644 --- a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts +++ b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts @@ -7,6 +7,7 @@ export default async function middlewareSSRLoader(this: any) { buildId, absolutePagePath, absoluteAppPath, + absoluteAppServerPath, absoluteDocumentPath, absolute500Path, absoluteErrorPath, @@ -16,11 +17,15 @@ export default async function middlewareSSRLoader(this: any) { const stringifiedPagePath = stringifyRequest(this, absolutePagePath) const stringifiedAppPath = stringifyRequest(this, absoluteAppPath) + const stringifiedAppServerPath = absoluteAppServerPath + ? stringifyRequest(this, absoluteAppServerPath) + : null + const stringifiedErrorPath = stringifyRequest(this, absoluteErrorPath) const stringifiedDocumentPath = stringifyRequest(this, absoluteDocumentPath) const stringified500Path = absolute500Path ? stringifyRequest(this, absolute500Path) - : 'null' + : null const transformed = ` import { adapter } from 'next/dist/server/web/adapter' @@ -31,9 +36,15 @@ export default async function middlewareSSRLoader(this: any) { import Document from ${stringifiedDocumentPath} const appMod = require(${stringifiedAppPath}) + const appServerMod = ${ + stringifiedAppServerPath ? `require(${stringifiedAppServerPath})` : 'null' + } const pageMod = require(${stringifiedPagePath}) const errorMod = require(${stringifiedErrorPath}) - const error500Mod = ${stringified500Path} ? require(${stringified500Path}) : null + const error500Mod = ${ + stringified500Path ? `require(${stringified500Path})` : 'null' + } + const buildManifest = self.__BUILD_MANIFEST const reactLoadableManifest = self.__REACT_LOADABLE_MANIFEST @@ -44,7 +55,7 @@ export default async function middlewareSSRLoader(this: any) { page: ${JSON.stringify(page)}, buildId: ${JSON.stringify(buildId)}, } - + const render = getRender({ dev: ${dev}, page: ${JSON.stringify(page)}, @@ -56,6 +67,7 @@ export default async function middlewareSSRLoader(this: any) { buildManifest, reactLoadableManifest, serverComponentManifest: ${isServerComponent} ? rscManifest : null, + appServerMod, config: ${stringifiedConfig}, buildId: ${JSON.stringify(buildId)}, }) diff --git a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts index 44f08b11c9ea3..1bb978309b0d3 100644 --- a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts +++ b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts @@ -27,6 +27,7 @@ export function getRender({ serverComponentManifest, config, buildId, + appServerMod, }: { dev: boolean page: string @@ -37,7 +38,8 @@ export function getRender({ Document: DocumentType buildManifest: BuildManifest reactLoadableManifest: ReactLoadableManifest - serverComponentManifest: any | null + serverComponentManifest: any + appServerMod: any config: NextConfig buildId: string }) { @@ -48,6 +50,7 @@ export function getRender({ Document, App: appMod.default as AppType, AppMod: appMod, + AppServerMod: appServerMod, } const server = new WebServer({ diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/index.ts b/packages/next/build/webpack/loaders/next-serverless-loader/index.ts index 08e517fabed88..dfcf7e26c58ea 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/index.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/index.ts @@ -18,6 +18,7 @@ export type ServerlessLoaderQuery = { distDir: string absolutePagePath: string absoluteAppPath: string + absoluteAppServerPath: string absoluteDocumentPath: string absoluteErrorPath: string absolute404Path: string diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts b/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts index 8331054269a58..0ac97927d3a10 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts @@ -65,7 +65,7 @@ export function getPageHandler(ctx: ServerlessHandlerCtx) { _params?: any ) { let Component - let App + let AppMod let config let Document let Error @@ -78,7 +78,7 @@ export function getPageHandler(ctx: ServerlessHandlerCtx) { getServerSideProps, getStaticPaths, Component, - App, + AppMod, config, { default: Document }, { default: Error }, @@ -103,8 +103,9 @@ export function getPageHandler(ctx: ServerlessHandlerCtx) { setLazyProp({ req: req as any }, 'cookies', getCookieParser(req.headers)) const options = { - App, + AppMod, Document, + ComponentMod: { default: Component }, buildManifest, getStaticProps, getServerSideProps, diff --git a/packages/next/build/webpack/plugins/flight-manifest-plugin.ts b/packages/next/build/webpack/plugins/flight-manifest-plugin.ts index 09b526f75266a..a2c0b9b55e20d 100644 --- a/packages/next/build/webpack/plugins/flight-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/flight-manifest-plugin.ts @@ -7,6 +7,7 @@ import { webpack, sources } from 'next/dist/compiled/webpack/webpack' import { MIDDLEWARE_FLIGHT_MANIFEST } from '../../../shared/lib/constants' +import { createClientComponentFilter } from '../loaders/next-flight-server-loader' // This is the module that will be used to anchor all client references to. // I.e. it will have all the client files as async deps from this point on. @@ -17,20 +18,20 @@ import { MIDDLEWARE_FLIGHT_MANIFEST } from '../../../shared/lib/constants' type Options = { dev: boolean - clientComponentsRegex: RegExp + pageExtensions: string[] } const PLUGIN_NAME = 'FlightManifestPlugin' export class FlightManifestPlugin { dev: boolean = false - clientComponentsRegex: RegExp + pageExtensions: string[] constructor(options: Options) { if (typeof options.dev === 'boolean') { this.dev = options.dev } - this.clientComponentsRegex = options.clientComponentsRegex + this.pageExtensions = options.pageExtensions } apply(compiler: any) { @@ -63,7 +64,7 @@ export class FlightManifestPlugin { createAsset(assets: any, compilation: any) { const manifest: any = {} - const { clientComponentsRegex } = this + const isClientComponent = createClientComponentFilter(this.pageExtensions) compilation.chunkGroups.forEach((chunkGroup: any) => { function recordModule(id: string, _chunk: any, mod: any) { const resource = mod.resource?.replace(/\?__sc_client__$/, '') @@ -71,11 +72,9 @@ export class FlightManifestPlugin { // TODO: Hook into deps instead of the target module. // That way we know by the type of dep whether to include. // It also resolves conflicts when the same module is in multiple chunks. - const isNextClientComponent = /next[\\/](link|image)/.test(resource) - if (!clientComponentsRegex.test(resource) && !isNextClientComponent) { + if (!isClientComponent(resource)) { return } - const moduleExports: any = manifest[resource] || {} const exportsInfo = compilation.moduleGraph.getExportsInfo(mod) diff --git a/packages/next/build/webpack/plugins/pages-manifest-plugin.ts b/packages/next/build/webpack/plugins/pages-manifest-plugin.ts index e562239c52958..723e841c8145a 100644 --- a/packages/next/build/webpack/plugins/pages-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/pages-manifest-plugin.ts @@ -49,6 +49,10 @@ export default class PagesManifestPlugin implements webpack.Plugin { file.endsWith('.js') ) + // Skip _app.server entry which is empty + if (!files.length) { + continue + } // Write filename, replace any backslashes in path (on windows) with forwardslashes for cross-platform consistency. pages[pagePath] = files[files.length - 1] diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index af4c6b9a78100..37bde9206f1a7 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -87,7 +87,7 @@ let webpackHMR: any let CachedApp: AppComponent, onPerfEntry: (metric: any) => void let CachedComponent: React.ComponentType -let isAppRSC: boolean +let isRSCPage: boolean class Container extends React.Component<{ fn: (err: Error, info?: any) => void @@ -288,7 +288,6 @@ export async function hydrate(opts?: { beforeRender?: () => Promise }) { const { component: app, exports: mod } = appEntrypoint CachedApp = app as AppComponent - isAppRSC = !!mod.__next_rsc__ const exportedReportWebVitals = mod && mod.reportWebVitals onPerfEntry = ({ id, @@ -333,6 +332,7 @@ export async function hydrate(opts?: { beforeRender?: () => Promise }) { throw pageEntrypoint.error } CachedComponent = pageEntrypoint.component + isRSCPage = !!pageEntrypoint.exports.__next_rsc__ if (process.env.NODE_ENV !== 'production') { const { isValidElementType } = require('next/dist/compiled/react-is') @@ -649,7 +649,7 @@ function AppContainer({ } function renderApp(App: AppComponent, appProps: AppProps) { - if (process.env.__NEXT_RSC && isAppRSC) { + if (process.env.__NEXT_RSC && isRSCPage) { const { Component, err: _, router: __, ...props } = appProps return } else { diff --git a/packages/next/export/index.ts b/packages/next/export/index.ts index 0f241d21ffe6f..9eff2e8a40e7d 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -239,7 +239,12 @@ export default async function exportApp( continue } - if (page === '/_document' || page === '/_app' || page === '/_error') { + if ( + page === '/_document' || + page === '/_app.server' || + page === '/_app' || + page === '/_error' + ) { continue } diff --git a/packages/next/pages/_app.server.tsx b/packages/next/pages/_app.server.tsx new file mode 100644 index 0000000000000..8dbc25dc5634e --- /dev/null +++ b/packages/next/pages/_app.server.tsx @@ -0,0 +1,3 @@ +export default function AppServer({ children }: { children: React.ReactNode }) { + return children +} diff --git a/packages/next/server/dev/hot-reloader.ts b/packages/next/server/dev/hot-reloader.ts index e4240446806f2..f9b53c078a57a 100644 --- a/packages/next/server/dev/hot-reloader.ts +++ b/packages/next/server/dev/hot-reloader.ts @@ -564,6 +564,7 @@ export default class HotReloader { page, stringifiedConfig: JSON.stringify(this.config), absoluteAppPath: this.pagesMapping['/_app'], + absoluteAppServerPath: this.pagesMapping['/_app.server'], absoluteDocumentPath: this.pagesMapping['/_document'], absoluteErrorPath: this.pagesMapping['/_error'], absolute404Path: this.pagesMapping['/404'] || '', diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index 1a8534b0d464d..1a8659acac252 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -937,9 +937,11 @@ export default class DevServer extends Server { try { await this.hotReloader!.ensurePage(pathname) + const serverComponents = this.nextConfig.experimental.serverComponents + // When the new page is compiled, we need to reload the server component // manifest. - if (this.nextConfig.experimental.serverComponents) { + if (serverComponents) { this.serverComponentManifest = super.getServerComponentManifest() } diff --git a/packages/next/server/load-components.ts b/packages/next/server/load-components.ts index 361f0183899d5..881598c769ee5 100644 --- a/packages/next/server/load-components.ts +++ b/packages/next/server/load-components.ts @@ -31,7 +31,7 @@ export type LoadComponentsReturnType = { pageConfig: PageConfig buildManifest: BuildManifest reactLoadableManifest: ReactLoadableManifest - serverComponentManifest?: any | null + serverComponentManifest?: any Document: DocumentType App: AppType getStaticProps?: GetStaticProps @@ -39,6 +39,7 @@ export type LoadComponentsReturnType = { getServerSideProps?: GetServerSideProps ComponentMod: any AppMod: any + AppServerMod: any } export async function loadDefaultErrorComponents(distDir: string) { @@ -57,6 +58,8 @@ export async function loadDefaultErrorComponents(distDir: string) { reactLoadableManifest: {}, ComponentMod, AppMod, + // Use App for fallback + AppServerMod: AppMod, } } @@ -99,10 +102,11 @@ export async function loadComponents( } as LoadComponentsReturnType } - const [DocumentMod, AppMod, ComponentMod] = await Promise.all([ + const [DocumentMod, AppMod, ComponentMod, AppServerMod] = await Promise.all([ requirePage('/_document', distDir, serverless), requirePage('/_app', distDir, serverless), requirePage(pathname, distDir, serverless), + serverComponents ? requirePage('/_app.server', distDir, serverless) : null, ]) const [buildManifest, reactLoadableManifest, serverComponentManifest] = @@ -129,6 +133,7 @@ export async function loadComponents( pageConfig: ComponentMod.config || {}, ComponentMod, AppMod, + AppServerMod, getServerSideProps, getStaticProps, getStaticPaths, diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index a8d4025b387d6..0b1832862c534 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -658,7 +658,8 @@ export default class NextNodeServer extends BaseServer { const components = await loadComponents( this.distDir, pagePath!, - !this.renderOpts.dev && this._isLikeServerless + !this.renderOpts.dev && this._isLikeServerless, + this.renderOpts.serverComponents ) if ( diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index 4dac851e600f8..dab009cb73ec8 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -79,6 +79,7 @@ import { } from './node-web-streams-helper' import { ImageConfigContext } from '../shared/lib/image-config-context' import { FlushEffectsContext } from '../shared/lib/flush-effects' +import { interopDefault } from '../lib/interop-default' let optimizeAmp: typeof import('./optimize-amp').default let getFontDefinitionFromManifest: typeof import('./font-utils').getFontDefinitionFromManifest @@ -195,10 +196,14 @@ function enhanceComponents( } } -function renderFlight(AppMod: any, Component: React.ComponentType, props: any) { - const AppServer = AppMod.__next_rsc__ - ? (AppMod.default as React.ComponentType) +function renderFlight(AppMod: any, ComponentMod: any, props: any) { + const isServerComponent = !!ComponentMod.__next_rsc__ + const App = interopDefault(AppMod) + const Component = interopDefault(ComponentMod) + const AppServer = isServerComponent + ? (App as React.ComponentType) : React.Fragment + return ( @@ -380,7 +385,6 @@ const useFlightResponse = createFlightHook() // Create the wrapper component for a Flight stream. function createServerComponentRenderer( - OriginalComponent: React.ComponentType, AppMod: any, ComponentMod: any, { @@ -399,11 +403,12 @@ function createServerComponentRenderer( // react-server-dom-webpack. This is a hack until we find a better way. // @ts-ignore globalThis.__webpack_require__ = ComponentMod.__next_rsc__.__webpack_require__ + const Component = interopDefault(ComponentMod) - const ServerComponentWrapper = (props: any) => { + function ServerComponentWrapper(props: any) { const id = (React as any).useId() const reqStream: ReadableStream = renderToReadableStream( - renderFlight(AppMod, OriginalComponent, props), + renderFlight(AppMod, ComponentMod, props), serverComponentManifest ) @@ -422,10 +427,6 @@ function createServerComponentRenderer( return root } - const Component = (props: any) => { - return - } - // Although it's not allowed to attach some static methods to Component, // we still re-assign all the component APIs to keep the behavior unchanged. for (const methodName of [ @@ -434,13 +435,13 @@ function createServerComponentRenderer( 'getServerSideProps', 'getStaticPaths', ]) { - const method = (OriginalComponent as any)[methodName] + const method = (Component as any)[methodName] if (method) { - ;(Component as any)[methodName] = method + ;(ServerComponentWrapper as any)[methodName] = method } } - return Component + return ServerComponentWrapper } export async function renderToHTML( @@ -464,7 +465,6 @@ export async function renderToHTML( err, dev = false, ampPath = '', - App, pageConfig = {}, buildManifest, fontManifest, @@ -484,22 +484,26 @@ export async function renderToHTML( reactRoot, runtime: globalRuntime, ComponentMod, - AppMod, + AppMod: AppClientMod, + AppServerMod, } = renderOpts const hasConcurrentFeatures = reactRoot let Document = renderOpts.Document - const OriginalComponent = renderOpts.Component // We don't need to opt-into the flight inlining logic if the page isn't a RSC. const isServerComponent = hasConcurrentFeatures && !!serverComponentManifest && - !!ComponentMod.__next_rsc_server__ + !!ComponentMod.__next_rsc__?.server let Component: React.ComponentType<{}> | ((props: any) => JSX.Element) = renderOpts.Component + + const AppMod = isServerComponent ? AppServerMod : AppClientMod + const App = interopDefault(AppMod) + let serverComponentsInlinedTransformStream: TransformStream< Uint8Array, Uint8Array @@ -513,17 +517,12 @@ export async function renderToHTML( if (isServerComponent) { serverComponentsInlinedTransformStream = new TransformStream() const search = stringifyQuery(query) - Component = createServerComponentRenderer( - OriginalComponent, - AppMod, - ComponentMod, - { - cachePrefix: pathname + (search ? `?${search}` : ''), - inlinedTransformStream: serverComponentsInlinedTransformStream, - staticTransformStream: serverComponentsPageDataTransformStream, - serverComponentManifest, - } - ) + Component = createServerComponentRenderer(AppMod, ComponentMod, { + cachePrefix: pathname + (search ? `?${search}` : ''), + inlinedTransformStream: serverComponentsInlinedTransformStream, + staticTransformStream: serverComponentsPageDataTransformStream, + serverComponentManifest, + }) } const getFontDefinition = (url: string): string => { @@ -738,7 +737,7 @@ export async function renderToHTML( AppTree: (props: any) => { return ( - + {renderFlight(AppMod, ComponentMod, { ...props, router })} ) }, @@ -1223,7 +1222,7 @@ export async function renderToHTML( return new RenderResult( pipeThrough( renderToReadableStream( - renderFlight(AppMod, OriginalComponent, { + renderFlight(AppMod, ComponentMod, { ...props.pageProps, ...serverComponentProps, }), @@ -1373,7 +1372,7 @@ export async function renderToHTML( ) : ( - {isServerComponent && AppMod.__next_rsc__ ? ( + {isServerComponent && !!AppMod.__next_rsc__ ? ( // _app.server.js is used. ) : ( @@ -1625,7 +1624,6 @@ export async function renderToHTML( optimizeFonts: renderOpts.optimizeFonts, nextScriptWorkers: renderOpts.nextScriptWorkers, runtime: globalRuntime, - hasConcurrentFeatures, } const document = ( diff --git a/packages/next/taskfile.js b/packages/next/taskfile.js index 3cf7a9f146205..fc4a1ee697fa9 100644 --- a/packages/next/taskfile.js +++ b/packages/next/taskfile.js @@ -1847,6 +1847,13 @@ export async function pages_app(task, opts) { .target('dist/pages') } +export async function pages_app_server(task, opts) { + await task + .source('pages/_app.server.tsx') + .swc('client', { dev: opts.dev, keepImportAssertions: true }) + .target('dist/pages') +} + export async function pages_error(task, opts) { await task .source('pages/_error.tsx') @@ -1862,7 +1869,10 @@ export async function pages_document(task, opts) { } export async function pages(task, opts) { - await task.parallel(['pages_app', 'pages_error', 'pages_document'], opts) + await task.parallel( + ['pages_app', 'pages_app_server', 'pages_error', 'pages_document'], + opts + ) } export async function telemetry(task, opts) { diff --git a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/_app.server.js b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/_app.server.js new file mode 100644 index 0000000000000..0d80a5a8abb31 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/_app.server.js @@ -0,0 +1,8 @@ +export default function AppServer({ children }) { + return ( +
+ + {children} +
+ ) +} diff --git a/test/integration/react-streaming-and-server-components/test/basic.js b/test/integration/react-streaming-and-server-components/test/basic.js index 5c5aec7ba8209..ec7952b230b0e 100644 --- a/test/integration/react-streaming-and-server-components/test/basic.js +++ b/test/integration/react-streaming-and-server-components/test/basic.js @@ -65,4 +65,11 @@ export default async function basic(context, { env }) { expect(content).toMatchInlineSnapshot('"foo.clientbar.client"') expect(dynamicIds).toBe(undefined) }) + + if (env === 'prod') { + it(`should not display custom _app or _app.server in treeview if there's not any`, () => { + const { stdout } = context + expect(stdout).not.toMatch(/\s\/_app(\.server)?/) + }) + } } diff --git a/test/integration/react-streaming-and-server-components/test/index.test.js b/test/integration/react-streaming-and-server-components/test/index.test.js index bb52a23a2b833..6ac2a84e5cb42 100644 --- a/test/integration/react-streaming-and-server-components/test/index.test.js +++ b/test/integration/react-streaming-and-server-components/test/index.test.js @@ -77,106 +77,83 @@ describe('Edge runtime - errors', () => { }) }) -describe('Edge runtime - prod', () => { - const context = { appDir } - - beforeAll(async () => { - error500Page.write(page500) - context.appPort = await findPort() - const { stderr } = await nextBuild(context.appDir) - context.stderr = stderr - context.server = await nextStart(context.appDir, context.appPort) - }) - afterAll(async () => { - error500Page.delete() - await killApp(context.server) - }) - - it('should warn user for experimental risk with edge runtime and server components', async () => { - const edgeRuntimeWarning = - 'You are using the experimental Edge Runtime with `experimental.runtime`.' - const rscWarning = `You have experimental React Server Components enabled. Continue at your own risk.` - expect(context.stderr).toContain(edgeRuntimeWarning) - expect(context.stderr).toContain(rscWarning) - }) +const edgeRuntimeBasicSuite = { + runTests: (context, env) => { + const options = { runtime: 'edge', env } + basic(context, options) + streaming(context, options) + rsc(context, options) + runtime(context, options) - it('should generate middleware SSR manifests for edge runtime', async () => { - const distServerDir = join(distDir, 'server') - const files = [ - 'middleware-build-manifest.js', - 'middleware-ssr-runtime.js', - 'middleware-flight-manifest.js', - 'middleware-flight-manifest.json', - 'middleware-manifest.json', - ] - - const requiredServerFiles = ( - await fs.readJSON(join(distDir, 'required-server-files.json')) - ).files - - files.forEach((file) => { - const filepath = join(distServerDir, file) - expect(fs.existsSync(filepath)).toBe(true) - }) + if (env === 'dev') { + it('should have content-type and content-encoding headers', async () => { + const res = await fetchViaHTTP(context.appPort, '/') + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8') + expect(res.headers.get('content-encoding')).toBe('gzip') + }) + } + if (env === 'prod') { + it('should warn user for experimental risk with edge runtime and server components', async () => { + const edgeRuntimeWarning = + 'You are using the experimental Edge Runtime with `experimental.runtime`.' + const rscWarning = `You have experimental React Server Components enabled. Continue at your own risk.` + expect(context.stderr).toContain(edgeRuntimeWarning) + expect(context.stderr).toContain(rscWarning) + }) - requiredServerFiles.forEach((file) => { - const requiredFilePath = join(appDir, file) - expect(fs.existsSync(requiredFilePath)).toBe(true) - }) - }) + it('should generate middleware SSR manifests for edge runtime', async () => { + const distServerDir = join(distDir, 'server') + const files = [ + 'middleware-build-manifest.js', + 'middleware-ssr-runtime.js', + 'middleware-flight-manifest.js', + 'middleware-flight-manifest.json', + 'middleware-manifest.json', + ] - it('should have clientInfo in middleware manifest', async () => { - const middlewareManifestPath = join( - distDir, - 'server', - 'middleware-manifest.json' - ) - const content = JSON.parse( - await fs.readFile(middlewareManifestPath, 'utf8') - ) - for (const item of [ - ['/', true], - ['/next-api/image', true], - ['/next-api/link', true], - ['/routes/[dynamic]', true], - ]) { - expect(content.clientInfo).toContainEqual(item) - } - expect(content.clientInfo).not.toContainEqual([['/404', true]]) - }) + const requiredServerFiles = ( + await fs.readJSON(join(distDir, 'required-server-files.json')) + ).files - const options = { runtime: 'edge', env: 'prod' } - basic(context, options) - streaming(context, options) - rsc(context, options) - runtime(context, options) -}) + files.forEach((file) => { + const filepath = join(distServerDir, file) + expect(fs.existsSync(filepath)).toBe(true) + }) -describe('Edge runtime - dev', () => { - const context = { appDir } + requiredServerFiles.forEach((file) => { + const requiredFilePath = join(appDir, file) + expect(fs.existsSync(requiredFilePath)).toBe(true) + }) + }) - beforeAll(async () => { + it('should have clientInfo in middleware manifest', async () => { + const middlewareManifestPath = join( + distDir, + 'server', + 'middleware-manifest.json' + ) + const content = JSON.parse( + await fs.readFile(middlewareManifestPath, 'utf8') + ) + for (const item of [ + ['/', true], + ['/next-api/image', true], + ['/next-api/link', true], + ['/routes/[dynamic]', true], + ]) { + expect(content.clientInfo).toContainEqual(item) + } + expect(content.clientInfo).not.toContainEqual([['/404', true]]) + }) + } + }, + beforeAll: () => { error500Page.write(page500) - context.appPort = await findPort() - context.server = await nextDev(context.appDir, context.appPort) - }) - afterAll(async () => { + }, + afterAll: () => { error500Page.delete() - await killApp(context.server) - }) - - it('should have content-type and content-encoding headers', async () => { - const res = await fetchViaHTTP(context.appPort, '/') - expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8') - expect(res.headers.get('content-encoding')).toBe('gzip') - }) - - const options = { runtime: 'edge', env: 'dev' } - basic(context, options) - streaming(context, options) - rsc(context, options) - runtime(context, options) -}) + }, +} const nodejsRuntimeBasicSuite = { runTests: (context, env) => { @@ -259,6 +236,8 @@ const documentSuite = { runSuite('Node.js runtime', 'dev', nodejsRuntimeBasicSuite) runSuite('Node.js runtime', 'prod', nodejsRuntimeBasicSuite) +runSuite('Edge runtime', 'dev', edgeRuntimeBasicSuite) +runSuite('Edge runtime', 'prod', edgeRuntimeBasicSuite) runSuite('Custom App', 'dev', customAppPageSuite) runSuite('Custom App', 'prod', customAppPageSuite) @@ -276,7 +255,10 @@ function runSuite(suiteName, env, options) { options.beforeAll?.() if (env === 'prod') { context.appPort = await findPort() - context.code = (await nextBuild(context.appDir)).code + const { stdout, stderr, code } = await nextBuild(context.appDir) + context.stdout = stdout + context.stderr = stderr + context.code = code context.server = await nextStart(context.appDir, context.appPort) } if (env === 'dev') { diff --git a/test/integration/react-streaming-and-server-components/test/rsc.js b/test/integration/react-streaming-and-server-components/test/rsc.js index e96e8dd40f849..0444891c15815 100644 --- a/test/integration/react-streaming-and-server-components/test/rsc.js +++ b/test/integration/react-streaming-and-server-components/test/rsc.js @@ -15,7 +15,6 @@ export default function (context, { runtime, env }) { // should have only 1 DOCTYPE expect(homeHTML).toMatch(/^ { @@ -49,6 +61,7 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/static', { isStatic: true, isEdge: false, + isRSC: false, }) }) @@ -56,6 +69,7 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/node', { isStatic: true, isEdge: false, + isRSC: false, }) }) @@ -63,6 +77,7 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/node-ssr', { isStatic: false, isEdge: false, + isRSC: false, }) }) @@ -70,6 +85,7 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/node-ssg', { isStatic: true, isEdge: false, + isRSC: false, }) }) @@ -77,6 +93,7 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/node-rsc', { isStatic: true, isEdge: false, + isRSC: true, }) }) @@ -84,6 +101,7 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/node-rsc-ssr', { isStatic: false, isEdge: false, + isRSC: true, }) }) @@ -91,6 +109,7 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/node-rsc-ssg', { isStatic: true, isEdge: false, + isRSC: true, }) }) @@ -106,7 +125,7 @@ describe('Switchable runtime (prod)', () => { expect(renderedAt1).toBe(renderedAt2) // Trigger a revalidation after 3s. - await new Promise((resolve) => setTimeout(resolve, 4000)) + await waitFor(4000) await renderViaHTTP(context.appPort, '/node-rsc-isr') await check(async () => { @@ -122,6 +141,7 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/edge', { isStatic: false, isEdge: true, + isRSC: false, }) }) @@ -129,6 +149,7 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/edge-rsc', { isStatic: false, isEdge: true, + isRSC: true, }) }) @@ -137,7 +158,9 @@ describe('Switchable runtime (prod)', () => { /^[┌├└/]/.test(line) ) const expectedOutputLines = splitLines(` - ┌ ○ /404 + ┌ /_app + ├ /_app.server + ├ ○ /404 ├ ℇ /edge ├ ℇ /edge-rsc ├ ○ /node @@ -149,9 +172,11 @@ describe('Switchable runtime (prod)', () => { ├ λ /node-ssr └ ○ /static `) - const isMatched = expectedOutputLines.every((line, index) => - stdoutLines[index].startsWith(line) - ) + const isMatched = expectedOutputLines.every((line, index) => { + const matched = stdoutLines[index].startsWith(line) + return matched + }) + expect(isMatched).toBe(true) })