diff --git a/docs/advanced-features/react-18/server-components.md b/docs/advanced-features/react-18/server-components.md index 4426f7eda2e7c..2a919d97598bd 100644 --- a/docs/advanced-features/react-18/server-components.md +++ b/docs/advanced-features/react-18/server-components.md @@ -94,15 +94,7 @@ export default function Document() { ### `next/app` -If you're using `_app.js`, the usage is the same as [Custom App](/docs/advanced-features/custom-app). -If you're using `_app.server.js` as a server component, see the example below where it only receives the `children` prop as React elements. You can wrap any other client or server components around `children` to customize the layout of your app. - -```js -// pages/_app.server.js -export default function App({ children }) { - return children -} -``` +The usage of `_app.js` is the same as [Custom App](/docs/advanced-features/custom-app). Using custom app as server component such as `_app.server.js` is not recommended, to keep align with non server components apps for client specific things like global CSS imports. ### Routing diff --git a/packages/next/build/analysis/extract-const-value.ts b/packages/next/build/analysis/extract-const-value.ts new file mode 100644 index 0000000000000..4d7190b7d773e --- /dev/null +++ b/packages/next/build/analysis/extract-const-value.ts @@ -0,0 +1,189 @@ +import type { + ArrayExpression, + BooleanLiteral, + ExportDeclaration, + Identifier, + KeyValueProperty, + Module, + Node, + NullLiteral, + NumericLiteral, + ObjectExpression, + StringLiteral, + VariableDeclaration, +} from '@swc/core' + +/** + * Extracts the value of an exported const variable named `exportedName` + * (e.g. "export const config = { runtime: 'edge' }") from swc's AST. + * The value must be one of (or throws UnsupportedValueError): + * - string + * - boolean + * - number + * - null + * - undefined + * - array containing values listed in this list + * - object containing values listed in this list + * + * Throws NoSuchDeclarationError if the declaration is not found. + */ +export function extractExportedConstValue( + module: Module, + exportedName: string +): any { + for (const moduleItem of module.body) { + if (!isExportDeclaration(moduleItem)) { + continue + } + + const declaration = moduleItem.declaration + if (!isVariableDeclaration(declaration)) { + continue + } + + if (declaration.kind !== 'const') { + continue + } + + for (const decl of declaration.declarations) { + if ( + isIdentifier(decl.id) && + decl.id.value === exportedName && + decl.init + ) { + return extractValue(decl.init) + } + } + } + + throw new NoSuchDeclarationError() +} + +/** + * A wrapper on top of `extractExportedConstValue` that returns undefined + * instead of throwing when the thrown error is known. + */ +export function tryToExtractExportedConstValue( + module: Module, + exportedName: string +) { + try { + return extractExportedConstValue(module, exportedName) + } catch (error) { + if ( + error instanceof UnsupportedValueError || + error instanceof NoSuchDeclarationError + ) { + return undefined + } + } +} + +function isExportDeclaration(node: Node): node is ExportDeclaration { + return node.type === 'ExportDeclaration' +} + +function isVariableDeclaration(node: Node): node is VariableDeclaration { + return node.type === 'VariableDeclaration' +} + +function isIdentifier(node: Node): node is Identifier { + return node.type === 'Identifier' +} + +function isBooleanLiteral(node: Node): node is BooleanLiteral { + return node.type === 'BooleanLiteral' +} + +function isNullLiteral(node: Node): node is NullLiteral { + return node.type === 'NullLiteral' +} + +function isStringLiteral(node: Node): node is StringLiteral { + return node.type === 'StringLiteral' +} + +function isNumericLiteral(node: Node): node is NumericLiteral { + return node.type === 'NumericLiteral' +} + +function isArrayExpression(node: Node): node is ArrayExpression { + return node.type === 'ArrayExpression' +} + +function isObjectExpression(node: Node): node is ObjectExpression { + return node.type === 'ObjectExpression' +} + +function isKeyValueProperty(node: Node): node is KeyValueProperty { + return node.type === 'KeyValueProperty' +} + +class UnsupportedValueError extends Error {} +class NoSuchDeclarationError extends Error {} + +function extractValue(node: Node): any { + if (isNullLiteral(node)) { + return null + } else if (isBooleanLiteral(node)) { + // e.g. true / false + return node.value + } else if (isStringLiteral(node)) { + // e.g. "abc" + return node.value + } else if (isNumericLiteral(node)) { + // e.g. 123 + return node.value + } else if (isIdentifier(node)) { + switch (node.value) { + case 'undefined': + return undefined + default: + throw new UnsupportedValueError() + } + } else if (isArrayExpression(node)) { + // e.g. [1, 2, 3] + const arr = [] + for (const elem of node.elements) { + if (elem) { + if (elem.spread) { + // e.g. [ ...a ] + throw new UnsupportedValueError() + } + + arr.push(extractValue(elem.expression)) + } else { + // e.g. [1, , 2] + // ^^ + arr.push(undefined) + } + } + return arr + } else if (isObjectExpression(node)) { + // e.g. { a: 1, b: 2 } + const obj: any = {} + for (const prop of node.properties) { + if (!isKeyValueProperty(prop)) { + // e.g. { ...a } + throw new UnsupportedValueError() + } + + let key + if (isIdentifier(prop.key)) { + // e.g. { a: 1, b: 2 } + key = prop.key.value + } else if (isStringLiteral(prop.key)) { + // e.g. { "a": 1, "b": 2 } + key = prop.key.value + } else { + throw new UnsupportedValueError() + } + + obj[key] = extractValue(prop.value) + } + + return obj + } else { + throw new UnsupportedValueError() + } +} diff --git a/packages/next/build/analysis/get-page-static-info.ts b/packages/next/build/analysis/get-page-static-info.ts new file mode 100644 index 0000000000000..1daab5c055c11 --- /dev/null +++ b/packages/next/build/analysis/get-page-static-info.ts @@ -0,0 +1,119 @@ +import type { PageRuntime } from '../../server/config-shared' +import type { NextConfig } from '../../server/config-shared' +import { tryToExtractExportedConstValue } from './extract-const-value' +import { parseModule } from './parse-module' +import { promises as fs } from 'fs' + +export interface PageStaticInfo { + runtime?: PageRuntime + ssg?: boolean + ssr?: boolean +} + +/** + * For a given pageFilePath and nextConfig, if the config supports it, this + * function will read the file and return the runtime that should be used. + * It will look into the file content only if the page *requires* a runtime + * to be specified, that is, when gSSP or gSP is used. + * Related discussion: https://github.com/vercel/next.js/discussions/34179 + */ +export async function getPageStaticInfo(params: { + nextConfig: Partial + pageFilePath: string + isDev?: boolean +}): Promise { + const { isDev, pageFilePath, nextConfig } = params + + const fileContent = (await tryToReadFile(pageFilePath, !isDev)) || '' + if (/runtime|getStaticProps|getServerSideProps/.test(fileContent)) { + const swcAST = await parseModule(pageFilePath, fileContent) + const { ssg, ssr } = checkExports(swcAST) + const config = tryToExtractExportedConstValue(swcAST, 'config') || {} + if (config?.runtime === 'edge') { + return { + runtime: config.runtime, + ssr: ssr, + ssg: ssg, + } + } + + // For Node.js runtime, we do static optimization. + if (config?.runtime === 'nodejs') { + return { + runtime: ssr || ssg ? config.runtime : undefined, + ssr: ssr, + ssg: ssg, + } + } + + // When the runtime is required because there is ssr or ssg we fallback + if (ssr || ssg) { + return { + runtime: nextConfig.experimental?.runtime, + ssr: ssr, + ssg: ssg, + } + } + } + + return { ssr: false, ssg: false } +} + +/** + * Receives a parsed AST from SWC and checks if it belongs to a module that + * requires a runtime to be specified. Those are: + * - Modules with `export function getStaticProps | getServerSideProps` + * - Modules with `export { getStaticProps | getServerSideProps } ` + */ +function checkExports(swcAST: any) { + if (Array.isArray(swcAST?.body)) { + try { + for (const node of swcAST.body) { + if ( + node.type === 'ExportDeclaration' && + node.declaration?.type === 'FunctionDeclaration' && + ['getStaticProps', 'getServerSideProps'].includes( + node.declaration.identifier?.value + ) + ) { + return { + ssg: node.declaration.identifier.value === 'getStaticProps', + ssr: node.declaration.identifier.value === 'getServerSideProps', + } + } + + if (node.type === 'ExportNamedDeclaration') { + const values = node.specifiers.map( + (specifier: any) => + specifier.type === 'ExportSpecifier' && + specifier.orig?.type === 'Identifier' && + specifier.orig?.value + ) + + return { + ssg: values.some((value: any) => + ['getStaticProps'].includes(value) + ), + ssr: values.some((value: any) => + ['getServerSideProps'].includes(value) + ), + } + } + } + } catch (err) {} + } + + return { ssg: false, ssr: false } +} + +async function tryToReadFile(filePath: string, shouldThrow: boolean) { + try { + return await fs.readFile(filePath, { + encoding: 'utf8', + }) + } catch (error) { + if (shouldThrow) { + throw error + } + } +} diff --git a/packages/next/build/analysis/parse-module.ts b/packages/next/build/analysis/parse-module.ts new file mode 100644 index 0000000000000..5ba1dd24a15c3 --- /dev/null +++ b/packages/next/build/analysis/parse-module.ts @@ -0,0 +1,15 @@ +import LRUCache from 'next/dist/compiled/lru-cache' +import { withPromiseCache } from '../../lib/with-promise-cache' +import { createHash } from 'crypto' +import { parse } from '../swc' + +/** + * Parses a module with SWC using an LRU cache where the parsed module will + * be indexed by a sha of its content holding up to 500 entries. + */ +export const parseModule = withPromiseCache( + new LRUCache({ max: 500 }), + async (filename: string, content: string) => + parse(content, { isModule: 'unknown', filename }).catch(() => null), + (_, content) => createHash('sha1').update(content).digest('hex') +) diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index 2cb71fe9497a8..f6f7bec71a5e8 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -1,12 +1,11 @@ import type { ClientPagesLoaderOptions } from './webpack/loaders/next-client-pages-loader' import type { MiddlewareLoaderOptions } from './webpack/loaders/next-middleware-loader' import type { MiddlewareSSRLoaderQuery } from './webpack/loaders/next-middleware-ssr-loader' -import type { NextConfigComplete, NextConfig } from '../server/config-shared' +import type { NextConfigComplete } from '../server/config-shared' import type { PageRuntime } from '../server/config-shared' import type { ServerlessLoaderQuery } from './webpack/loaders/next-serverless-loader' import type { webpack5 } from 'next/dist/compiled/webpack/webpack' import type { LoadedEnvFiles } from '@next/env' -import fs from 'fs' import chalk from 'next/dist/compiled/chalk' import { posix, join } from 'path' import { stringify } from 'querystring' @@ -29,8 +28,8 @@ import { import { __ApiPreviewProps } from '../server/api-utils' import { isTargetLikeServerless } from '../server/utils' import { warn } from './output/log' -import { parse } from '../build/swc' -import { isServerComponentPage, withoutRSCExtensions } from './utils' +import { isServerComponentPage } from './utils' +import { getPageStaticInfo } from './analysis/get-page-static-info' import { normalizePathSep } from '../shared/lib/page-path/normalize-path-sep' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' import { serverComponentRegex } from './webpack/loaders/utils' @@ -38,17 +37,11 @@ import { serverComponentRegex } from './webpack/loaders/utils' type ObjectValue = T extends { [key: string]: infer V } ? V : never /** - * For a given page path removes the provided extensions. `/_app.server` is a - * special case because it is the only page where we want to preserve the RSC - * server extension. + * For a given page path removes the provided extensions. */ export function getPageFromPath(pagePath: string, pageExtensions: string[]) { - const extensions = pagePath.includes('/_app.server.') - ? withoutRSCExtensions(pageExtensions) - : pageExtensions - let page = normalizePathSep( - pagePath.replace(new RegExp(`\\.+(${extensions.join('|')})$`), '') + pagePath.replace(new RegExp(`\\.+(${pageExtensions.join('|')})$`), '') ) page = page.replace(/\/index$/, '') @@ -119,7 +112,6 @@ export function createPagesMapping({ if (isDev) { delete pages['/_app'] - delete pages['/_app.server'] delete pages['/_error'] delete pages['/_document'] } @@ -133,134 +125,10 @@ export function createPagesMapping({ '/_app': `${root}/_app`, '/_error': `${root}/_error`, '/_document': `${root}/_document`, - ...(hasServerComponents ? { '/_app.server': `${root}/_app.server` } : {}), ...pages, } } -type PageStaticInfo = { runtime?: PageRuntime; ssr?: boolean; ssg?: boolean } - -const cachedPageStaticInfo = new Map() - -// @TODO: We should limit the maximum concurrency of this function as there -// could be thousands of pages existing. -export async function getPageStaticInfo( - pageFilePath: string, - nextConfig: Partial, - isDev?: boolean -): Promise { - const globalRuntime = nextConfig.experimental?.runtime - const cached = cachedPageStaticInfo.get(pageFilePath) - if (cached) { - return cached[1] - } - - let pageContent: string - try { - pageContent = await fs.promises.readFile(pageFilePath, { - encoding: 'utf8', - }) - } catch (err) { - if (!isDev) throw err - return {} - } - - // When gSSP or gSP is used, this page requires an execution runtime. If the - // page config is not present, we fallback to the global runtime. Related - // discussion: - // https://github.com/vercel/next.js/discussions/34179 - let isRuntimeRequired: boolean = false - let pageRuntime: PageRuntime = undefined - let ssr = false - let ssg = false - - // Since these configurations should always be static analyzable, we can - // skip these cases that "runtime" and "gSP", "gSSP" are not included in the - // source code. - if (/runtime|getStaticProps|getServerSideProps/.test(pageContent)) { - try { - const { body } = await parse(pageContent, { - filename: pageFilePath, - isModule: 'unknown', - }) - - for (const node of body) { - const { type, declaration } = node - if (type === 'ExportDeclaration') { - // Match `export const config` - const valueNode = declaration?.declarations?.[0] - if (valueNode?.id?.value === 'config') { - const props = valueNode.init.properties - const runtimeKeyValue = props.find( - (prop: any) => prop.key.value === 'runtime' - ) - const runtime = runtimeKeyValue?.value?.value - pageRuntime = - runtime === 'edge' || runtime === 'nodejs' ? runtime : pageRuntime - } else if (declaration?.type === 'FunctionDeclaration') { - // Match `export function getStaticProps | getServerSideProps` - const identifier = declaration.identifier?.value - if ( - identifier === 'getStaticProps' || - identifier === 'getServerSideProps' - ) { - isRuntimeRequired = true - ssg = identifier === 'getStaticProps' - ssr = identifier === 'getServerSideProps' - } - } - } else if (type === 'ExportNamedDeclaration') { - // Match `export { getStaticProps | getServerSideProps } ` - const { specifiers } = node - for (const specifier of specifiers) { - const { orig } = specifier - const hasDataFetchingExports = - specifier.type === 'ExportSpecifier' && - orig?.type === 'Identifier' && - (orig?.value === 'getStaticProps' || - orig?.value === 'getServerSideProps') - if (hasDataFetchingExports) { - isRuntimeRequired = true - ssg = orig.value === 'getStaticProps' - ssr = orig.value === 'getServerSideProps' - break - } - } - } - } - } catch (err) {} - } - - if (!pageRuntime) { - if (isRuntimeRequired) { - pageRuntime = globalRuntime - } - } else { - // For Node.js runtime, we do static optimization. - if (!isRuntimeRequired && pageRuntime === 'nodejs') { - pageRuntime = undefined - } - } - - const info = { - runtime: pageRuntime, - ssr, - ssg, - } - cachedPageStaticInfo.set(pageFilePath, [Date.now(), info]) - return info -} - -export function invalidatePageRuntimeCache( - pageFilePath: string, - safeTime: number -) { - const cached = cachedPageStaticInfo.get(pageFilePath) - if (cached && cached[0] < safeTime) { - cachedPageStaticInfo.delete(pageFilePath) - } -} - interface CreateEntrypointsParams { buildId: string config: NextConfigComplete @@ -299,7 +167,6 @@ export function getEdgeServerEntry(opts: { const loaderParams: MiddlewareSSRLoaderQuery = { absolute500Path: opts.pages['/500'] || '', absoluteAppPath: opts.pages['/_app'], - absoluteAppServerPath: opts.pages['/_app.server'], absoluteDocumentPath: opts.pages['/_document'], absoluteErrorPath: opts.pages['/_error'], absolutePagePath: opts.absolutePagePath, @@ -343,7 +210,6 @@ export function getServerlessEntry(opts: { const loaderParams: ServerlessLoaderQuery = { absolute404Path: opts.pages['/404'] || '', absoluteAppPath: opts.pages['/_app'], - absoluteAppServerPath: opts.pages['/_app.server'], absoluteDocumentPath: opts.pages['/_document'], absoluteErrorPath: opts.pages['/_error'], absolutePagePath: opts.absolutePagePath, @@ -457,10 +323,15 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { const isServerComponent = serverComponentRegex.test(absolutePagePath) + const staticInfo = await getPageStaticInfo({ + nextConfig: config, + pageFilePath, + isDev, + }) + runDependingOnPageType({ page, - pageRuntime: (await getPageStaticInfo(pageFilePath, config, isDev)) - .runtime, + pageRuntime: staticInfo.runtime, onClient: () => { if (isServerComponent) { // We skip the initial entries for server component pages and let the diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index cd7cc927ac4cc..3024221520fec 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -76,11 +76,8 @@ import { } from '../telemetry/events' import { Telemetry } from '../telemetry/storage' import { runCompiler } from './compiler' -import { - createEntrypoints, - createPagesMapping, - getPageStaticInfo, -} from './entries' +import { getPageStaticInfo } from './analysis/get-page-static-info' +import { createEntrypoints, createPagesMapping } from './entries' import { generateBuildId } from './generate-build-id' import { isWriteable } from './is-writeable' import * as Log from './output/log' @@ -1088,9 +1085,14 @@ export default async function build( p.startsWith(actualPage + '.') || p.startsWith(actualPage + '/index.') ) + const pageRuntime = pagePath - ? (await getPageStaticInfo(join(pagesDir, pagePath), config)) - .runtime + ? ( + await getPageStaticInfo({ + pageFilePath: join(pagesDir, pagePath), + nextConfig: config, + }) + ).runtime : undefined if (hasServerComponents && pagePath) { diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 2ce5efc338cee..5ab0c775c5ef9 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -47,7 +47,7 @@ import { Sema } from 'next/dist/compiled/async-sema' import { MiddlewareManifest } from './webpack/plugins/middleware-plugin' import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' -import { getPageStaticInfo } from './entries' +import { getPageStaticInfo } from './analysis/get-page-static-info' const { builtinModules } = require('module') const RESERVED_PAGE = /^\/(_app|_error|_document|api(\/|$))/ @@ -141,12 +141,6 @@ 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')), static: useStatic404, @@ -172,8 +166,7 @@ export async function printTreeView( !( e === '/_document' || e === '/_error' || - (!hasCustomApp && e === '/_app') || - (!hasCustomAppServer && e === '/_app.server') + (!hasCustomApp && e === '/_app') ) ) .sort((a, b) => a.localeCompare(b)) @@ -1302,9 +1295,14 @@ export async function isEdgeRuntimeCompiled( } } + const staticInfo = await getPageStaticInfo({ + pageFilePath: module.resource, + nextConfig: config, + }) + // Check the page runtime as well since we cannot detect the runtime from // compilation when it's for the client part of edge function - return (await getPageStaticInfo(module.resource, config)).runtime === 'edge' + return staticInfo.runtime === 'edge' } export function getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage( diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index cceb0e81c7aed..a6e9c45c9b3c2 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -606,19 +606,12 @@ export default async function getBaseWebpackConfig( if (dev) { customAppAliases[`${PAGES_DIR_ALIAS}/_app`] = [ - ...rawPageExtensions.reduce((prev, ext) => { + ...config.pageExtensions.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}`)) 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 5ac54d0992ecd..5feb565ac9043 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 @@ -4,7 +4,6 @@ import { stringifyRequest } from '../../stringify-request' export type MiddlewareSSRLoaderQuery = { absolute500Path: string absoluteAppPath: string - absoluteAppServerPath: string absoluteDocumentPath: string absoluteErrorPath: string absolutePagePath: string @@ -22,7 +21,6 @@ export default async function middlewareSSRLoader(this: any) { buildId, absolutePagePath, absoluteAppPath, - absoluteAppServerPath, absoluteDocumentPath, absolute500Path, absoluteErrorPath, @@ -42,10 +40,6 @@ 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 @@ -61,9 +55,6 @@ 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 = ${ @@ -85,7 +76,6 @@ 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 e186b66087d20..2013b9058e2cf 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,7 +27,6 @@ export function getRender({ serverComponentManifest, config, buildId, - appServerMod, }: { dev: boolean page: string @@ -49,8 +48,6 @@ export function getRender({ reactLoadableManifest, 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 0141539a841db..533f4d64aa7a2 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/index.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/index.ts @@ -18,7 +18,6 @@ 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 806299a229f48..30b1daf40e415 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 AppMod + let App let config let Document let Error @@ -78,7 +78,7 @@ export function getPageHandler(ctx: ServerlessHandlerCtx) { getServerSideProps, getStaticPaths, Component, - AppMod, + App, config, { default: Document }, { default: Error }, @@ -103,7 +103,7 @@ export function getPageHandler(ctx: ServerlessHandlerCtx) { setLazyProp({ req: req as any }, 'cookies', getCookieParser(req.headers)) const options = { - AppMod, + App, Document, ComponentMod: { default: Component }, buildManifest, diff --git a/packages/next/build/webpack/plugins/flight-manifest-plugin.ts b/packages/next/build/webpack/plugins/flight-manifest-plugin.ts index 197dbbdf47a09..f0362f7c1b1a4 100644 --- a/packages/next/build/webpack/plugins/flight-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/flight-manifest-plugin.ts @@ -19,7 +19,7 @@ import { getInvalidator, entries, } from '../../../server/dev/on-demand-entry-handler' -import { getPageStaticInfo } from '../../entries' +import { getPageStaticInfo } from '../../analysis/get-page-static-info' // 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. @@ -96,8 +96,6 @@ export class FlightManifestPlugin { // For each SC server compilation entry, we need to create its corresponding // client component entry. for (const [name, entry] of compilation.entries.entries()) { - if (name === 'pages/_app.server') continue - // Check if the page entry is a server component or not. const entryDependency = entry.dependencies?.[0] const request = entryDependency?.request @@ -137,7 +135,11 @@ export class FlightManifestPlugin { // Parse gSSP and gSP exports from the page source. const pageStaticInfo = this.isEdgeServer ? {} - : await getPageStaticInfo(routeInfo.absolutePagePath, {}, this.dev) + : await getPageStaticInfo({ + pageFilePath: routeInfo.absolutePagePath, + nextConfig: {}, + isDev: this.dev, + }) const clientLoader = `next-flight-client-entry-loader?${stringify({ modules: clientComponentImports, diff --git a/packages/next/build/webpack/plugins/pages-manifest-plugin.ts b/packages/next/build/webpack/plugins/pages-manifest-plugin.ts index 4cb3e3640511c..068d732d13ba7 100644 --- a/packages/next/build/webpack/plugins/pages-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/pages-manifest-plugin.ts @@ -60,7 +60,7 @@ export default class PagesManifestPlugin implements webpack.Plugin { file.endsWith('.js') ) - // Skip _app.server entry which is empty + // Skip entries which are empty if (!files.length) { continue } diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index 662ba6b8bd147..f3b03a6a85d8a 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -86,7 +86,6 @@ let webpackHMR: any let CachedApp: AppComponent, onPerfEntry: (metric: any) => void let CachedComponent: React.ComponentType -let isRSCPage: boolean class Container extends React.Component<{ fn: (err: Error, info?: any) => void @@ -331,7 +330,6 @@ 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') @@ -647,12 +645,7 @@ function AppContainer({ } function renderApp(App: AppComponent, appProps: AppProps) { - if (process.env.__NEXT_RSC && isRSCPage) { - const { Component, err: _, router: __, ...props } = appProps - return - } else { - return - } + return } const wrapApp = diff --git a/packages/next/client/next-dev.js b/packages/next/client/next-dev.js index a98a45213902a..7faab941066a1 100644 --- a/packages/next/client/next-dev.js +++ b/packages/next/client/next-dev.js @@ -15,7 +15,7 @@ if (!window._nextSetupHydrationWarning) { const isHydrateError = args.some( (arg) => typeof arg === 'string' && - arg.match(/Warning:.*?did not match.*?Server:/) + arg.match(/(hydration|content does not match|did not match)/i) ) if (isHydrateError) { args = [ diff --git a/packages/next/export/index.ts b/packages/next/export/index.ts index fd08ec9ab0b6d..669a6a1c5708d 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -237,12 +237,7 @@ export default async function exportApp( continue } - if ( - page === '/_document' || - page === '/_app.server' || - page === '/_app' || - page === '/_error' - ) { + if (page === '/_document' || page === '/_app' || page === '/_error') { continue } diff --git a/packages/next/lib/with-promise-cache.ts b/packages/next/lib/with-promise-cache.ts new file mode 100644 index 0000000000000..a007210c47013 --- /dev/null +++ b/packages/next/lib/with-promise-cache.ts @@ -0,0 +1,32 @@ +/* eslint-disable no-redeclare */ +interface Cache { + set(key: K, value: V, maxAge?: number): boolean + get(key: K): V | undefined + del(key: K): void +} + +export function withPromiseCache( + cache: Cache>, + fn: (value: K) => Promise +): (value: K) => Promise +export function withPromiseCache( + cache: Cache>, + fn: (...values: T) => Promise, + getKey: (...values: T) => K +): (...values: T) => Promise +export function withPromiseCache( + cache: Cache>, + fn: (...values: T) => Promise, + getKey?: (...values: T) => K +): (...values: T) => Promise { + return (...values: T) => { + const key = getKey ? getKey(...values) : values[0] + let p = cache.get(key) + if (!p) { + p = fn(...values) + p.catch(() => cache.del(key)) + cache.set(key, p) + } + return p + } +} diff --git a/packages/next/pages/_app.server.tsx b/packages/next/pages/_app.server.tsx deleted file mode 100644 index 8dbc25dc5634e..0000000000000 --- a/packages/next/pages/_app.server.tsx +++ /dev/null @@ -1,3 +0,0 @@ -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 736aa19556719..d98a9f5640ed3 100644 --- a/packages/next/server/dev/hot-reloader.ts +++ b/packages/next/server/dev/hot-reloader.ts @@ -43,7 +43,7 @@ import { Span, trace } from '../../trace' import { getProperError } from '../../lib/is-error' import ws from 'next/dist/compiled/ws' import { promises as fs } from 'fs' -import { getPageStaticInfo } from '../../build/entries' +import { getPageStaticInfo } from '../../build/analysis/get-page-static-info' import { serverComponentRegex } from '../../build/webpack/loaders/utils' import { stringify } from 'querystring' @@ -566,11 +566,14 @@ export default class HotReloader { const isServerComponent = serverComponentRegex.test(absolutePagePath) + const staticInfo = await getPageStaticInfo({ + pageFilePath: absolutePagePath, + nextConfig: this.config, + }) + runDependingOnPageType({ page, - pageRuntime: ( - await getPageStaticInfo(absolutePagePath, this.config) - ).runtime, + pageRuntime: staticInfo.runtime, onEdgeServer: () => { if (!isEdgeServerCompilation) return entries[pageKey].status = BUILDING diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index ef64202f5295f..42787442a60b8 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -65,10 +65,7 @@ import { import { getSortedRoutes, isDynamicRoute } from '../../shared/lib/router/utils' import { runDependingOnPageType } from '../../build/entries' import { NodeNextResponse, NodeNextRequest } from '../base-http/node' -import { - getPageStaticInfo, - invalidatePageRuntimeCache, -} from '../../build/entries' +import { getPageStaticInfo } from '../../build/analysis/get-page-static-info' import { normalizePathSep } from '../../shared/lib/page-path/normalize-path-sep' import { normalizeViewPath } from '../../shared/lib/router/utils/view-paths' import { MIDDLEWARE_FILE } from '../../lib/constants' @@ -345,11 +342,14 @@ export default class DevServer extends Server { continue } - invalidatePageRuntimeCache(fileName, meta.safeTime) + const staticInfo = await getPageStaticInfo({ + pageFilePath: fileName, + nextConfig: this.nextConfig, + }) + runDependingOnPageType({ page: pageName, - pageRuntime: (await getPageStaticInfo(fileName, this.nextConfig)) - .runtime, + pageRuntime: staticInfo.runtime, onClient: () => {}, onServer: () => {}, onEdgeServer: () => { diff --git a/packages/next/server/dev/on-demand-entry-handler.ts b/packages/next/server/dev/on-demand-entry-handler.ts index 6e877315e316c..7bc5fe00260fe 100644 --- a/packages/next/server/dev/on-demand-entry-handler.ts +++ b/packages/next/server/dev/on-demand-entry-handler.ts @@ -3,7 +3,7 @@ import type { webpack5 as webpack } from 'next/dist/compiled/webpack/webpack' import type { NextConfigComplete } from '../config-shared' import { EventEmitter } from 'events' import { findPageFile } from '../lib/find-page-file' -import { getPageStaticInfo, runDependingOnPageType } from '../../build/entries' +import { runDependingOnPageType } from '../../build/entries' import { join, posix } from 'path' import { normalizePathSep } from '../../shared/lib/page-path/normalize-path-sep' import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' @@ -14,6 +14,7 @@ import { reportTrigger } from '../../build/output' import getRouteFromEntrypoint from '../get-route-from-entrypoint' import { serverComponentRegex } from '../../build/webpack/loaders/utils' import { MIDDLEWARE_FILE, MIDDLEWARE_FILENAME } from '../../lib/constants' +import { getPageStaticInfo } from '../../build/analysis/get-page-static-info' export const ADDED = Symbol('added') export const BUILDING = Symbol('building') @@ -236,11 +237,14 @@ export function onDemandEntryHandler({ }) } + const staticInfo = await getPageStaticInfo({ + pageFilePath: pagePathData.absolutePagePath, + nextConfig, + }) + const promises = runDependingOnPageType({ page: pagePathData.page, - pageRuntime: ( - await getPageStaticInfo(pagePathData.absolutePagePath, nextConfig) - ).runtime, + pageRuntime: staticInfo.runtime, onClient: () => addPageEntry('client'), onServer: () => addPageEntry('server'), onEdgeServer: () => addPageEntry('edge-server'), diff --git a/packages/next/server/load-components.ts b/packages/next/server/load-components.ts index b8cebe97259cc..7db624cfb4483 100644 --- a/packages/next/server/load-components.ts +++ b/packages/next/server/load-components.ts @@ -40,8 +40,6 @@ export type LoadComponentsReturnType = { getStaticPaths?: GetStaticPaths getServerSideProps?: GetServerSideProps ComponentMod: any - AppMod: any - AppServerMod: any isViewPath?: boolean } @@ -60,9 +58,6 @@ export async function loadDefaultErrorComponents(distDir: string) { buildManifest: require(join(distDir, `fallback-${BUILD_MANIFEST}`)), reactLoadableManifest: {}, ComponentMod, - AppMod, - // Use App for fallback - AppServerMod: AppMod, } } @@ -106,7 +101,7 @@ export async function loadComponents( } as LoadComponentsReturnType } - const [DocumentMod, AppMod, ComponentMod, AppServerMod] = await Promise.all([ + const [DocumentMod, AppMod, ComponentMod] = await Promise.all([ Promise.resolve().then(() => requirePage('/_document', distDir, serverless, rootEnabled) ), @@ -116,11 +111,6 @@ export async function loadComponents( Promise.resolve().then(() => requirePage(pathname, distDir, serverless, rootEnabled) ), - hasServerComponents - ? Promise.resolve().then(() => - requirePage('/_app.server', distDir, serverless, rootEnabled) - ) - : null, ]) const [buildManifest, reactLoadableManifest, serverComponentManifest] = @@ -175,8 +165,6 @@ export async function loadComponents( reactLoadableManifest, pageConfig: ComponentMod.config || {}, ComponentMod, - AppMod, - AppServerMod, getServerSideProps, getStaticProps, getStaticPaths, diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index f5d421ff47e04..bfb3a0628b838 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -79,7 +79,6 @@ import { continueFromInitialStream, } from './node-web-streams-helper' import { ImageConfigContext } from '../shared/lib/image-config-context' -import { interopDefault } from '../lib/interop-default' import stripAnsi from 'next/dist/compiled/strip-ansi' import { urlQueryToSearchParams } from '../shared/lib/router/utils/querystring' import { postProcessHTML } from './post-process' @@ -197,20 +196,10 @@ function enhanceComponents( } function renderPageTree( - App: any, - Component: any, - props: any, - isServerComponent: boolean + App: AppType, + Component: NextComponentType, + props: any ) { - const { router: _, ...rest } = props - if (isServerComponent) { - return ( - - - - ) - } - return } @@ -382,7 +371,6 @@ function useFlightResponse({ // Create the wrapper component for a Flight stream. function createServerComponentRenderer( - App: any, Component: any, { cachePrefix, @@ -400,9 +388,7 @@ function createServerComponentRenderer( const id = (React as any).useId() const reqStream: ReadableStream = renderToReadableStream( - - - , + , serverComponentManifest ) @@ -466,8 +452,7 @@ export async function renderToHTML( images, runtime: globalRuntime, ComponentMod, - AppMod, - AppServerMod, + App, } = renderOpts let Document = renderOpts.Document @@ -483,8 +468,6 @@ export async function renderToHTML( renderOpts.Component const OriginComponent = Component - const App = interopDefault(isServerComponent ? AppServerMod : AppMod) - let serverComponentsInlinedTransformStream: TransformStream< Uint8Array, Uint8Array @@ -504,7 +487,7 @@ export async function renderToHTML( globalThis.__webpack_require__ = ComponentMod.__next_rsc__.__webpack_require__ - Component = createServerComponentRenderer(App, Component, { + Component = createServerComponentRenderer(Component, { cachePrefix: pathname + (search ? `?${search}` : ''), inlinedTransformStream: serverComponentsInlinedTransformStream, pageData: serverComponentsStaticPageData, @@ -722,12 +705,7 @@ export async function renderToHTML( AppTree: (props: any) => { return ( - {renderPageTree( - App, - OriginComponent, - { ...props, router }, - isServerComponent - )} + {renderPageTree(App, OriginComponent, { ...props, router })} ) }, @@ -1191,15 +1169,7 @@ export async function renderToHTML( if (renderServerComponentData) { return new RenderResult( renderToReadableStream( - renderPageTree( - App, - OriginComponent, - { - ...props.pageProps, - ...serverComponentProps, - }, - true - ), + , serverComponentManifest ).pipeThrough(createBufferedTransformStream()) ) @@ -1327,12 +1297,10 @@ export async function renderToHTML( const html = ReactDOMServer.renderToString( - {renderPageTree( - EnhancedApp, - EnhancedComponent, - { ...props, router }, - false - )} + {renderPageTree(EnhancedApp, EnhancedComponent, { + ...props, + router, + })} ) @@ -1367,13 +1335,10 @@ export async function renderToHTML( ) : ( - {renderPageTree( - // AppServer is included in the EnhancedComponent in ServerComponentWrapper - isServerComponent ? React.Fragment : EnhancedApp, - EnhancedComponent, - { ...(isServerComponent ? props.pageProps : props), router }, - isServerComponent - )} + {renderPageTree(EnhancedApp, EnhancedComponent, { + ...props, + router, + })} ) diff --git a/packages/next/taskfile.js b/packages/next/taskfile.js index b912e726ff40e..58b343b2617bd 100644 --- a/packages/next/taskfile.js +++ b/packages/next/taskfile.js @@ -1858,13 +1858,6 @@ 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') @@ -1880,10 +1873,7 @@ export async function pages_document(task, opts) { } export async function pages(task, opts) { - await task.parallel( - ['pages_app', 'pages_app_server', 'pages_error', 'pages_document'], - opts - ) + await task.parallel(['pages_app', 'pages_error', 'pages_document'], opts) } export async function telemetry(task, opts) { diff --git a/packages/react-dev-overlay/src/client.ts b/packages/react-dev-overlay/src/client.ts index 8a4a83be0b500..abb495d076c8b 100644 --- a/packages/react-dev-overlay/src/client.ts +++ b/packages/react-dev-overlay/src/client.ts @@ -11,6 +11,12 @@ function onUnhandledError(ev: ErrorEvent) { return } + if ( + error.message.match(/(hydration|content does not match|did not match)/i) + ) { + error.message += `\n\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error` + } + const e = error Bus.emit({ type: Bus.TYPE_UNHANDLED_ERROR, diff --git a/test/development/basic/hmr.test.ts b/test/development/basic/hmr.test.ts index bb54ce9f5b62b..a4f3f177e3f5a 100644 --- a/test/development/basic/hmr.test.ts +++ b/test/development/basic/hmr.test.ts @@ -26,6 +26,18 @@ describe('basic HMR', () => { }) afterAll(() => next.destroy()) + it('should show hydration error correctly', async () => { + const browser = await webdriver(next.url, '/hydration-error') + await check(async () => { + const logs = await browser.log() + return logs.some((log) => + log.message.includes('messages/react-hydration-error') + ) + ? 'success' + : JSON.stringify(logs, null, 2) + }, 'success') + }) + it('should have correct router.isReady for auto-export page', async () => { let browser = await webdriver(next.url, '/auto-export-is-ready') diff --git a/test/development/basic/hmr/pages/hydration-error.js b/test/development/basic/hmr/pages/hydration-error.js new file mode 100644 index 0000000000000..46ffdde96a2fb --- /dev/null +++ b/test/development/basic/hmr/pages/hydration-error.js @@ -0,0 +1,3 @@ +export default function Page(props) { + return

is server {typeof window}

+} diff --git a/test/integration/cli/test/index.test.js b/test/integration/cli/test/index.test.js index de5951fbcfef3..0b44cc527a1c2 100644 --- a/test/integration/cli/test/index.test.js +++ b/test/integration/cli/test/index.test.js @@ -491,12 +491,8 @@ describe('CLI Usage', () => { stdout: true, stderr: true, }) + expect((info.stderr || '').toLowerCase()).not.toContain('error') - // when a stable release is done the non-latest canary - // warning will show so skip this check for the stable release - if (pkg.version.includes('-canary')) { - expect(info.stderr || '').toBe('') - } expect(info.stdout).toMatch( new RegExp(` Operating System: diff --git a/test/integration/pnpm-support/app-multi-page/.babelrc b/test/integration/pnpm-support/app-multi-page/.babelrc deleted file mode 100644 index 1ff94f7ed28e1..0000000000000 --- a/test/integration/pnpm-support/app-multi-page/.babelrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "presets": ["next/babel"] -} diff --git a/test/integration/pnpm-support/app-multi-page/package.json b/test/integration/pnpm-support/app-multi-page/package.json deleted file mode 100644 index ae87c0708d191..0000000000000 --- a/test/integration/pnpm-support/app-multi-page/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "pnpm-app-multi-page", - "version": "1.0.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start" - }, - "dependencies": { - "react": "^17.0.2", - "react-dom": "^17.0.2" - } -} diff --git a/test/integration/pnpm-support/app/.babelrc b/test/integration/pnpm-support/app/.babelrc deleted file mode 100644 index 1ff94f7ed28e1..0000000000000 --- a/test/integration/pnpm-support/app/.babelrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "presets": ["next/babel"] -} diff --git a/test/integration/pnpm-support/app/package.json b/test/integration/pnpm-support/app/package.json deleted file mode 100644 index b39d0e9d6bc63..0000000000000 --- a/test/integration/pnpm-support/app/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "pnpm-app", - "version": "1.0.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start" - }, - "dependencies": { - "react": "^17.0.2", - "react-dom": "^17.0.2" - } -} diff --git a/test/integration/pnpm-support/test/index.test.js b/test/integration/pnpm-support/test/index.test.js deleted file mode 100644 index 0fb6337bfafb5..0000000000000 --- a/test/integration/pnpm-support/test/index.test.js +++ /dev/null @@ -1,255 +0,0 @@ -/* eslint-env jest */ -import execa from 'execa' -import fs from 'fs-extra' -import os from 'os' -import path from 'path' -import { findPort, killProcess, renderViaHTTP, waitFor } from 'next-test-utils' -import webdriver from 'next-webdriver' - -const packagesDir = path.join(__dirname, '..', '..', '..', '..', 'packages') - -const APP_DIRS = { - app: path.join(__dirname, '..', 'app'), - 'app-multi-page': path.join(__dirname, '..', 'app-multi-page'), -} - -// runs a command showing logs and returning the stdout -const runCommand = (cwd, cmd, args) => { - const proc = execa(cmd, [...args], { - cwd, - stdio: [process.stdin, 'pipe', process.stderr], - }) - - let stdout = '' - proc.stdout.on('data', (data) => { - const s = data.toString() - process.stdout.write(s) - stdout += s - }) - return new Promise((resolve, reject) => { - proc.on('exit', (code) => { - if (code === 0) { - return resolve({ ...proc, stdout }) - } - reject( - new Error(`Command ${cmd} ${args.join(' ')} failed with code ${code}`) - ) - }) - }) -} - -const runNpm = (cwd, ...args) => execa('npm', [...args], { cwd }) -const runPnpm = (cwd, ...args) => runCommand(cwd, 'npx', ['pnpm', ...args]) - -async function usingTempDir(fn) { - const folder = path.join(os.tmpdir(), Math.random().toString(36).substring(2)) - await fs.mkdirp(folder) - try { - return await fn(folder) - } finally { - await fs.remove(folder) - } -} - -/** - * Using 'npm pack', create a tarball of the given package in - * directory `pkg` and write it to `cwd`. - * - * `pkg` is relative to the monorepo 'packages/' directory. - * - * Return the absolute path to the tarball. - */ -async function pack(cwd, pkg) { - const pkgDir = path.join(packagesDir, pkg) - const { stdout } = await runNpm( - cwd, - 'pack', - '--ignore-scripts', // Skip the prepublish script - path.join(packagesDir, pkg) - ) - const tarballFilename = stdout.match(/.*\.tgz/)[0] - - if (!tarballFilename) { - throw new Error( - `npm failed to pack "next" package tarball in directory ${pkgDir}.` - ) - } - - return path.join(cwd, tarballFilename) -} - -/** - * Create a Next.js app in a temporary directory, and install dependencies with pnpm. - * - * "next" and its monorepo dependencies are installed by `npm pack`-ing tarballs from - * 'next.js/packages/*', because installing "next" directly via - * `pnpm add path/to/next.js/packages/next` results in a symlink: - * 'app/node_modules/next' -> 'path/to/next.js/packages/next'. - * This is undesired since modules inside "next" would be resolved to the - * next.js monorepo 'node_modules' and lead to false test results; - * installing from a tarball avoids this issue. - * - * The "next" package's monorepo dependencies (e.g. "@next/env", "@next/polyfill-module") - * are not bundled with `npm pack next.js/packages/next`, - * so they need to be packed individually. - * To ensure that they are installed upon installing "next", a package.json "pnpm.overrides" - * field is used to override these dependency paths at install time. - */ -async function usingPnpmCreateNextApp(appDir, fn) { - await usingTempDir(async (tempDir) => { - const nextTarballPath = await pack(tempDir, 'next') - const dependencyTarballPaths = { - '@next/env': await pack(tempDir, 'next-env'), - '@next/polyfill-module': await pack(tempDir, 'next-polyfill-module'), - '@next/polyfill-nomodule': await pack(tempDir, 'next-polyfill-nomodule'), - '@next/react-dev-overlay': await pack(tempDir, 'react-dev-overlay'), - '@next/react-refresh-utils': await pack(tempDir, 'react-refresh-utils'), - } - - const tempAppDir = path.join(tempDir, 'app') - await fs.copy(appDir, tempAppDir) - - // Inject dependency tarball paths into a "pnpm.overrides" field in package.json, - // so that they are installed from packed tarballs rather than from the npm registry. - const packageJsonPath = path.join(tempAppDir, 'package.json') - const overrides = {} - for (const [dependency, tarballPath] of Object.entries( - dependencyTarballPaths - )) { - overrides[dependency] = `file:${tarballPath}` - } - const packageJsonWithOverrides = { - ...(await fs.readJson(packageJsonPath)), - pnpm: { overrides }, - } - await fs.writeFile( - packageJsonPath, - JSON.stringify(packageJsonWithOverrides, null, 2) - ) - - await runPnpm(tempAppDir, 'install') - await runPnpm(tempAppDir, 'add', `next@${nextTarballPath}`) - - await fs.copy( - path.join(__dirname, '../../../../packages/next-swc/native'), - path.join(tempAppDir, 'node_modules/@next/swc/native') - ) - - await fn(tempAppDir) - }) -} - -describe('pnpm support', () => { - it('should build with dependencies installed via pnpm', async () => { - await usingPnpmCreateNextApp(APP_DIRS['app'], async (appDir) => { - expect( - await fs.pathExists(path.join(appDir, 'pnpm-lock.yaml')) - ).toBeTruthy() - - const packageJsonPath = path.join(appDir, 'package.json') - const packageJson = await fs.readJson(packageJsonPath) - expect(packageJson.dependencies['next']).toMatch(/^file:/) - for (const dependency of [ - '@next/env', - '@next/polyfill-module', - '@next/polyfill-nomodule', - '@next/react-dev-overlay', - '@next/react-refresh-utils', - ]) { - expect(packageJson.pnpm.overrides[dependency]).toMatch(/^file:/) - } - - const { stdout } = await runPnpm(appDir, 'run', 'build') - - expect(stdout).toMatch(/Compiled successfully/) - }) - }) - - it('should execute client-side JS on each page in outputStandalone', async () => { - await usingPnpmCreateNextApp(APP_DIRS['app-multi-page'], async (appDir) => { - const { stdout } = await runPnpm(appDir, 'run', 'build') - - expect(stdout).toMatch(/Compiled successfully/) - - let appPort - let appProcess - let browser - try { - appPort = await findPort() - const standaloneDir = path.resolve(appDir, '.next/standalone/app') - - // simulate what happens in a Dockerfile - await fs.remove(path.join(appDir, 'node_modules')) - await fs.copy( - path.resolve(appDir, './.next/static'), - path.resolve(standaloneDir, './.next/static'), - { overwrite: true } - ) - appProcess = execa('node', ['server.js'], { - cwd: standaloneDir, - env: { - PORT: appPort, - }, - stdio: 'inherit', - }) - - await waitFor(1000) - - await renderViaHTTP(appPort, '/') - - browser = await webdriver(appPort, '/', { - waitHydration: false, - }) - expect(await browser.waitForElementByCss('#world').text()).toBe('World') - await browser.close() - - browser = await webdriver(appPort, '/about', { - waitHydration: false, - }) - expect(await browser.waitForElementByCss('#world').text()).toBe('World') - await browser.close() - } finally { - await killProcess(appProcess.pid) - await waitFor(5000) - } - }) - }) - - it('should execute client-side JS on each page', async () => { - await usingPnpmCreateNextApp(APP_DIRS['app-multi-page'], async (appDir) => { - const { stdout } = await runPnpm(appDir, 'run', 'build') - - expect(stdout).toMatch(/Compiled successfully/) - - let appPort - let appProcess - let browser - try { - appPort = await findPort() - appProcess = execa('pnpm', ['run', 'start', '--', '--port', appPort], { - cwd: appDir, - stdio: 'inherit', - }) - - await waitFor(5000) - - await renderViaHTTP(appPort, '/') - - browser = await webdriver(appPort, '/', { - waitHydration: false, - }) - expect(await browser.waitForElementByCss('#world').text()).toBe('World') - await browser.close() - - browser = await webdriver(appPort, '/about', { - waitHydration: false, - }) - expect(await browser.waitForElementByCss('#world').text()).toBe('World') - await browser.close() - } finally { - await killProcess(appProcess.pid) - await waitFor(5000) - } - }) - }) -}) diff --git a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/_app.js b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/_app.js index 57b0da8210940..ec15c6af8e6f9 100644 --- a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/_app.js +++ b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/_app.js @@ -1,6 +1,6 @@ export default function App({ Component, pageProps }) { return ( -
+
) 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 deleted file mode 100644 index 15ab2f9fdc716..0000000000000 --- a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/_app.server.js +++ /dev/null @@ -1,8 +0,0 @@ -export default function AppServer({ children }) { - return ( -
- - {children} -
- ) -} 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 f038bc168a19d..0a6cbade04325 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 @@ -28,43 +28,26 @@ function getOccurrence(text, matcher) { function flight(context) { describe('flight response', () => { - it('should contain _app.server in flight response (node)', async () => { + it('should not contain _app.js in flight response (node)', async () => { const html = await renderViaHTTP(context.appPort, '/node-rsc') const flightResponse = await renderViaHTTP( context.appPort, '/node-rsc?__flight__=1' ) expect( - getOccurrence(html, new RegExp(`class="app-server-root"`, 'g')) + getOccurrence(html, new RegExp(`class="app-client-root"`, 'g')) ).toBe(1) expect( getOccurrence( flightResponse, - new RegExp(`"className":\\s*"app-server-root"`, 'g') + new RegExp(`"className":\\s*"app-client-root"`, 'g') ) - ).toBe(1) + ).toBe(0) }) }) - - it('should contain _app.server in flight response (edge)', async () => { - const html = await renderViaHTTP(context.appPort, '/edge-rsc') - const flightResponse = await renderViaHTTP( - context.appPort, - '/edge-rsc?__flight__=1' - ) - expect( - getOccurrence(html, new RegExp(`class="app-server-root"`, 'g')) - ).toBe(1) - expect( - getOccurrence( - flightResponse, - new RegExp(`"className":\\s*"app-server-root"`, 'g') - ) - ).toBe(1) - }) } -async function testRoute(appPort, url, { isStatic, isEdge, isRSC }) { +async function testRoute(appPort, url, { isStatic, isEdge }) { const html1 = await renderViaHTTP(appPort, url) const renderedAt1 = +html1.match(/Time: (\d+)/)[1] expect(html1).toContain(`Runtime: ${isEdge ? 'Edge' : 'Node.js'}`) @@ -80,15 +63,6 @@ async function testRoute(appPort, url, { isStatic, isEdge, isRSC }) { // Should be re-rendered. expect(renderedAt1).toBeLessThan(renderedAt2) } - // If the page is using 1 root, it won't use the other. - // e.g. server component page won't have client app root. - const rootClasses = ['app-server-root', 'app-client-root'] - const [rootClass, oppositeRootClass] = isRSC - ? [rootClasses[0], rootClasses[1]] - : [rootClasses[1], rootClasses[0]] - - expect(getOccurrence(html1, new RegExp(`class="${rootClass}"`, 'g'))).toBe(1) - expect(html1).not.toContain(`"${oppositeRootClass}"`) } describe('Switchable runtime (prod)', () => { @@ -114,7 +88,6 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/static', { isStatic: true, isEdge: false, - isRSC: false, }) }) @@ -122,7 +95,6 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/node', { isStatic: true, isEdge: false, - isRSC: false, }) }) @@ -130,7 +102,6 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/node-ssr', { isStatic: false, isEdge: false, - isRSC: false, }) }) @@ -138,7 +109,6 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/node-ssg', { isStatic: true, isEdge: false, - isRSC: false, }) }) @@ -146,7 +116,6 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/node-rsc', { isStatic: true, isEdge: false, - isRSC: true, }) const html = await renderViaHTTP(context.appPort, '/node-rsc') @@ -157,7 +126,6 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/node-rsc-ssr', { isStatic: false, isEdge: false, - isRSC: true, }) }) @@ -165,7 +133,6 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/node-rsc-ssg', { isStatic: true, isEdge: false, - isRSC: true, }) }) @@ -197,7 +164,6 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/edge', { isStatic: false, isEdge: true, - isRSC: false, }) }) @@ -205,7 +171,6 @@ describe('Switchable runtime (prod)', () => { await testRoute(context.appPort, '/edge-rsc', { isStatic: false, isEdge: true, - isRSC: true, }) }) @@ -215,7 +180,6 @@ describe('Switchable runtime (prod)', () => { ) const expectedOutputLines = splitLines(` ┌ /_app - ├ /_app.server ├ ○ /404 ├ ℇ /edge ├ ℇ /edge-rsc diff --git a/test/integration/pnpm-support/app-multi-page/.npmrc b/test/production/pnpm-support/app-multi-page/.npmrc similarity index 100% rename from test/integration/pnpm-support/app-multi-page/.npmrc rename to test/production/pnpm-support/app-multi-page/.npmrc diff --git a/test/integration/pnpm-support/app-multi-page/next.config.js b/test/production/pnpm-support/app-multi-page/next.config.js similarity index 100% rename from test/integration/pnpm-support/app-multi-page/next.config.js rename to test/production/pnpm-support/app-multi-page/next.config.js diff --git a/test/integration/pnpm-support/app-multi-page/pages/about.js b/test/production/pnpm-support/app-multi-page/pages/about.js similarity index 100% rename from test/integration/pnpm-support/app-multi-page/pages/about.js rename to test/production/pnpm-support/app-multi-page/pages/about.js diff --git a/test/integration/pnpm-support/app-multi-page/pages/index.js b/test/production/pnpm-support/app-multi-page/pages/index.js similarity index 100% rename from test/integration/pnpm-support/app-multi-page/pages/index.js rename to test/production/pnpm-support/app-multi-page/pages/index.js diff --git a/test/integration/pnpm-support/app/next.config.js b/test/production/pnpm-support/app/next.config.js similarity index 100% rename from test/integration/pnpm-support/app/next.config.js rename to test/production/pnpm-support/app/next.config.js diff --git a/test/integration/pnpm-support/app/pages/index.js b/test/production/pnpm-support/app/pages/index.js similarity index 100% rename from test/integration/pnpm-support/app/pages/index.js rename to test/production/pnpm-support/app/pages/index.js diff --git a/test/integration/pnpm-support/app/pages/regenerator.js b/test/production/pnpm-support/app/pages/regenerator.js similarity index 100% rename from test/integration/pnpm-support/app/pages/regenerator.js rename to test/production/pnpm-support/app/pages/regenerator.js diff --git a/test/production/pnpm-support/test/index.test.ts b/test/production/pnpm-support/test/index.test.ts new file mode 100644 index 0000000000000..f691c62ad618b --- /dev/null +++ b/test/production/pnpm-support/test/index.test.ts @@ -0,0 +1,133 @@ +/* eslint-env jest */ +import path from 'path' +import execa from 'execa' +import fs from 'fs-extra' +import webdriver from 'next-webdriver' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { + findPort, + initNextServerScript, + killApp, + renderViaHTTP, +} from 'next-test-utils' + +describe('pnpm support', () => { + let next: NextInstance | undefined + + beforeAll(async () => { + try { + const version = await execa('pnpm', ['--version']) + console.warn(`using pnpm version`, version.stdout) + } catch (_) { + // install pnpm if not available + await execa('npm', ['i', '-g', 'pnpm@latest']) + } + }) + afterEach(async () => { + try { + await next?.destroy() + } catch (_) {} + }) + + it('should build with dependencies installed via pnpm', async () => { + next = await createNext({ + files: { + pages: new FileRef(path.join(__dirname, '..', 'app/pages')), + 'next.config.js': new FileRef( + path.join(__dirname, '..', 'app/next.config.js') + ), + }, + packageJson: { + scripts: { + build: 'next build', + start: 'next start', + }, + }, + installCommand: 'pnpm install', + buildCommand: 'pnpm run build', + }) + + expect(await next.readFile('pnpm-lock.yaml')).toBeTruthy() + + expect(next.cliOutput).toMatch(/Compiled successfully/) + + const html = await renderViaHTTP(next.url, '/') + expect(html).toContain('Hello World') + }) + + it('should execute client-side JS on each page in outputStandalone', async () => { + next = await createNext({ + files: { + pages: new FileRef(path.join(__dirname, '..', 'app-multi-page/pages')), + '.npmrc': new FileRef( + path.join(__dirname, '..', 'app-multi-page/.npmrc') + ), + 'next.config.js': new FileRef( + path.join(__dirname, '..', 'app-multi-page/next.config.js') + ), + }, + packageJson: { + scripts: { + dev: 'next dev', + build: 'next build', + start: 'next start', + }, + }, + buildCommand: 'pnpm run build', + installCommand: '', + }) + await next.stop() + expect(next.cliOutput).toMatch(/Compiled successfully/) + + let appPort + let server + let browser + try { + appPort = await findPort() + const standaloneDir = path.join( + next.testDir, + '.next/standalone/', + path.basename(next.testDir) + ) + + // simulate what happens in a Dockerfile + await fs.remove(path.join(next.testDir, 'node_modules')) + await fs.copy( + path.join(next.testDir, './.next/static'), + path.join(standaloneDir, './.next/static'), + { overwrite: true } + ) + server = await initNextServerScript( + path.join(standaloneDir, 'server.js'), + /Listening/, + { + ...process.env, + PORT: appPort, + }, + undefined, + { + cwd: standaloneDir, + } + ) + + await renderViaHTTP(appPort, '/') + + browser = await webdriver(appPort, '/', { + waitHydration: false, + }) + expect(await browser.waitForElementByCss('#world').text()).toBe('World') + await browser.close() + + browser = await webdriver(appPort, '/about', { + waitHydration: false, + }) + expect(await browser.waitForElementByCss('#world').text()).toBe('World') + await browser.close() + } finally { + if (server) { + await killApp(server) + } + } + }) +}) diff --git a/test/unit/parse-page-runtime.test.ts b/test/unit/parse-page-runtime.test.ts index 496eabd20f476..50437004802ba 100644 --- a/test/unit/parse-page-runtime.test.ts +++ b/test/unit/parse-page-runtime.test.ts @@ -1,4 +1,4 @@ -import { getPageStaticInfo } from 'next/dist/build/entries' +import { getPageStaticInfo } from 'next/dist/build/analysis/get-page-static-info' import { join } from 'path' const fixtureDir = join(__dirname, 'fixtures') @@ -11,52 +11,52 @@ function createNextConfig(runtime?: 'edge' | 'nodejs') { describe('parse page runtime config', () => { it('should parse nodejs runtime correctly', async () => { - const { runtime } = await getPageStaticInfo( - join(fixtureDir, 'page-runtime/nodejs-ssr.js'), - createNextConfig() - ) + const { runtime } = await getPageStaticInfo({ + pageFilePath: join(fixtureDir, 'page-runtime/nodejs-ssr.js'), + nextConfig: createNextConfig(), + }) expect(runtime).toBe('nodejs') }) it('should parse static runtime correctly', async () => { - const { runtime } = await getPageStaticInfo( - join(fixtureDir, 'page-runtime/nodejs.js'), - createNextConfig() - ) + const { runtime } = await getPageStaticInfo({ + pageFilePath: join(fixtureDir, 'page-runtime/nodejs.js'), + nextConfig: createNextConfig(), + }) expect(runtime).toBe(undefined) }) it('should parse edge runtime correctly', async () => { - const { runtime } = await getPageStaticInfo( - join(fixtureDir, 'page-runtime/edge.js'), - createNextConfig() - ) + const { runtime } = await getPageStaticInfo({ + pageFilePath: join(fixtureDir, 'page-runtime/edge.js'), + nextConfig: createNextConfig(), + }) expect(runtime).toBe('edge') }) it('should return undefined if no runtime is specified', async () => { - const { runtime } = await getPageStaticInfo( - join(fixtureDir, 'page-runtime/static.js'), - createNextConfig() - ) + const { runtime } = await getPageStaticInfo({ + pageFilePath: join(fixtureDir, 'page-runtime/static.js'), + nextConfig: createNextConfig(), + }) expect(runtime).toBe(undefined) }) }) describe('fallback to the global runtime configuration', () => { it('should fallback when gSP is defined and exported', async () => { - const { runtime } = await getPageStaticInfo( - join(fixtureDir, 'page-runtime/fallback-with-gsp.js'), - createNextConfig('edge') - ) + const { runtime } = await getPageStaticInfo({ + pageFilePath: join(fixtureDir, 'page-runtime/fallback-with-gsp.js'), + nextConfig: createNextConfig('edge'), + }) expect(runtime).toBe('edge') }) it('should fallback when gSP is re-exported from other module', async () => { - const { runtime } = await getPageStaticInfo( - join(fixtureDir, 'page-runtime/fallback-re-export-gsp.js'), - createNextConfig('edge') - ) + const { runtime } = await getPageStaticInfo({ + pageFilePath: join(fixtureDir, 'page-runtime/fallback-re-export-gsp.js'), + nextConfig: createNextConfig('edge'), + }) expect(runtime).toBe('edge') }) })