From 47a61207384c5fb2776202b8aeb7fbbc24bc6360 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 13 Aug 2022 11:55:55 -0500 Subject: [PATCH 1/7] Update .env HMR handling (#39566) --- packages/next-env/index.ts | 10 +- packages/next/build/webpack-config.ts | 306 ++++++++++-------- packages/next/server/dev/hot-reloader.ts | 84 +++-- packages/next/server/dev/next-dev-server.ts | 45 ++- .../env-config/app/pages/another-global.js | 4 +- .../env-config/app/pages/api/all.js | 2 +- .../env-config/app/pages/global.js | 4 +- .../integration/env-config/app/pages/index.js | 16 +- .../env-config/app/pages/some-ssg.js | 16 +- .../env-config/app/pages/some-ssp.js | 16 +- .../integration/env-config/test/index.test.js | 164 ++++++++-- 11 files changed, 448 insertions(+), 219 deletions(-) diff --git a/packages/next-env/index.ts b/packages/next-env/index.ts index f8de0069cbce..a36abfc194a8 100644 --- a/packages/next-env/index.ts +++ b/packages/next-env/index.ts @@ -13,6 +13,7 @@ export type LoadedEnvFiles = Array<{ let initialEnv: Env | undefined = undefined let combinedEnv: Env | undefined = undefined let cachedLoadedEnvFiles: LoadedEnvFiles = [] +let previousLoadedEnvFiles: LoadedEnvFiles = [] type Log = { info: (...args: any[]) => void @@ -49,7 +50,13 @@ export function processEnv( result = dotenvExpand(result) - if (result.parsed) { + if ( + result.parsed && + !previousLoadedEnvFiles.some( + (item) => + item.contents === envFile.contents && item.path === envFile.path + ) + ) { log.info(`Loaded env from ${path.join(dir || '', envFile.path)}`) } @@ -88,6 +95,7 @@ export function loadEnvConfig( return { combinedEnv, loadedEnvFiles: cachedLoadedEnvFiles } } process.env = Object.assign({}, initialEnv) + previousLoadedEnvFiles = cachedLoadedEnvFiles cachedLoadedEnvFiles = [] const isTest = process.env.NODE_ENV === 'test' diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index cfff9b5f757a..20eb0ea57fa9 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -71,6 +71,163 @@ const watchOptions = Object.freeze({ ignored: ['**/.git/**', '**/.next/**'], }) +export function getDefineEnv({ + dev, + config, + distDir, + isClient, + hasRewrites, + hasReactRoot, + isNodeServer, + isEdgeServer, + middlewareRegex, + hasServerComponents, +}: { + dev?: boolean + distDir: string + isClient?: boolean + hasRewrites?: boolean + hasReactRoot?: boolean + isNodeServer?: boolean + isEdgeServer?: boolean + middlewareRegex?: string + config: NextConfigComplete + hasServerComponents?: boolean +}) { + return { + // internal field to identify the plugin config + __NEXT_DEFINE_ENV: 'true', + + ...Object.keys(process.env).reduce( + (prev: { [key: string]: string }, key: string) => { + if (key.startsWith('NEXT_PUBLIC_')) { + prev[`process.env.${key}`] = JSON.stringify(process.env[key]!) + } + return prev + }, + {} + ), + ...Object.keys(config.env).reduce((acc, key) => { + errorIfEnvConflicted(config, key) + + return { + ...acc, + [`process.env.${key}`]: JSON.stringify(config.env[key]), + } + }, {}), + ...(!isEdgeServer + ? {} + : { + EdgeRuntime: JSON.stringify( + /** + * Cloud providers can set this environment variable to allow users + * and library authors to have different implementations based on + * the runtime they are running with, if it's not using `edge-runtime` + */ + process.env.NEXT_EDGE_RUNTIME_PROVIDER || 'edge-runtime' + ), + }), + // TODO: enforce `NODE_ENV` on `process.env`, and add a test: + 'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production'), + ...((isNodeServer || isEdgeServer) && { + 'process.env.NEXT_RUNTIME': JSON.stringify( + isEdgeServer ? 'edge' : 'nodejs' + ), + }), + 'process.env.__NEXT_MIDDLEWARE_REGEX': JSON.stringify( + middlewareRegex || '' + ), + 'process.env.__NEXT_MANUAL_CLIENT_BASE_PATH': JSON.stringify( + config.experimental.manualClientBasePath + ), + 'process.env.__NEXT_NEW_LINK_BEHAVIOR': JSON.stringify( + config.experimental.newNextLinkBehavior + ), + 'process.env.__NEXT_OPTIMISTIC_CLIENT_CACHE': JSON.stringify( + config.experimental.optimisticClientCache + ), + 'process.env.__NEXT_CROSS_ORIGIN': JSON.stringify(config.crossOrigin), + 'process.browser': JSON.stringify(isClient), + 'process.env.__NEXT_TEST_MODE': JSON.stringify( + process.env.__NEXT_TEST_MODE + ), + // This is used in client/dev-error-overlay/hot-dev-client.js to replace the dist directory + ...(dev && (isClient || isEdgeServer) + ? { + 'process.env.__NEXT_DIST_DIR': JSON.stringify(distDir), + } + : {}), + 'process.env.__NEXT_TRAILING_SLASH': JSON.stringify(config.trailingSlash), + 'process.env.__NEXT_BUILD_INDICATOR': JSON.stringify( + config.devIndicators.buildActivity + ), + 'process.env.__NEXT_BUILD_INDICATOR_POSITION': JSON.stringify( + config.devIndicators.buildActivityPosition + ), + 'process.env.__NEXT_STRICT_MODE': JSON.stringify(config.reactStrictMode), + 'process.env.__NEXT_REACT_ROOT': JSON.stringify(hasReactRoot), + 'process.env.__NEXT_RSC': JSON.stringify(hasServerComponents), + 'process.env.__NEXT_OPTIMIZE_FONTS': JSON.stringify( + config.optimizeFonts && !dev + ), + 'process.env.__NEXT_OPTIMIZE_CSS': JSON.stringify( + config.experimental.optimizeCss && !dev + ), + 'process.env.__NEXT_SCRIPT_WORKERS': JSON.stringify( + config.experimental.nextScriptWorkers && !dev + ), + 'process.env.__NEXT_SCROLL_RESTORATION': JSON.stringify( + config.experimental.scrollRestoration + ), + 'process.env.__NEXT_IMAGE_OPTS': JSON.stringify({ + deviceSizes: config.images.deviceSizes, + imageSizes: config.images.imageSizes, + path: config.images.path, + loader: config.images.loader, + dangerouslyAllowSVG: config.images.dangerouslyAllowSVG, + experimentalUnoptimized: config?.experimental?.images?.unoptimized, + experimentalFuture: config.experimental?.images?.allowFutureImage, + ...(dev + ? { + // pass domains in development to allow validating on the client + domains: config.images.domains, + experimentalRemotePatterns: + config.experimental?.images?.remotePatterns, + } + : {}), + }), + 'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath), + 'process.env.__NEXT_HAS_REWRITES': JSON.stringify(hasRewrites), + 'process.env.__NEXT_I18N_SUPPORT': JSON.stringify(!!config.i18n), + 'process.env.__NEXT_I18N_DOMAINS': JSON.stringify(config.i18n?.domains), + 'process.env.__NEXT_ANALYTICS_ID': JSON.stringify(config.analyticsId), + ...(isNodeServer || isEdgeServer + ? { + // Fix bad-actors in the npm ecosystem (e.g. `node-formidable`) + // This is typically found in unmaintained modules from the + // pre-webpack era (common in server-side code) + 'global.GENTLY': JSON.stringify(false), + } + : undefined), + // stub process.env with proxy to warn a missing value is + // being accessed in development mode + ...(config.experimental.pageEnv && dev + ? { + 'process.env': ` + new Proxy(${isNodeServer ? 'process.env' : '{}'}, { + get(target, prop) { + if (typeof target[prop] === 'undefined') { + console.warn(\`An environment variable (\${prop}) that was not provided in the environment was accessed.\nSee more info here: https://nextjs.org/docs/messages/missing-env-value\`) + } + return target[prop] + } + }) + `, + } + : {}), + } +} + function getSupportedBrowsers( dir: string, isDevelopment: boolean, @@ -1495,141 +1652,20 @@ export default async function getBaseWebpackConfig( // Avoid process being overridden when in web run time ...(isClient && { process: [require.resolve('process')] }), }), - new webpack.DefinePlugin({ - ...Object.keys(process.env).reduce( - (prev: { [key: string]: string }, key: string) => { - if (key.startsWith('NEXT_PUBLIC_')) { - prev[`process.env.${key}`] = JSON.stringify(process.env[key]!) - } - return prev - }, - {} - ), - ...Object.keys(config.env).reduce((acc, key) => { - errorIfEnvConflicted(config, key) - - return { - ...acc, - [`process.env.${key}`]: JSON.stringify(config.env[key]), - } - }, {}), - ...(compilerType !== COMPILER_NAMES.edgeServer - ? {} - : { - EdgeRuntime: JSON.stringify( - /** - * Cloud providers can set this environment variable to allow users - * and library authors to have different implementations based on - * the runtime they are running with, if it's not using `edge-runtime` - */ - process.env.NEXT_EDGE_RUNTIME_PROVIDER || 'edge-runtime' - ), - }), - // TODO: enforce `NODE_ENV` on `process.env`, and add a test: - 'process.env.NODE_ENV': JSON.stringify( - dev ? 'development' : 'production' - ), - ...((isNodeServer || isEdgeServer) && { - 'process.env.NEXT_RUNTIME': JSON.stringify( - isEdgeServer ? 'edge' : 'nodejs' - ), - }), - 'process.env.__NEXT_MIDDLEWARE_REGEX': JSON.stringify( - middlewareRegex || '' - ), - 'process.env.__NEXT_MANUAL_CLIENT_BASE_PATH': JSON.stringify( - config.experimental.manualClientBasePath - ), - 'process.env.__NEXT_NEW_LINK_BEHAVIOR': JSON.stringify( - config.experimental.newNextLinkBehavior - ), - 'process.env.__NEXT_OPTIMISTIC_CLIENT_CACHE': JSON.stringify( - config.experimental.optimisticClientCache - ), - 'process.env.__NEXT_CROSS_ORIGIN': JSON.stringify(crossOrigin), - 'process.browser': JSON.stringify(isClient), - 'process.env.__NEXT_TEST_MODE': JSON.stringify( - process.env.__NEXT_TEST_MODE - ), - // This is used in client/dev-error-overlay/hot-dev-client.js to replace the dist directory - ...(dev && (isClient || isEdgeServer) - ? { - 'process.env.__NEXT_DIST_DIR': JSON.stringify(distDir), - } - : {}), - 'process.env.__NEXT_TRAILING_SLASH': JSON.stringify( - config.trailingSlash - ), - 'process.env.__NEXT_BUILD_INDICATOR': JSON.stringify( - config.devIndicators.buildActivity - ), - 'process.env.__NEXT_BUILD_INDICATOR_POSITION': JSON.stringify( - config.devIndicators.buildActivityPosition - ), - 'process.env.__NEXT_STRICT_MODE': JSON.stringify( - config.reactStrictMode - ), - 'process.env.__NEXT_REACT_ROOT': JSON.stringify(hasReactRoot), - 'process.env.__NEXT_RSC': JSON.stringify(hasServerComponents), - 'process.env.__NEXT_OPTIMIZE_FONTS': JSON.stringify( - config.optimizeFonts && !dev - ), - 'process.env.__NEXT_OPTIMIZE_CSS': JSON.stringify( - config.experimental.optimizeCss && !dev - ), - 'process.env.__NEXT_SCRIPT_WORKERS': JSON.stringify( - config.experimental.nextScriptWorkers && !dev - ), - 'process.env.__NEXT_SCROLL_RESTORATION': JSON.stringify( - config.experimental.scrollRestoration - ), - 'process.env.__NEXT_IMAGE_OPTS': JSON.stringify({ - deviceSizes: config.images.deviceSizes, - imageSizes: config.images.imageSizes, - path: config.images.path, - loader: config.images.loader, - dangerouslyAllowSVG: config.images.dangerouslyAllowSVG, - experimentalUnoptimized: config?.experimental?.images?.unoptimized, - experimentalFuture: config.experimental?.images?.allowFutureImage, - ...(dev - ? { - // pass domains in development to allow validating on the client - domains: config.images.domains, - experimentalRemotePatterns: - config.experimental?.images?.remotePatterns, - } - : {}), - }), - 'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath), - 'process.env.__NEXT_HAS_REWRITES': JSON.stringify(hasRewrites), - 'process.env.__NEXT_I18N_SUPPORT': JSON.stringify(!!config.i18n), - 'process.env.__NEXT_I18N_DOMAINS': JSON.stringify(config.i18n?.domains), - 'process.env.__NEXT_ANALYTICS_ID': JSON.stringify(config.analyticsId), - ...(isNodeServer || isEdgeServer - ? { - // Fix bad-actors in the npm ecosystem (e.g. `node-formidable`) - // This is typically found in unmaintained modules from the - // pre-webpack era (common in server-side code) - 'global.GENTLY': JSON.stringify(false), - } - : undefined), - // stub process.env with proxy to warn a missing value is - // being accessed in development mode - ...(config.experimental.pageEnv && dev - ? { - 'process.env': ` - new Proxy(${isNodeServer ? 'process.env' : '{}'}, { - get(target, prop) { - if (typeof target[prop] === 'undefined') { - console.warn(\`An environment variable (\${prop}) that was not provided in the environment was accessed.\nSee more info here: https://nextjs.org/docs/messages/missing-env-value\`) - } - return target[prop] - } - }) - `, - } - : {}), - }), + new webpack.DefinePlugin( + getDefineEnv({ + dev, + config, + distDir, + isClient, + hasRewrites, + hasReactRoot, + isNodeServer, + isEdgeServer, + middlewareRegex, + hasServerComponents, + }) + ), isClient && new ReactLoadablePlugin({ filename: REACT_LOADABLE_MANIFEST, diff --git a/packages/next/server/dev/hot-reloader.ts b/packages/next/server/dev/hot-reloader.ts index 6fd55bb5a49a..9d9d269a859f 100644 --- a/packages/next/server/dev/hot-reloader.ts +++ b/packages/next/server/dev/hot-reloader.ts @@ -1,4 +1,4 @@ -import type { webpack5 } from 'next/dist/compiled/webpack/webpack' +import { webpack5 } from 'next/dist/compiled/webpack/webpack' import type { NextConfigComplete } from '../config-shared' import type { CustomRoutes } from '../../lib/load-custom-routes' import { getOverlayMiddleware } from 'next/dist/compiled/@next/react-dev-overlay/dist/middleware' @@ -48,6 +48,7 @@ import ws from 'next/dist/compiled/ws' import { promises as fs } from 'fs' import { getPageStaticInfo } from '../../build/analysis/get-page-static-info' import { serverComponentRegex } from '../../build/webpack/loaders/utils' +import { UnwrapPromise } from '../../lib/coalesced-function' const wsServer = new ws.Server({ noServer: true }) @@ -159,8 +160,8 @@ export default class HotReloader { private distDir: string private webpackHotMiddleware?: WebpackHotMiddleware private config: NextConfigComplete - private hasServerComponents: boolean - private hasReactRoot: boolean + public hasServerComponents: boolean + public hasReactRoot: boolean public clientStats: webpack5.Stats | null public serverStats: webpack5.Stats | null public edgeServerStats: webpack5.Stats | null @@ -176,6 +177,10 @@ export default class HotReloader { private hotReloaderSpan: Span private pagesMapping: { [key: string]: string } = {} private appDir?: string + public multiCompiler?: webpack5.MultiCompiler + public activeConfigs?: Array< + UnwrapPromise> + > constructor( dir: string, @@ -452,6 +457,7 @@ export default class HotReloader { .traceChild('generate-webpack-config') .traceAsyncFn(() => Promise.all([ + // order is important here getBaseWebpackConfig(this.dir, { ...commonWebpackOptions, compilerType: COMPILER_NAMES.client, @@ -525,24 +531,23 @@ export default class HotReloader { }) } - public async start(): Promise { + public async start(initial?: boolean): Promise { const startSpan = this.hotReloaderSpan.traceChild('start') startSpan.stop() // Stop immediately to create an artificial parent span - await this.clean(startSpan) + if (initial) { + await this.clean(startSpan) + // Ensure distDir exists before writing package.json + await fs.mkdir(this.distDir, { recursive: true }) - // Ensure distDir exists before writing package.json - await fs.mkdir(this.distDir, { recursive: true }) - - const distPackageJsonPath = join(this.distDir, 'package.json') - - // Ensure commonjs handling is used for files in the distDir (generally .next) - // Files outside of the distDir can be "type": "module" - await fs.writeFile(distPackageJsonPath, '{"type": "commonjs"}') - - const configs = await this.getWebpackConfig(startSpan) + const distPackageJsonPath = join(this.distDir, 'package.json') + // Ensure commonjs handling is used for files in the distDir (generally .next) + // Files outside of the distDir can be "type": "module" + await fs.writeFile(distPackageJsonPath, '{"type": "commonjs"}') + } + this.activeConfigs = await this.getWebpackConfig(startSpan) - for (const config of configs) { + for (const config of this.activeConfigs) { const defaultEntry = config.entry config.entry = async (...args) => { // @ts-ignore entry is always a function @@ -679,14 +684,16 @@ export default class HotReloader { // Enable building of client compilation before server compilation in development // @ts-ignore webpack 5 - configs.parallelism = 1 + this.activeConfigs.parallelism = 1 - const multiCompiler = webpack(configs) as unknown as webpack5.MultiCompiler + this.multiCompiler = webpack( + this.activeConfigs + ) as unknown as webpack5.MultiCompiler watchCompilers( - multiCompiler.compilers[0], - multiCompiler.compilers[1], - multiCompiler.compilers[2] + this.multiCompiler.compilers[0], + this.multiCompiler.compilers[1], + this.multiCompiler.compilers[2] ) // Watch for changes to client/server page files so we can tell when just @@ -757,21 +764,21 @@ export default class HotReloader { } } - multiCompiler.compilers[0].hooks.emit.tap( + this.multiCompiler.compilers[0].hooks.emit.tap( 'NextjsHotReloaderForClient', trackPageChanges(prevClientPageHashes, changedClientPages) ) - multiCompiler.compilers[1].hooks.emit.tap( + this.multiCompiler.compilers[1].hooks.emit.tap( 'NextjsHotReloaderForServer', trackPageChanges(prevServerPageHashes, changedServerPages) ) - multiCompiler.compilers[2].hooks.emit.tap( + this.multiCompiler.compilers[2].hooks.emit.tap( 'NextjsHotReloaderForServer', trackPageChanges(prevEdgeServerPageHashes, changedEdgeServerPages) ) // This plugin watches for changes to _document.js and notifies the client side that it should reload the page - multiCompiler.compilers[1].hooks.failed.tap( + this.multiCompiler.compilers[1].hooks.failed.tap( 'NextjsHotReloaderForServer', (err: Error) => { this.serverError = err @@ -779,7 +786,7 @@ export default class HotReloader { } ) - multiCompiler.compilers[2].hooks.done.tap( + this.multiCompiler.compilers[2].hooks.done.tap( 'NextjsHotReloaderForServer', (stats) => { this.serverError = null @@ -787,7 +794,7 @@ export default class HotReloader { } ) - multiCompiler.compilers[1].hooks.done.tap( + this.multiCompiler.compilers[1].hooks.done.tap( 'NextjsHotReloaderForServer', (stats) => { this.serverError = null @@ -820,7 +827,7 @@ export default class HotReloader { this.serverPrevDocumentHash = documentChunk.hash || null } ) - multiCompiler.hooks.done.tap('NextjsHotReloaderForServer', () => { + this.multiCompiler.hooks.done.tap('NextjsHotReloaderForServer', () => { const serverOnlyChanges = difference( changedServerPages, changedClientPages @@ -862,14 +869,14 @@ export default class HotReloader { } }) - multiCompiler.compilers[0].hooks.failed.tap( + this.multiCompiler.compilers[0].hooks.failed.tap( 'NextjsHotReloaderForClient', (err: Error) => { this.clientError = err this.clientStats = null } ) - multiCompiler.compilers[0].hooks.done.tap( + this.multiCompiler.compilers[0].hooks.done.tap( 'NextjsHotReloaderForClient', (stats) => { this.clientError = null @@ -908,15 +915,15 @@ export default class HotReloader { ) this.webpackHotMiddleware = new WebpackHotMiddleware( - multiCompiler.compilers + this.multiCompiler.compilers ) let booted = false this.watcher = await new Promise((resolve) => { - const watcher = multiCompiler.watch( + const watcher = this.multiCompiler?.watch( // @ts-ignore webpack supports an array of watchOptions when using a multiCompiler - configs.map((config) => config.watchOptions!), + this.activeConfigs.map((config) => config.watchOptions!), // Errors are handled separately (_err: any) => { if (!booted) { @@ -928,7 +935,7 @@ export default class HotReloader { }) this.onDemandEntries = onDemandEntryHandler({ - multiCompiler, + multiCompiler: this.multiCompiler, pagesDir: this.pagesDir, appDir: this.appDir, rootDir: this.dir, @@ -950,7 +957,13 @@ export default class HotReloader { // trigger invalidation to ensure any previous callbacks // are handled in the on-demand-entry-handler - getInvalidator()?.invalidate() + if (!initial) { + this.invalidate() + } + } + + public invalidate() { + return getInvalidator()?.invalidate() } public async stop(): Promise { @@ -965,6 +978,7 @@ export default class HotReloader { ) }) } + this.multiCompiler = undefined } public async getCompilationErrors(page: string) { diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index bb694fde62a9..80a9d385bae2 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -72,6 +72,7 @@ import { isMiddlewareFile, NestedMiddlewareError, } from '../../build/utils' +import { getDefineEnv } from '../../build/webpack-config' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: React.FunctionComponent @@ -391,8 +392,46 @@ export default class DevServer extends Server { if (envChange) { this.loadEnvConfig({ dev: true, forceReload: true }) - await this.hotReloader?.stop() - await this.hotReloader?.start() + + this.hotReloader?.activeConfigs?.forEach((config, idx) => { + const isClient = idx === 0 + const isNodeServer = idx === 1 + const isEdgeServer = idx === 2 + const hasRewrites = + this.customRoutes.rewrites.afterFiles.length > 0 || + this.customRoutes.rewrites.beforeFiles.length > 0 || + this.customRoutes.rewrites.fallback.length > 0 + + config.plugins?.forEach((plugin: any) => { + // we look for the DefinePlugin definitions so we can + // update them on the active compilers + if ( + plugin && + typeof plugin.definitions === 'object' && + plugin.definitions.__NEXT_DEFINE_ENV + ) { + const newDefine = getDefineEnv({ + dev: true, + config: this.nextConfig, + distDir: this.distDir, + isClient, + hasRewrites, + hasReactRoot: this.hotReloader?.hasReactRoot, + isNodeServer, + isEdgeServer, + hasServerComponents: this.hotReloader?.hasServerComponents, + }) + + Object.keys(plugin.definitions).forEach((key) => { + if (!(key in newDefine)) { + delete plugin.definitions[key] + } + }) + Object.assign(plugin.definitions, newDefine) + } + }) + }) + this.hotReloader?.invalidate() } if (nestedMiddleware.length > 0) { @@ -512,7 +551,7 @@ export default class DevServer extends Server { }) await super.prepare() await this.addExportPathMapRoutes() - await this.hotReloader.start() + await this.hotReloader.start(true) await this.startWatcher() this.setDevReady!() diff --git a/test/integration/env-config/app/pages/another-global.js b/test/integration/env-config/app/pages/another-global.js index 7db38a7029f9..c1d5d4a9da22 100644 --- a/test/integration/env-config/app/pages/another-global.js +++ b/test/integration/env-config/app/pages/another-global.js @@ -1 +1,3 @@ -export default () =>

{process.env.NEXT_PUBLIC_HELLO_WORLD}

+export default function Page() { + return

{process.env.NEXT_PUBLIC_HELLO_WORLD}

+} diff --git a/test/integration/env-config/app/pages/api/all.js b/test/integration/env-config/app/pages/api/all.js index 52f181fe5fb8..e2021d6b38e2 100644 --- a/test/integration/env-config/app/pages/api/all.js +++ b/test/integration/env-config/app/pages/api/all.js @@ -27,7 +27,7 @@ const variables = [ 'NEXT_PUBLIC_HELLO_WORLD', ] -export default async (req, res) => { +export default async function handler(req, res) { const items = { nextConfigEnv: process.env.nextConfigEnv, nextConfigPublicEnv: process.env.nextConfigPublicEnv, diff --git a/test/integration/env-config/app/pages/global.js b/test/integration/env-config/app/pages/global.js index ded4746e3107..d4e4aefc644f 100644 --- a/test/integration/env-config/app/pages/global.js +++ b/test/integration/env-config/app/pages/global.js @@ -1 +1,3 @@ -export default () =>

{process.env.NEXT_PUBLIC_TEST_DEST}

+export default function Page() { + return

{process.env.NEXT_PUBLIC_TEST_DEST}

+} diff --git a/test/integration/env-config/app/pages/index.js b/test/integration/env-config/app/pages/index.js index c8bc8ce3a77e..43756e2dd103 100644 --- a/test/integration/env-config/app/pages/index.js +++ b/test/integration/env-config/app/pages/index.js @@ -44,10 +44,12 @@ export async function getStaticProps() { } } -export default ({ env }) => ( - <> -

{JSON.stringify(env)}

-
{process.env.nextConfigEnv}
-
{process.env.nextConfigPublicEnv}
- -) +export default function Page({ env }) { + return ( + <> +

{JSON.stringify(env)}

+
{process.env.nextConfigEnv}
+
{process.env.nextConfigPublicEnv}
+ + ) +} diff --git a/test/integration/env-config/app/pages/some-ssg.js b/test/integration/env-config/app/pages/some-ssg.js index d46d371b5906..75a3be8db867 100644 --- a/test/integration/env-config/app/pages/some-ssg.js +++ b/test/integration/env-config/app/pages/some-ssg.js @@ -44,10 +44,12 @@ export async function getStaticProps() { } } -export default ({ env }) => ( - <> -

{JSON.stringify(env)}

-
{process.env.nextConfigEnv}
-
{process.env.nextConfigPublicEnv}
- -) +export default function Page({ env }) { + return ( + <> +

{JSON.stringify(env)}

+
{process.env.nextConfigEnv}
+
{process.env.nextConfigPublicEnv}
+ + ) +} diff --git a/test/integration/env-config/app/pages/some-ssp.js b/test/integration/env-config/app/pages/some-ssp.js index ee985c926e22..fc06ddc11540 100644 --- a/test/integration/env-config/app/pages/some-ssp.js +++ b/test/integration/env-config/app/pages/some-ssp.js @@ -43,10 +43,12 @@ export async function getServerSideProps() { } } -export default ({ env }) => ( - <> -

{JSON.stringify(env)}

-
{process.env.nextConfigEnv}
-
{process.env.nextConfigPublicEnv}
- -) +export default function Page({ env }) { + return ( + <> +

{JSON.stringify(env)}

+
{process.env.nextConfigEnv}
+
{process.env.nextConfigPublicEnv}
+ + ) +} diff --git a/test/integration/env-config/test/index.test.js b/test/integration/env-config/test/index.test.js index 23baf525c59f..6846e9e2f24f 100644 --- a/test/integration/env-config/test/index.test.js +++ b/test/integration/env-config/test/index.test.js @@ -4,6 +4,7 @@ import url from 'url' import fs from 'fs-extra' import { join } from 'path' import cheerio from 'cheerio' +import webdriver from 'next-webdriver' import { nextBuild, nextStart, @@ -170,6 +171,9 @@ describe('Env Config', () => { const originalContents = [] beforeAll(async () => { const outputIndex = output.length + const envFiles = (await fs.readdir(appDir)).filter((file) => + file.startsWith('.env') + ) const envToUpdate = [ { toAdd: 'NEW_ENV_KEY=true', @@ -185,12 +189,16 @@ describe('Env Config', () => { }, ] - for (const { file, toAdd } of envToUpdate) { - const content = await fs.readFile(join(appDir, file), 'utf8') + for (const file of envFiles) { + const filePath = join(appDir, file) + const content = await fs.readFile(filePath, 'utf8') originalContents.push({ file, content }) - await fs.writeFile(join(appDir, file), content + '\n' + toAdd) - } + const toUpdate = envToUpdate.find((item) => item.file === file) + if (toUpdate) { + await fs.writeFile(filePath, content + `\n${toUpdate.toAdd}`) + } + } await check(() => { return output.substring(outputIndex) }, /Loaded env from/) @@ -203,24 +211,138 @@ describe('Env Config', () => { runTests('dev', true) - it('should update inlined values correctly', async () => { - await renderViaHTTP(appPort, '/another-global') - - const buildManifest = await fs.readJson( - join(__dirname, '../app/.next/build-manifest.json') - ) - - const pageFile = buildManifest.pages['/another-global'].find( - (filename) => filename.includes('pages/another-global') - ) + it('should have updated runtime values after change', async () => { + let html = await fetchViaHTTP(appPort, '/').then((res) => res.text()) + let renderedEnv = JSON.parse(cheerio.load(html)('p').text()) + + expect(renderedEnv['ENV_FILE_KEY']).toBe('env') + expect(renderedEnv['ENV_FILE_LOCAL_OVERRIDE_TEST']).toBe('localenv') + let outputIdx = output.length + + const envFile = join(appDir, '.env') + const envLocalFile = join(appDir, '.env.local') + const envContent = originalContents.find( + (item) => item.file === '.env' + ).content + const envLocalContent = originalContents.find( + (item) => item.file === '.env.local' + ).content + + try { + await fs.writeFile( + envFile, + envContent.replace(`ENV_FILE_KEY=env`, `ENV_FILE_KEY=env-updated`) + ) + + // we should only log we loaded new env from .env + await check(() => output.substring(outputIdx), /Loaded env from/) + expect( + [...output.substring(outputIdx).matchAll(/Loaded env from/g)].length + ).toBe(1) + expect(output.substring(outputIdx)).not.toContain('.env.local') + + await check(async () => { + html = await fetchViaHTTP(appPort, '/').then((res) => res.text()) + renderedEnv = JSON.parse(cheerio.load(html)('p').text()) + expect(renderedEnv['ENV_FILE_KEY']).toBe('env-updated') + expect(renderedEnv['ENV_FILE_LOCAL_OVERRIDE_TEST']).toBe('localenv') + return 'success' + }, 'success') + + outputIdx = output.length + + await fs.writeFile( + envLocalFile, + envLocalContent.replace( + `ENV_FILE_LOCAL_OVERRIDE_TEST=localenv`, + `ENV_FILE_LOCAL_OVERRIDE_TEST=localenv-updated` + ) + ) + + // we should only log we loaded new env from .env + await check(() => output.substring(outputIdx), /Loaded env from/) + expect( + [...output.substring(outputIdx).matchAll(/Loaded env from/g)].length + ).toBe(1) + expect(output.substring(outputIdx)).toContain('.env.local') + + await check(async () => { + html = await fetchViaHTTP(appPort, '/').then((res) => res.text()) + renderedEnv = JSON.parse(cheerio.load(html)('p').text()) + expect(renderedEnv['ENV_FILE_KEY']).toBe('env-updated') + expect(renderedEnv['ENV_FILE_LOCAL_OVERRIDE_TEST']).toBe( + 'localenv-updated' + ) + return 'success' + }, 'success') + } finally { + await fs.writeFile(envFile, envContent) + await fs.writeFile(envLocalFile, envLocalContent) + } + }) - // read client bundle contents since a server side render can - // have the value available during render but it not be injected - const bundleContent = await fs.readFile( - join(appDir, '.next', pageFile), - 'utf8' - ) - expect(bundleContent).toContain('again') + it('should trigger HMR correctly when NEXT_PUBLIC_ env is changed', async () => { + const envFile = join(appDir, '.env') + const envLocalFile = join(appDir, '.env.local') + const envContent = originalContents.find( + (item) => item.file === '.env' + ).content + const envLocalContent = originalContents.find( + (item) => item.file === '.env.local' + ).content + + try { + const browser = await webdriver(appPort, '/global') + expect(await browser.elementByCss('p').text()).toBe('another') + + let outputIdx = output.length + + await fs.writeFile( + envFile, + envContent.replace( + `NEXT_PUBLIC_TEST_DEST=another`, + `NEXT_PUBLIC_TEST_DEST=replaced` + ) + ) + // we should only log we loaded new env from .env + await check(() => output.substring(outputIdx), /Loaded env from/) + expect( + [...output.substring(outputIdx).matchAll(/Loaded env from/g)].length + ).toBe(1) + expect(output.substring(outputIdx)).not.toContain('.env.local') + + await check(() => browser.elementByCss('p').text(), 'replaced') + + outputIdx = output.length + + await fs.writeFile( + envLocalFile, + envLocalContent + `\nNEXT_PUBLIC_TEST_DEST=overridden` + ) + // we should only log we loaded new env from .env + await check(() => output.substring(outputIdx), /Loaded env from/) + expect( + [...output.substring(outputIdx).matchAll(/Loaded env from/g)].length + ).toBe(1) + expect(output.substring(outputIdx)).toContain('.env.local') + + await check(() => browser.elementByCss('p').text(), 'overridden') + + outputIdx = output.length + + await fs.writeFile(envLocalFile, envLocalContent) + // we should only log we loaded new env from .env + await check(() => output.substring(outputIdx), /Loaded env from/) + expect( + [...output.substring(outputIdx).matchAll(/Loaded env from/g)].length + ).toBe(1) + expect(output.substring(outputIdx)).toContain('.env.local') + + await check(() => browser.elementByCss('p').text(), 'replaced') + } finally { + await fs.writeFile(envFile, envContent) + await fs.writeFile(envLocalFile, envLocalContent) + } }) }) }) From d8809164ad5698b19c2fe30967cc0e8e4a387065 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 13 Aug 2022 12:27:25 -0500 Subject: [PATCH 2/7] v12.2.6-canary.0 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/eslint-config-next/package.json | 4 ++-- packages/eslint-plugin-next/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 14 +++++++------- packages/react-dev-overlay/package.json | 2 +- packages/react-refresh-utils/package.json | 2 +- pnpm-lock.yaml | 16 ++++++++-------- 16 files changed, 30 insertions(+), 30 deletions(-) diff --git a/lerna.json b/lerna.json index f4f87151b2c2..08b1efcfc4c4 100644 --- a/lerna.json +++ b/lerna.json @@ -16,5 +16,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "12.2.5" + "version": "12.2.6-canary.0" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 0428548795d3..621ff222ba14 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "12.2.5", + "version": "12.2.6-canary.0", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 4bf94148347b..1ac08e8fc7ac 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "12.2.5", + "version": "12.2.6-canary.0", "description": "ESLint configuration used by NextJS.", "main": "index.js", "license": "MIT", @@ -9,7 +9,7 @@ "directory": "packages/eslint-config-next" }, "dependencies": { - "@next/eslint-plugin-next": "12.2.5", + "@next/eslint-plugin-next": "12.2.6-canary.0", "@rushstack/eslint-patch": "^1.1.3", "@typescript-eslint/parser": "^5.21.0", "eslint-import-resolver-node": "^0.3.6", diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index ed2e5a24810e..a0c8959ab4ef 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "12.2.5", + "version": "12.2.6-canary.0", "description": "ESLint plugin for NextJS.", "main": "lib/index.js", "license": "MIT", diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 09ff3f3b59b4..40a152380d7c 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "12.2.5", + "version": "12.2.6-canary.0", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 9515b9a57b33..bb2bd3ad9b46 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "12.2.5", + "version": "12.2.6-canary.0", "license": "MIT", "dependencies": { "chalk": "4.1.0", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 38d52757874f..09db53da6245 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "12.2.5", + "version": "12.2.6-canary.0", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 3e462d7480ed..af216e03322a 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "12.2.5", + "version": "12.2.6-canary.0", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index a9d1a1ba0298..091843862ddf 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "12.2.5", + "version": "12.2.6-canary.0", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 43908ab93053..08f2a42aaac6 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "12.2.5", + "version": "12.2.6-canary.0", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 50f769a145c7..a94c9c68c96a 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "12.2.5", + "version": "12.2.6-canary.0", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index cbeb19b1dd04..9dddc8147a3e 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "12.2.5", + "version": "12.2.6-canary.0", "private": true, "scripts": { "build-native": "napi build --platform -p next-swc-napi --cargo-name next_swc_napi native --features plugin", diff --git a/packages/next/package.json b/packages/next/package.json index 70e540aca09b..7765833b3a0c 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "12.2.5", + "version": "12.2.6-canary.0", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -70,7 +70,7 @@ ] }, "dependencies": { - "@next/env": "12.2.5", + "@next/env": "12.2.6-canary.0", "@swc/helpers": "0.4.3", "caniuse-lite": "^1.0.30001332", "postcss": "8.4.14", @@ -121,11 +121,11 @@ "@hapi/accept": "5.0.2", "@napi-rs/cli": "2.7.0", "@napi-rs/triples": "1.1.0", - "@next/polyfill-module": "12.2.5", - "@next/polyfill-nomodule": "12.2.5", - "@next/react-dev-overlay": "12.2.5", - "@next/react-refresh-utils": "12.2.5", - "@next/swc": "12.2.5", + "@next/polyfill-module": "12.2.6-canary.0", + "@next/polyfill-nomodule": "12.2.6-canary.0", + "@next/react-dev-overlay": "12.2.6-canary.0", + "@next/react-refresh-utils": "12.2.6-canary.0", + "@next/swc": "12.2.6-canary.0", "@segment/ajv-human-errors": "2.1.2", "@taskr/clear": "1.1.0", "@taskr/esnext": "1.1.0", diff --git a/packages/react-dev-overlay/package.json b/packages/react-dev-overlay/package.json index 92b397371d53..19a9647a9f5e 100644 --- a/packages/react-dev-overlay/package.json +++ b/packages/react-dev-overlay/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-dev-overlay", - "version": "12.2.5", + "version": "12.2.6-canary.0", "description": "A development-only overlay for developing React applications.", "repository": { "url": "vercel/next.js", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 9aea86df6422..9a5eb975cbe2 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "12.2.5", + "version": "12.2.6-canary.0", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d09106bdfe26..930f08d9ec13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,7 +364,7 @@ importers: packages/eslint-config-next: specifiers: - '@next/eslint-plugin-next': 12.2.5 + '@next/eslint-plugin-next': 12.2.6-canary.0 '@rushstack/eslint-patch': ^1.1.3 '@typescript-eslint/parser': ^5.21.0 eslint-import-resolver-node: ^0.3.6 @@ -420,12 +420,12 @@ importers: '@hapi/accept': 5.0.2 '@napi-rs/cli': 2.7.0 '@napi-rs/triples': 1.1.0 - '@next/env': 12.2.5 - '@next/polyfill-module': 12.2.5 - '@next/polyfill-nomodule': 12.2.5 - '@next/react-dev-overlay': 12.2.5 - '@next/react-refresh-utils': 12.2.5 - '@next/swc': 12.2.5 + '@next/env': 12.2.6-canary.0 + '@next/polyfill-module': 12.2.6-canary.0 + '@next/polyfill-nomodule': 12.2.6-canary.0 + '@next/react-dev-overlay': 12.2.6-canary.0 + '@next/react-refresh-utils': 12.2.6-canary.0 + '@next/swc': 12.2.6-canary.0 '@segment/ajv-human-errors': 2.1.2 '@swc/helpers': 0.4.3 '@taskr/clear': 1.1.0 @@ -21225,7 +21225,7 @@ packages: typescript: 4.6.3 /tty-browserify/0.0.0: - resolution: {integrity: sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=} + resolution: {integrity: sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==} dev: true /tty-browserify/0.0.1: From 0f65bf6e8b4d6b1855e064730cf66d75c5625d7a Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 13 Aug 2022 15:39:31 -0500 Subject: [PATCH 3/7] Fix failing switchable runtime deploy test (#39579) This ensures we add the needed manifest for https://github.com/vercel/next.js/commit/b5aa571c71b8b3912bb96b78c359bfc9681833b4 in the `required-files-manifest`. No new test cases have been added as this was caught by the existing deploy test. ## Bug - [x] Related issues linked using `fixes #number` - [x] Integration tests added - [ ] Errors have helpful link attached, see `contributing.md` x-ref: https://github.com/vercel/next.js/runs/7821226464?check_suite_focus=true#step:8:193 --- packages/next/build/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 58b61955249c..91e4e9ff1505 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -57,6 +57,7 @@ import { APP_PATH_ROUTES_MANIFEST, COMPILER_NAMES, APP_BUILD_MANIFEST, + FLIGHT_SERVER_CSS_MANIFEST, } from '../shared/lib/constants' import { getSortedRoutes, isDynamicRoute } from '../shared/lib/router/utils' import { __ApiPreviewProps } from '../server/api-utils' @@ -708,6 +709,10 @@ export default async function build( ? [ path.join(SERVER_DIRECTORY, FLIGHT_MANIFEST + '.js'), path.join(SERVER_DIRECTORY, FLIGHT_MANIFEST + '.json'), + path.join( + SERVER_DIRECTORY, + FLIGHT_SERVER_CSS_MANIFEST + '.json' + ), ] : []), REACT_LOADABLE_MANIFEST, From 6791e7547e36c90a80bc130345ddafd5f0b24425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milind=20Mishra=20=E2=9A=9B=EF=B8=8F?= Date: Mon, 15 Aug 2022 02:35:30 +0530 Subject: [PATCH 4/7] Typo (#39596) ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [x] Errors have helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The examples guidelines are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing.md#adding-examples) ## Bug was a simple `typo` ![image](https://user-images.githubusercontent.com/28717686/184554656-83594c8e-cfab-4da1-89a2-f98d08ffe03f.png) - Now fixed --- examples/next-forms/pages/js-form.js | 2 +- examples/next-forms/pages/no-js-form.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/next-forms/pages/js-form.js b/examples/next-forms/pages/js-form.js index 3dd90a9a59a0..c77ee4c9520f 100644 --- a/examples/next-forms/pages/js-form.js +++ b/examples/next-forms/pages/js-form.js @@ -45,7 +45,7 @@ export default function PageWithJSbasedForm() {

Get started by looking at{' '} - pages/js-from.js + pages/js-form.js

diff --git a/examples/next-forms/pages/no-js-form.js b/examples/next-forms/pages/no-js-form.js index 8a5a8f6fb62d..17288c115222 100644 --- a/examples/next-forms/pages/no-js-form.js +++ b/examples/next-forms/pages/no-js-form.js @@ -13,7 +13,7 @@ export default function Form() {

Get started by looking at{' '} - pages/no-js-from.js + pages/no-js-form.js

{/*action: The action attribute defines where the data gets sent. Its value must be a valid relative or absolute URL. If this attribute isn't provided, the data will be sent to the URL of the page containing the form — the current page. From e0d7ee01df95aa07fbe42bf1a37e09228409a255 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Sun, 14 Aug 2022 23:51:11 +0200 Subject: [PATCH 5/7] Fix Edge SSR routes (#39594) Currently Edge SSR routes are added to both `routedPages` (catch-all page render routes) and `edgeRoutesSet` (catch-all edge function routes). This causes the request to be handled by both and results in an error (the Node server can't execute the Edge SSR route as a regular page). Another fix is to make sure Edge Function routes are sorted too, so `/foo` can be caught before dynamic ones like `/[id]`. ## Bug - [ ] Related issues linked using `fixes #number` - [x] Integration tests added - [ ] Errors have helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The examples guidelines are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing.md#adding-examples) --- packages/next/server/dev/next-dev-server.ts | 5 ++- packages/next/server/next-server.ts | 34 +++++++---------- test/e2e/switchable-runtime/index.test.ts | 37 +++++++++++++++++++ .../e2e/switchable-runtime/pages/edge/[id].js | 13 +++++++ test/e2e/switchable-runtime/pages/edge/foo.js | 13 +++++++ 5 files changed, 80 insertions(+), 22 deletions(-) create mode 100644 test/e2e/switchable-runtime/pages/edge/[id].js create mode 100644 test/e2e/switchable-runtime/pages/edge/foo.js diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index 80a9d385bae2..a43f65c1ed3f 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -382,12 +382,13 @@ export default class DevServer extends Server { page: pageName, pageRuntime: staticInfo.runtime, onClient: () => {}, - onServer: () => {}, + onServer: () => { + routedPages.push(pageName) + }, onEdgeServer: () => { edgeRoutesSet.add(pageName) }, }) - routedPages.push(pageName) } if (envChange) { diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 0eb6747391f5..b58ffafead42 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -84,10 +84,10 @@ import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing- import { getNextPathnameInfo } from '../shared/lib/router/utils/get-next-pathname-info' import { bodyStreamToNodeStream, getClonableBody } from './body-streams' import { checkIsManualRevalidate } from './api-utils' -import { isDynamicRoute } from '../shared/lib/router/utils' import { shouldUseReactRoot } from './utils' import ResponseCache from './response-cache' import { IncrementalCache } from './lib/incremental-cache' +import { getSortedRoutes } from '../shared/lib/router/utils/sorted-routes' if (shouldUseReactRoot) { ;(process.env as any).__NEXT_REACT_ROOT = 'true' @@ -1151,10 +1151,13 @@ export default class NextNodeServer extends BaseServer { return [] } - return Object.keys(manifest.functions).map((page) => ({ - match: getMiddlewareMatcher(manifest.functions[page]), - page, - })) + // Make sure to sort function routes too. + return getSortedRoutes(Object.keys(manifest.functions)).map((page) => { + return { + match: getMiddlewareMatcher(manifest.functions[page]), + page, + } + }) } protected getEdgeRoutes(): RoutingItem[] { @@ -1371,22 +1374,13 @@ export default class NextNodeServer extends BaseServer { const normalizedPathname = removeTrailingSlash(pathname || '') let page = normalizedPathname let params: Params | undefined = undefined - let pageFound = !isDynamicRoute(page) - - if (this.dynamicRoutes) { - for (const dynamicRoute of this.dynamicRoutes) { - params = dynamicRoute.match(normalizedPathname) || undefined - if (params) { - page = dynamicRoute.page - pageFound = true - break - } - } - } - if (!pageFound) { - return { - finished: false, + for (const edgeFunction of edgeFunctions) { + const matched = edgeFunction.match(page) + if (matched) { + params = matched + page = edgeFunction.page + break } } diff --git a/test/e2e/switchable-runtime/index.test.ts b/test/e2e/switchable-runtime/index.test.ts index a1868f18d0eb..7ec464455d62 100644 --- a/test/e2e/switchable-runtime/index.test.ts +++ b/test/e2e/switchable-runtime/index.test.ts @@ -70,6 +70,43 @@ describe('Switchable runtime', () => { expect(devMiddlewareManifest).toEqual({}) }) + it('should sort edge SSR routes correctly', async () => { + const res = await fetchViaHTTP(next.url, `/edge/foo`) + const html = await res.text() + + // /edge/foo should be caught before /edge/[id] + expect(html).toContain(`to /edge/[id]`) + }) + + it('should be able to navigate between edge SSR routes without any errors', async () => { + const res = await fetchViaHTTP(next.url, `/edge/foo`) + const html = await res.text() + + // /edge/foo should be caught before /edge/[id] + expect(html).toContain(`to /edge/[id]`) + + const browser = await webdriver(context.appPort, '/edge/foo') + + await browser.waitForElementByCss('a').click() + + // on /edge/[id] + await check( + () => browser.eval('document.documentElement.innerHTML'), + /to \/edge\/foo/ + ) + + await browser.waitForElementByCss('a').click() + + // on /edge/foo + await check( + () => browser.eval('document.documentElement.innerHTML'), + /to \/edge\/\[id\]/ + ) + + expect(context.stdout).not.toContain('self is not defined') + expect(context.stderr).not.toContain('self is not defined') + }) + it.skip('should support client side navigation to ssr rsc pages', async () => { let flightRequest = null diff --git a/test/e2e/switchable-runtime/pages/edge/[id].js b/test/e2e/switchable-runtime/pages/edge/[id].js new file mode 100644 index 000000000000..8e91214e16c7 --- /dev/null +++ b/test/e2e/switchable-runtime/pages/edge/[id].js @@ -0,0 +1,13 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ to /edge/foo +
+ ) +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/switchable-runtime/pages/edge/foo.js b/test/e2e/switchable-runtime/pages/edge/foo.js new file mode 100644 index 000000000000..b14bc44d881f --- /dev/null +++ b/test/e2e/switchable-runtime/pages/edge/foo.js @@ -0,0 +1,13 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ to /edge/[id] +
+ ) +} + +export const config = { + runtime: 'experimental-edge', +} From 683db9a8ad2660c0de12dfb44ef6808a9726cb20 Mon Sep 17 00:00:00 2001 From: Jan Potoms <2109932+Janpot@users.noreply.github.com> Date: Mon, 15 Aug 2022 15:33:25 +0200 Subject: [PATCH 6/7] Support tsconfig paths without baseurl (#34926) tsconfig behaves differently with `tsc` vs. Next.js. When `baseurl` is omitted, `paths` should be resolved against the tsconfig location. Consequentially, in this case the paths must start with `./`. This PR aligns Next.js behavior with `tsc` around `paths` without `baseurl`. Related: https://github.com/microsoft/TypeScript/pull/40101 --- packages/next/build/load-jsconfig.ts | 12 +++++- .../jsconfig-paths/test/index.test.js | 31 +++++++++++++- .../typescript-paths/test/index.test.js | 42 ++++++++++++++++++- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/packages/next/build/load-jsconfig.ts b/packages/next/build/load-jsconfig.ts index bb99b55f518a..aa775327fc94 100644 --- a/packages/next/build/load-jsconfig.ts +++ b/packages/next/build/load-jsconfig.ts @@ -49,6 +49,7 @@ export default async function loadJsConfig( typeScriptPath && (await fileExists(tsConfigPath)) ) + let implicitBaseurl let jsConfig // jsconfig is a subset of tsconfig if (useTypeScript) { @@ -65,17 +66,24 @@ export default async function loadJsConfig( )) as typeof import('typescript') const tsConfig = await getTypeScriptConfiguration(ts, tsConfigPath, true) jsConfig = { compilerOptions: tsConfig.options } + implicitBaseurl = path.dirname(tsConfigPath) } const jsConfigPath = path.join(dir, 'jsconfig.json') if (!useTypeScript && (await fileExists(jsConfigPath))) { jsConfig = parseJsonFile(jsConfigPath) + implicitBaseurl = path.dirname(jsConfigPath) } let resolvedBaseUrl - if (jsConfig?.compilerOptions?.baseUrl) { - resolvedBaseUrl = path.resolve(dir, jsConfig.compilerOptions.baseUrl) + if (jsConfig) { + if (jsConfig.compilerOptions?.baseUrl) { + resolvedBaseUrl = path.resolve(dir, jsConfig.compilerOptions.baseUrl) + } else { + resolvedBaseUrl = implicitBaseurl + } } + return { useTypeScript, jsConfig, diff --git a/test/integration/jsconfig-paths/test/index.test.js b/test/integration/jsconfig-paths/test/index.test.js index 7037101d8873..8680963c1a01 100644 --- a/test/integration/jsconfig-paths/test/index.test.js +++ b/test/integration/jsconfig-paths/test/index.test.js @@ -3,6 +3,7 @@ import fs from 'fs-extra' import { join } from 'path' import cheerio from 'cheerio' +import * as path from 'path' import { renderViaHTTP, findPort, @@ -10,7 +11,9 @@ import { nextBuild, killApp, check, + File, } from 'next-test-utils' +import * as JSON5 from 'json5' const appDir = join(__dirname, '..') let appPort @@ -21,7 +24,7 @@ async function get$(path, query) { return cheerio.load(html) } -describe('TypeScript Features', () => { +function runTests() { describe('default behavior', () => { let output = '' @@ -141,4 +144,30 @@ describe('TypeScript Features', () => { ).toBe(false) }) }) +} + +describe('jsconfig paths', () => { + runTests() +}) + +const jsconfig = new File(path.resolve(__dirname, '../jsconfig.json')) + +describe('jsconfig paths without baseurl', () => { + beforeAll(() => { + const jsconfigContent = JSON5.parse(jsconfig.originalContent) + delete jsconfigContent.compilerOptions.baseUrl + jsconfigContent.compilerOptions.paths = { + '@c/*': ['./components/*'], + '@lib/*': ['./lib/a/*', './lib/b/*'], + '@mycomponent': ['./components/hello.js'], + '*': ['./node_modules/*'], + } + jsconfig.write(JSON.stringify(jsconfigContent, null, 2)) + }) + + afterAll(() => { + jsconfig.restore() + }) + + runTests() }) diff --git a/test/integration/typescript-paths/test/index.test.js b/test/integration/typescript-paths/test/index.test.js index f7100467a146..aeb3c297dd62 100644 --- a/test/integration/typescript-paths/test/index.test.js +++ b/test/integration/typescript-paths/test/index.test.js @@ -2,7 +2,15 @@ import { join } from 'path' import cheerio from 'cheerio' -import { renderViaHTTP, findPort, launchApp, killApp } from 'next-test-utils' +import * as path from 'path' +import { + renderViaHTTP, + findPort, + launchApp, + killApp, + File, +} from 'next-test-utils' +import * as JSON5 from 'json5' const appDir = join(__dirname, '..') let appPort @@ -13,7 +21,7 @@ async function get$(path, query) { return cheerio.load(html) } -describe('TypeScript Features', () => { +function runTests() { describe('default behavior', () => { beforeAll(async () => { appPort = await findPort() @@ -46,4 +54,34 @@ describe('TypeScript Features', () => { expect($('body').text()).toMatch(/Not aliased to d\.ts file/) }) }) +} + +describe('typescript paths', () => { + runTests() +}) + +const tsconfig = new File(path.resolve(__dirname, '../tsconfig.json')) + +describe('typescript paths without baseurl', () => { + beforeAll(async () => { + const tsconfigContent = JSON5.parse(tsconfig.originalContent) + delete tsconfigContent.compilerOptions.baseUrl + tsconfigContent.compilerOptions.paths = { + 'isomorphic-unfetch': ['./types/unfetch.d.ts'], + '@c/*': ['./components/*'], + '@lib/*': ['./lib/a/*', './lib/b/*'], + '@mycomponent': ['./components/hello.tsx'], + 'd-ts-alias': [ + './components/alias-to-d-ts.d.ts', + './components/alias-to-d-ts.tsx', + ], + } + tsconfig.write(JSON.stringify(tsconfigContent, null, 2)) + }) + + afterAll(() => { + tsconfig.restore() + }) + + runTests() }) From 4cd8b23032895ab18878d443625e671b291c87d6 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 15 Aug 2022 16:29:51 +0200 Subject: [PATCH 7/7] Enable @typescript-eslint/no-use-before-define for functions (#39602) Follow-up to the earlier enabling of classes/variables etc. Bug Related issues linked using fixes #number Integration tests added Errors have helpful link attached, see contributing.md Feature Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. Related issues linked using fixes #number Integration tests added Documentation added Telemetry added. In case of a feature if it's used or not. Errors have helpful link attached, see contributing.md Documentation / Examples Make sure the linting passes by running pnpm lint The examples guidelines are followed from our contributing doc Co-authored-by: Steven --- .eslintrc.json | 2 +- .../build/analysis/extract-const-value.ts | 92 +- .../build/analysis/get-page-static-info.ts | 188 ++--- packages/next/build/entries.ts | 84 +- packages/next/build/index.ts | 178 ++-- packages/next/build/utils.ts | 406 ++++----- packages/next/build/webpack-config.ts | 22 +- .../loaders/next-middleware-wasm-loader.ts | 8 +- .../font-stylesheet-gathering-plugin.ts | 34 +- .../webpack/plugins/middleware-plugin.ts | 784 +++++++++--------- .../build/webpack/plugins/telemetry-plugin.ts | 62 +- packages/next/cli/next-info.ts | 38 +- .../client/components/hot-reloader.client.tsx | 34 +- .../client/dev/error-overlay/websocket.ts | 51 +- packages/next/client/future/image.tsx | 134 +-- packages/next/client/image.tsx | 240 +++--- packages/next/client/index.tsx | 778 ++++++++--------- packages/next/client/router.ts | 20 +- packages/next/client/use-intersection.tsx | 136 +-- packages/next/lib/patch-incorrect-lockfile.ts | 71 +- packages/next/lib/try-to-parse-path.ts | 46 +- packages/next/pages/_document.tsx | 542 ++++++------ packages/next/server/accept-header.ts | 18 +- packages/next/server/api-utils/node.ts | 442 +++++----- packages/next/server/base-server.ts | 30 +- packages/next/server/config.ts | 34 +- packages/next/server/dev/hot-middleware.ts | 40 +- packages/next/server/dev/hot-reloader.ts | 8 +- .../server/dev/on-demand-entry-handler.ts | 260 +++--- packages/next/server/image-optimizer.ts | 220 ++--- packages/next/server/lib/find-page-file.ts | 22 +- packages/next/server/next-server.ts | 48 +- .../next/server/node-web-streams-helper.ts | 146 ++-- packages/next/server/render.tsx | 74 +- packages/next/server/send-payload/index.ts | 48 +- packages/next/server/web/adapter.ts | 38 +- packages/next/server/web/next-url.ts | 20 +- packages/next/server/web/sandbox/context.ts | 228 ++--- packages/next/server/web/sandbox/sandbox.ts | 40 +- packages/next/server/web/utils.ts | 26 +- .../shared/lib/i18n/get-locale-redirect.ts | 96 +-- packages/next/shared/lib/router/router.ts | 464 +++++------ .../lib/router/utils/prepare-destination.ts | 62 +- .../shared/lib/router/utils/route-regex.ts | 116 +-- packages/next/telemetry/storage.ts | 20 +- packages/next/types/webpack.d.ts | 4 +- .../src/internal/helpers/nodeStackFrames.ts | 18 +- .../src/internal/helpers/stack-frame.ts | 20 +- .../react-refresh-utils/internal/helpers.ts | 38 +- .../e2e/edge-can-use-wasm-files/index.test.ts | 8 +- .../e2e/middleware-general/test/index.test.ts | 31 +- .../middleware-redirects/test/index.test.ts | 8 +- .../middleware-responses/test/index.test.ts | 6 +- .../middleware-rewrites/test/index.test.ts | 32 +- .../test/index.test.ts | 6 +- 55 files changed, 3312 insertions(+), 3309 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index cda24c1d8079..262fed1ad758 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -68,7 +68,7 @@ "@typescript-eslint/no-use-before-define": [ "warn", { - "functions": false, + "functions": true, "classes": true, "variables": true, "enums": true, diff --git a/packages/next/build/analysis/extract-const-value.ts b/packages/next/build/analysis/extract-const-value.ts index a08c9d40b9a9..209f3e813ae1 100644 --- a/packages/next/build/analysis/extract-const-value.ts +++ b/packages/next/build/analysis/extract-const-value.ts @@ -17,52 +17,6 @@ import type { export class NoSuchDeclarationError extends Error {} -/** - * Extracts the value of an exported const variable named `exportedName` - * (e.g. "export const config = { runtime: 'experimental-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, [exportedName]) - } - } - } - - throw new NoSuchDeclarationError() -} - function isExportDeclaration(node: Node): node is ExportDeclaration { return node.type === 'ExportDeclaration' } @@ -247,3 +201,49 @@ function extractValue(node: Node, path?: string[]): any { ) } } + +/** + * Extracts the value of an exported const variable named `exportedName` + * (e.g. "export const config = { runtime: 'experimental-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, [exportedName]) + } + } + } + + throw new NoSuchDeclarationError() +} diff --git a/packages/next/build/analysis/get-page-static-info.ts b/packages/next/build/analysis/get-page-static-info.ts index d2692d1450d2..98347d077035 100644 --- a/packages/next/build/analysis/get-page-static-info.ts +++ b/packages/next/build/analysis/get-page-static-info.ts @@ -22,79 +22,6 @@ export interface PageStaticInfo { middleware?: Partial } -/** - * 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 - page?: string -}): Promise { - const { isDev, pageFilePath, nextConfig, page } = params - - const fileContent = (await tryToReadFile(pageFilePath, !isDev)) || '' - if (/runtime|getStaticProps|getServerSideProps|matcher/.test(fileContent)) { - const swcAST = await parseModule(pageFilePath, fileContent) - const { ssg, ssr } = checkExports(swcAST) - - // default / failsafe value for config - let config: any = {} - try { - config = extractExportedConstValue(swcAST, 'config') - } catch (e) { - if (e instanceof UnsupportedValueError) { - warnAboutUnsupportedValue(pageFilePath, page, e) - } - // `export config` doesn't exist, or other unknown error throw by swc, silence them - } - - if ( - typeof config.runtime !== 'string' && - typeof config.runtime !== 'undefined' - ) { - throw new Error(`Provided runtime `) - } else if (!isServerRuntime(config.runtime)) { - const options = Object.values(SERVER_RUNTIME).join(', ') - if (typeof config.runtime !== 'string') { - throw new Error( - `The \`runtime\` config must be a string. Please leave it empty or choose one of: ${options}` - ) - } else { - throw new Error( - `Provided runtime "${config.runtime}" is not supported. Please leave it empty or choose one of: ${options}` - ) - } - } - - let runtime = - SERVER_RUNTIME.edge === config?.runtime - ? SERVER_RUNTIME.edge - : ssr || ssg - ? config?.runtime || nextConfig.experimental?.runtime - : undefined - - if (runtime === SERVER_RUNTIME.edge) { - warnAboutExperimentalEdgeApiFunctions() - } - - const middlewareConfig = getMiddlewareConfig(config, nextConfig) - - return { - ssr, - ssg, - ...(middlewareConfig && { middleware: middlewareConfig }), - ...(runtime && { runtime }), - } - } - - 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: @@ -154,27 +81,6 @@ async function tryToReadFile(filePath: string, shouldThrow: boolean) { } } -function getMiddlewareConfig( - config: any, - nextConfig: NextConfig -): Partial { - const result: Partial = {} - - if (config.matcher) { - result.pathMatcher = new RegExp( - getMiddlewareRegExpStrings(config.matcher, nextConfig).join('|') - ) - - if (result.pathMatcher.source.length > 4096) { - throw new Error( - `generated matcher config must be less than 4096 characters.` - ) - } - } - - return result -} - function getMiddlewareRegExpStrings( matcherOrMatchers: unknown, nextConfig: NextConfig @@ -226,6 +132,27 @@ function getMiddlewareRegExpStrings( } } +function getMiddlewareConfig( + config: any, + nextConfig: NextConfig +): Partial { + const result: Partial = {} + + if (config.matcher) { + result.pathMatcher = new RegExp( + getMiddlewareRegExpStrings(config.matcher, nextConfig).join('|') + ) + + if (result.pathMatcher.source.length > 4096) { + throw new Error( + `generated matcher config must be less than 4096 characters.` + ) + } + } + + return result +} + let warnedAboutExperimentalEdgeApiFunctions = false function warnAboutExperimentalEdgeApiFunctions() { if (warnedAboutExperimentalEdgeApiFunctions) { @@ -258,3 +185,76 @@ function warnAboutUnsupportedValue( warnedUnsupportedValueMap.set(pageFilePath, true) } + +/** + * 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 + page?: string +}): Promise { + const { isDev, pageFilePath, nextConfig, page } = params + + const fileContent = (await tryToReadFile(pageFilePath, !isDev)) || '' + if (/runtime|getStaticProps|getServerSideProps|matcher/.test(fileContent)) { + const swcAST = await parseModule(pageFilePath, fileContent) + const { ssg, ssr } = checkExports(swcAST) + + // default / failsafe value for config + let config: any = {} + try { + config = extractExportedConstValue(swcAST, 'config') + } catch (e) { + if (e instanceof UnsupportedValueError) { + warnAboutUnsupportedValue(pageFilePath, page, e) + } + // `export config` doesn't exist, or other unknown error throw by swc, silence them + } + + if ( + typeof config.runtime !== 'string' && + typeof config.runtime !== 'undefined' + ) { + throw new Error(`Provided runtime `) + } else if (!isServerRuntime(config.runtime)) { + const options = Object.values(SERVER_RUNTIME).join(', ') + if (typeof config.runtime !== 'string') { + throw new Error( + `The \`runtime\` config must be a string. Please leave it empty or choose one of: ${options}` + ) + } else { + throw new Error( + `Provided runtime "${config.runtime}" is not supported. Please leave it empty or choose one of: ${options}` + ) + } + } + + let runtime = + SERVER_RUNTIME.edge === config?.runtime + ? SERVER_RUNTIME.edge + : ssr || ssg + ? config?.runtime || nextConfig.experimental?.runtime + : undefined + + if (runtime === SERVER_RUNTIME.edge) { + warnAboutExperimentalEdgeApiFunctions() + } + + const middlewareConfig = getMiddlewareConfig(config, nextConfig) + + return { + ssr, + ssg, + ...(middlewareConfig && { middleware: middlewareConfig }), + ...(runtime && { runtime }), + } + } + + return { ssr: false, ssg: false } +} diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index 9cd266fd9535..4bea5cdfce4e 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -284,6 +284,48 @@ export function getClientEntry(opts: { : pageLoader } +export async function runDependingOnPageType(params: { + onClient: () => T + onEdgeServer: () => T + onServer: () => T + page: string + pageRuntime: ServerRuntime +}): Promise { + if (isMiddlewareFile(params.page)) { + await params.onEdgeServer() + return + } + if (params.page.match(API_ROUTE)) { + if (params.pageRuntime === SERVER_RUNTIME.edge) { + await params.onEdgeServer() + return + } + + await params.onServer() + return + } + if (params.page === '/_document') { + await params.onServer() + return + } + if ( + params.page === '/_app' || + params.page === '/_error' || + params.page === '/404' || + params.page === '/500' + ) { + await Promise.all([params.onClient(), params.onServer()]) + return + } + if (params.pageRuntime === SERVER_RUNTIME.edge) { + await Promise.all([params.onClient(), params.onEdgeServer()]) + return + } + + await Promise.all([params.onClient(), params.onServer()]) + return +} + export async function createEntrypoints(params: CreateEntrypointsParams) { const { config, @@ -439,48 +481,6 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { } } -export async function runDependingOnPageType(params: { - onClient: () => T - onEdgeServer: () => T - onServer: () => T - page: string - pageRuntime: ServerRuntime -}): Promise { - if (isMiddlewareFile(params.page)) { - await params.onEdgeServer() - return - } - if (params.page.match(API_ROUTE)) { - if (params.pageRuntime === SERVER_RUNTIME.edge) { - await params.onEdgeServer() - return - } - - await params.onServer() - return - } - if (params.page === '/_document') { - await params.onServer() - return - } - if ( - params.page === '/_app' || - params.page === '/_error' || - params.page === '/404' || - params.page === '/500' - ) { - await Promise.all([params.onClient(), params.onServer()]) - return - } - if (params.pageRuntime === SERVER_RUNTIME.edge) { - await Promise.all([params.onClient(), params.onEdgeServer()]) - return - } - - await Promise.all([params.onClient(), params.onServer()]) - return -} - export function finalizeEntrypoint({ name, compilerType, diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 91e4e9ff1505..458d733c8583 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -154,6 +154,95 @@ type SingleCompilerResult = { stats: webpack.Stats | undefined } +/** + * typescript will be loaded in "next/lib/verifyTypeScriptSetup" and + * then passed to "next/lib/typescript/runTypeCheck" as a parameter. + * + * Since it is impossible to pass a function from main thread to a worker, + * instead of running "next/lib/typescript/runTypeCheck" in a worker, + * we will run entire "next/lib/verifyTypeScriptSetup" in a worker instead. + */ +function verifyTypeScriptSetup( + dir: string, + intentDirs: string[], + typeCheckPreflight: boolean, + tsconfigPath: string, + disableStaticImages: boolean, + cacheDir: string | undefined, + numWorkers: number | undefined, + enableWorkerThreads: boolean | undefined +) { + const typeCheckWorker = new JestWorker( + require.resolve('../lib/verifyTypeScriptSetup'), + { + numWorkers, + enableWorkerThreads, + maxRetries: 0, + } + ) as JestWorker & { + verifyTypeScriptSetup: typeof import('../lib/verifyTypeScriptSetup').verifyTypeScriptSetup + } + + typeCheckWorker.getStdout().pipe(process.stdout) + typeCheckWorker.getStderr().pipe(process.stderr) + + return typeCheckWorker + .verifyTypeScriptSetup( + dir, + intentDirs, + typeCheckPreflight, + tsconfigPath, + disableStaticImages, + cacheDir + ) + .then((result) => { + typeCheckWorker.end() + return result + }) +} + +function generateClientSsgManifest( + prerenderManifest: PrerenderManifest, + { + buildId, + distDir, + locales, + }: { buildId: string; distDir: string; locales: string[] } +) { + const ssgPages = new Set( + [ + ...Object.entries(prerenderManifest.routes) + // Filter out dynamic routes + .filter(([, { srcRoute }]) => srcRoute == null) + .map(([route]) => normalizeLocalePath(route, locales).pathname), + ...Object.keys(prerenderManifest.dynamicRoutes), + ].sort() + ) + + const clientSsgManifestContent = `self.__SSG_MANIFEST=${devalue( + ssgPages + )};self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()` + + writeFileSync( + path.join(distDir, CLIENT_STATIC_FILES_PATH, buildId, '_ssgManifest.js'), + clientSsgManifestContent + ) +} + +function isTelemetryPlugin(plugin: unknown): plugin is TelemetryPlugin { + return plugin instanceof TelemetryPlugin +} + +function pageToRoute(page: string) { + const routeRegex = getNamedRouteRegex(page) + return { + page, + regex: normalizeRouteRegex(routeRegex.re.source), + routeKeys: routeRegex.routeKeys, + namedRegex: routeRegex.namedRegex, + } +} + export default async function build( dir: string, conf = null, @@ -2406,92 +2495,3 @@ export default async function build( teardownCrashReporter() } } - -/** - * typescript will be loaded in "next/lib/verifyTypeScriptSetup" and - * then passed to "next/lib/typescript/runTypeCheck" as a parameter. - * - * Since it is impossible to pass a function from main thread to a worker, - * instead of running "next/lib/typescript/runTypeCheck" in a worker, - * we will run entire "next/lib/verifyTypeScriptSetup" in a worker instead. - */ -function verifyTypeScriptSetup( - dir: string, - intentDirs: string[], - typeCheckPreflight: boolean, - tsconfigPath: string, - disableStaticImages: boolean, - cacheDir: string | undefined, - numWorkers: number | undefined, - enableWorkerThreads: boolean | undefined -) { - const typeCheckWorker = new JestWorker( - require.resolve('../lib/verifyTypeScriptSetup'), - { - numWorkers, - enableWorkerThreads, - maxRetries: 0, - } - ) as JestWorker & { - verifyTypeScriptSetup: typeof import('../lib/verifyTypeScriptSetup').verifyTypeScriptSetup - } - - typeCheckWorker.getStdout().pipe(process.stdout) - typeCheckWorker.getStderr().pipe(process.stderr) - - return typeCheckWorker - .verifyTypeScriptSetup( - dir, - intentDirs, - typeCheckPreflight, - tsconfigPath, - disableStaticImages, - cacheDir - ) - .then((result) => { - typeCheckWorker.end() - return result - }) -} - -function generateClientSsgManifest( - prerenderManifest: PrerenderManifest, - { - buildId, - distDir, - locales, - }: { buildId: string; distDir: string; locales: string[] } -) { - const ssgPages = new Set( - [ - ...Object.entries(prerenderManifest.routes) - // Filter out dynamic routes - .filter(([, { srcRoute }]) => srcRoute == null) - .map(([route]) => normalizeLocalePath(route, locales).pathname), - ...Object.keys(prerenderManifest.dynamicRoutes), - ].sort() - ) - - const clientSsgManifestContent = `self.__SSG_MANIFEST=${devalue( - ssgPages - )};self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()` - - writeFileSync( - path.join(distDir, CLIENT_STATIC_FILES_PATH, buildId, '_ssgManifest.js'), - clientSsgManifestContent - ) -} - -function isTelemetryPlugin(plugin: unknown): plugin is TelemetryPlugin { - return plugin instanceof TelemetryPlugin -} - -function pageToRoute(page: string) { - const routeRegex = getNamedRouteRegex(page) - return { - page, - regex: normalizeRouteRegex(routeRegex.re.source), - routeKeys: routeRegex.routeKeys, - namedRegex: routeRegex.namedRegex, - } -} diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 5cc24805b4e2..e37d1fa55420 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -65,6 +65,209 @@ const fsStat = (file: string) => { loadRequireHook() +export function unique(main: ReadonlyArray, sub: ReadonlyArray): T[] { + return [...new Set([...main, ...sub])] +} + +export function difference( + main: ReadonlyArray | ReadonlySet, + sub: ReadonlyArray | ReadonlySet +): T[] { + const a = new Set(main) + const b = new Set(sub) + return [...a].filter((x) => !b.has(x)) +} + +/** + * Return an array of the items shared by both arrays. + */ +function intersect(main: ReadonlyArray, sub: ReadonlyArray): T[] { + const a = new Set(main) + const b = new Set(sub) + return [...new Set([...a].filter((x) => b.has(x)))] +} + +function sum(a: ReadonlyArray): number { + return a.reduce((size, stat) => size + stat, 0) +} + +function denormalizeAppPagePath(page: string): string { + return page + '/page' +} + +type ComputeFilesGroup = { + files: ReadonlyArray + size: { + total: number + } +} + +type ComputeFilesManifest = { + unique: ComputeFilesGroup + common: ComputeFilesGroup +} + +type ComputeFilesManifestResult = { + router: { + pages: ComputeFilesManifest + app?: ComputeFilesManifest + } + sizes: Map +} + +let cachedBuildManifest: BuildManifest | undefined +let cachedAppBuildManifest: AppBuildManifest | undefined + +let lastCompute: ComputeFilesManifestResult | undefined +let lastComputePageInfo: boolean | undefined + +export async function computeFromManifest( + manifests: { + build: BuildManifest + app?: AppBuildManifest + }, + distPath: string, + gzipSize: boolean = true, + pageInfos?: Map +): Promise { + if ( + Object.is(cachedBuildManifest, manifests.build) && + lastComputePageInfo === !!pageInfos && + Object.is(cachedAppBuildManifest, manifests.app) + ) { + return lastCompute! + } + + // Determine the files that are in pages and app and count them, this will + // tell us if they are unique or common. + + const countBuildFiles = ( + map: Map, + key: string, + manifest: Record> + ) => { + for (const file of manifest[key]) { + if (key === '/_app') { + map.set(file, Infinity) + } else if (map.has(file)) { + map.set(file, map.get(file)! + 1) + } else { + map.set(file, 1) + } + } + } + + const files: { + pages: { + each: Map + expected: number + } + app?: { + each: Map + expected: number + } + } = { + pages: { each: new Map(), expected: 0 }, + } + + for (const key in manifests.build.pages) { + if (pageInfos) { + const pageInfo = pageInfos.get(key) + // don't include AMP pages since they don't rely on shared bundles + // AMP First pages are not under the pageInfos key + if (pageInfo?.isHybridAmp) { + continue + } + } + + files.pages.expected++ + countBuildFiles(files.pages.each, key, manifests.build.pages) + } + + // Collect the build files form the app manifest. + if (manifests.app?.pages) { + files.app = { each: new Map(), expected: 0 } + + for (const key in manifests.app.pages) { + files.app.expected++ + countBuildFiles(files.app.each, key, manifests.app.pages) + } + } + + const getSize = gzipSize ? fsStatGzip : fsStat + const stats = new Map() + + // For all of the files in the pages and app manifests, compute the file size + // at once. + + await Promise.all( + [ + ...new Set([ + ...files.pages.each.keys(), + ...(files.app?.each.keys() ?? []), + ]), + ].map(async (f) => { + try { + // Add the file size to the stats. + stats.set(f, await getSize(path.join(distPath, f))) + } catch {} + }) + ) + + const groupFiles = async (listing: { + each: Map + expected: number + }): Promise => { + const entries = [...listing.each.entries()] + + const shapeGroup = (group: [string, number][]): ComputeFilesGroup => + group.reduce( + (acc, [f]) => { + acc.files.push(f) + + const size = stats.get(f) + if (typeof size === 'number') { + acc.size.total += size + } + + return acc + }, + { + files: [] as string[], + size: { + total: 0, + }, + } + ) + + return { + unique: shapeGroup(entries.filter(([, len]) => len === 1)), + common: shapeGroup( + entries.filter( + ([, len]) => len === listing.expected || len === Infinity + ) + ), + } + } + + lastCompute = { + router: { + pages: await groupFiles(files.pages), + app: files.app ? await groupFiles(files.app) : undefined, + }, + sizes: stats, + } + + cachedBuildManifest = manifests.build + cachedAppBuildManifest = manifests.app + lastComputePageInfo = !!pageInfos + return lastCompute! +} + +export function isMiddlewareFilename(file?: string) { + return file === MIDDLEWARE_FILENAME || file === `src/${MIDDLEWARE_FILENAME}` +} + export interface PageInfo { isHybridAmp?: boolean size: number @@ -526,205 +729,6 @@ export function printCustomRoutes({ } } -type ComputeFilesGroup = { - files: ReadonlyArray - size: { - total: number - } -} - -type ComputeFilesManifest = { - unique: ComputeFilesGroup - common: ComputeFilesGroup -} - -type ComputeFilesManifestResult = { - router: { - pages: ComputeFilesManifest - app?: ComputeFilesManifest - } - sizes: Map -} - -let cachedBuildManifest: BuildManifest | undefined -let cachedAppBuildManifest: AppBuildManifest | undefined - -let lastCompute: ComputeFilesManifestResult | undefined -let lastComputePageInfo: boolean | undefined - -export async function computeFromManifest( - manifests: { - build: BuildManifest - app?: AppBuildManifest - }, - distPath: string, - gzipSize: boolean = true, - pageInfos?: Map -): Promise { - if ( - Object.is(cachedBuildManifest, manifests.build) && - lastComputePageInfo === !!pageInfos && - Object.is(cachedAppBuildManifest, manifests.app) - ) { - return lastCompute! - } - - // Determine the files that are in pages and app and count them, this will - // tell us if they are unique or common. - - const countBuildFiles = ( - map: Map, - key: string, - manifest: Record> - ) => { - for (const file of manifest[key]) { - if (key === '/_app') { - map.set(file, Infinity) - } else if (map.has(file)) { - map.set(file, map.get(file)! + 1) - } else { - map.set(file, 1) - } - } - } - - const files: { - pages: { - each: Map - expected: number - } - app?: { - each: Map - expected: number - } - } = { - pages: { each: new Map(), expected: 0 }, - } - - for (const key in manifests.build.pages) { - if (pageInfos) { - const pageInfo = pageInfos.get(key) - // don't include AMP pages since they don't rely on shared bundles - // AMP First pages are not under the pageInfos key - if (pageInfo?.isHybridAmp) { - continue - } - } - - files.pages.expected++ - countBuildFiles(files.pages.each, key, manifests.build.pages) - } - - // Collect the build files form the app manifest. - if (manifests.app?.pages) { - files.app = { each: new Map(), expected: 0 } - - for (const key in manifests.app.pages) { - files.app.expected++ - countBuildFiles(files.app.each, key, manifests.app.pages) - } - } - - const getSize = gzipSize ? fsStatGzip : fsStat - const stats = new Map() - - // For all of the files in the pages and app manifests, compute the file size - // at once. - - await Promise.all( - [ - ...new Set([ - ...files.pages.each.keys(), - ...(files.app?.each.keys() ?? []), - ]), - ].map(async (f) => { - try { - // Add the file size to the stats. - stats.set(f, await getSize(path.join(distPath, f))) - } catch {} - }) - ) - - const groupFiles = async (listing: { - each: Map - expected: number - }): Promise => { - const entries = [...listing.each.entries()] - - const shapeGroup = (group: [string, number][]): ComputeFilesGroup => - group.reduce( - (acc, [f]) => { - acc.files.push(f) - - const size = stats.get(f) - if (typeof size === 'number') { - acc.size.total += size - } - - return acc - }, - { - files: [] as string[], - size: { - total: 0, - }, - } - ) - - return { - unique: shapeGroup(entries.filter(([, len]) => len === 1)), - common: shapeGroup( - entries.filter( - ([, len]) => len === listing.expected || len === Infinity - ) - ), - } - } - - lastCompute = { - router: { - pages: await groupFiles(files.pages), - app: files.app ? await groupFiles(files.app) : undefined, - }, - sizes: stats, - } - - cachedBuildManifest = manifests.build - cachedAppBuildManifest = manifests.app - lastComputePageInfo = !!pageInfos - return lastCompute! -} - -export function unique(main: ReadonlyArray, sub: ReadonlyArray): T[] { - return [...new Set([...main, ...sub])] -} - -export function difference( - main: ReadonlyArray | ReadonlySet, - sub: ReadonlyArray | ReadonlySet -): T[] { - const a = new Set(main) - const b = new Set(sub) - return [...a].filter((x) => !b.has(x)) -} - -/** - * Return an array of the items shared by both arrays. - */ -function intersect(main: ReadonlyArray, sub: ReadonlyArray): T[] { - const a = new Set(main) - const b = new Set(sub) - return [...new Set([...a].filter((x) => b.has(x)))] -} - -function sum(a: ReadonlyArray): number { - return a.reduce((size, stat) => size + stat, 0) -} - -function denormalizeAppPagePath(page: string): string { - return page + '/page' -} - export async function getJsPageSizeInKb( routerType: ROUTER_TYPE, page: string, @@ -1430,10 +1434,6 @@ export function isMiddlewareFile(file: string) { ) } -export function isMiddlewareFilename(file?: string) { - return file === MIDDLEWARE_FILENAME || file === `src/${MIDDLEWARE_FILENAME}` -} - export function getPossibleMiddlewareFilenames( folder: string, extensions: string[] diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 20eb0ea57fa9..d8d74a636589 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -66,6 +66,17 @@ const NEXT_PROJECT_ROOT = pathJoin(__dirname, '..', '..') const NEXT_PROJECT_ROOT_DIST = pathJoin(NEXT_PROJECT_ROOT, 'dist') const NEXT_PROJECT_ROOT_DIST_CLIENT = pathJoin(NEXT_PROJECT_ROOT_DIST, 'client') +function errorIfEnvConflicted(config: NextConfigComplete, key: string) { + const isPrivateKey = /^(?:NODE_.+)|^(?:__.+)$/i.test(key) + const hasNextRuntimeKey = key === 'NEXT_RUNTIME' + + if (isPrivateKey || hasNextRuntimeKey) { + throw new Error( + `The key "${key}" under "env" in ${config.configFileName} is not allowed. https://nextjs.org/docs/messages/env-key-not-allowed` + ) + } +} + const watchOptions = Object.freeze({ aggregateTimeout: 5, ignored: ['**/.git/**', '**/.next/**'], @@ -2433,14 +2444,3 @@ export default async function getBaseWebpackConfig( return webpackConfig } - -function errorIfEnvConflicted(config: NextConfigComplete, key: string) { - const isPrivateKey = /^(?:NODE_.+)|^(?:__.+)$/i.test(key) - const hasNextRuntimeKey = key === 'NEXT_RUNTIME' - - if (isPrivateKey || hasNextRuntimeKey) { - throw new Error( - `The key "${key}" under "env" in ${config.configFileName} is not allowed. https://nextjs.org/docs/messages/env-key-not-allowed` - ) - } -} diff --git a/packages/next/build/webpack/loaders/next-middleware-wasm-loader.ts b/packages/next/build/webpack/loaders/next-middleware-wasm-loader.ts index 8ed7dd7212dc..c2a2f069e1e7 100644 --- a/packages/next/build/webpack/loaders/next-middleware-wasm-loader.ts +++ b/packages/next/build/webpack/loaders/next-middleware-wasm-loader.ts @@ -1,6 +1,10 @@ import { getModuleBuildInfo } from './get-module-build-info' import crypto from 'crypto' +function sha1(source: string | Buffer) { + return crypto.createHash('sha1').update(source).digest('hex') +} + export default function MiddlewareWasmLoader(this: any, source: Buffer) { const name = `wasm_${sha1(source)}` const filePath = `edge-chunks/${name}.wasm` @@ -11,7 +15,3 @@ export default function MiddlewareWasmLoader(this: any, source: Buffer) { } export const raw = true - -function sha1(source: string | Buffer) { - return crypto.createHash('sha1').update(source).digest('hex') -} diff --git a/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts b/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts index 7c881255afaf..a35d3124da2d 100644 --- a/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts +++ b/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts @@ -30,6 +30,23 @@ function minifyCss(css: string): Promise { .then((res) => res.css) } +function isNodeCreatingLinkElement(node: any) { + const callee = node.callee as any + if (callee.type !== 'Identifier') { + return false + } + const componentNode = node.arguments[0] as any + if (componentNode.type !== 'Literal') { + return false + } + // React has pragma: _jsx. + // Next has pragma: __jsx. + return ( + (callee.name === '_jsx' || callee.name === '__jsx') && + componentNode.value === 'link' + ) +} + export class FontStylesheetGatheringPlugin { compiler?: webpack.Compiler gatheredStylesheets: Array = [] @@ -240,20 +257,3 @@ export class FontStylesheetGatheringPlugin { }) } } - -function isNodeCreatingLinkElement(node: any) { - const callee = node.callee as any - if (callee.type !== 'Identifier') { - return false - } - const componentNode = node.arguments[0] as any - if (componentNode.type !== 'Literal') { - return false - } - // React has pragma: _jsx. - // Next has pragma: __jsx. - return ( - (callee.name === '_jsx' || callee.name === '__jsx') && - componentNode.value === 'link' - ) -} diff --git a/packages/next/build/webpack/plugins/middleware-plugin.ts b/packages/next/build/webpack/plugins/middleware-plugin.ts index 7ebf2bd1bc91..3678b8a960a0 100644 --- a/packages/next/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/build/webpack/plugins/middleware-plugin.ts @@ -51,192 +51,368 @@ const middlewareManifest: MiddlewareManifest = { version: 1, } -export default class MiddlewarePlugin { - dev: boolean +/** + * Checks the value of usingIndirectEval and when it is a set of modules it + * check if any of the modules is actually being used. If the value is + * simply truthy it will return true. + */ +function isUsingIndirectEvalAndUsedByExports(args: { + entryModule: webpack5.Module + moduleGraph: webpack5.ModuleGraph + runtime: any + usingIndirectEval: true | Set + wp: typeof webpack5 +}): boolean { + const { moduleGraph, runtime, entryModule, usingIndirectEval, wp } = args + if (typeof usingIndirectEval === 'boolean') { + return usingIndirectEval + } - constructor({ dev }: { dev: boolean }) { - this.dev = dev + const exportsInfo = moduleGraph.getExportsInfo(entryModule) + for (const exportName of usingIndirectEval) { + if (exportsInfo.getUsed(exportName, runtime) !== wp.UsageState.Unused) { + return true + } } - apply(compiler: webpack5.Compiler) { - compiler.hooks.compilation.tap(NAME, (compilation, params) => { - const { hooks } = params.normalModuleFactory - /** - * This is the static code analysis phase. - */ - const codeAnalyzer = getCodeAnalizer({ - dev: this.dev, - compiler, - compilation, - }) - hooks.parser.for('javascript/auto').tap(NAME, codeAnalyzer) - hooks.parser.for('javascript/dynamic').tap(NAME, codeAnalyzer) - hooks.parser.for('javascript/esm').tap(NAME, codeAnalyzer) + return false +} - /** - * Extract all metadata for the entry points in a Map object. - */ - const metadataByEntry = new Map() - compilation.hooks.afterOptimizeModules.tap( - NAME, - getExtractMetadata({ - compilation, - compiler, - dev: this.dev, - metadataByEntry, - }) +function getEntryFiles(entryFiles: string[], meta: EntryMetadata) { + const files: string[] = [] + if (meta.edgeSSR) { + if (meta.edgeSSR.isServerComponent) { + files.push(`server/${FLIGHT_MANIFEST}.js`) + files.push( + ...entryFiles + .filter( + (file) => + file.startsWith('pages/') && !file.endsWith('.hot-update.js') + ) + .map( + (file) => + 'server/' + + // TODO-APP: seems this should be removed. + file.replace('.js', NEXT_CLIENT_SSR_ENTRY_SUFFIX + '.js') + ) ) + } - /** - * Emit the middleware manifest. - */ - compilation.hooks.processAssets.tap( - { - name: 'NextJsMiddlewareManifest', - stage: (webpack as any).Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, - }, - getCreateAssets({ compilation, metadataByEntry }) - ) - }) + files.push( + `server/${MIDDLEWARE_BUILD_MANIFEST}.js`, + `server/${MIDDLEWARE_REACT_LOADABLE_MANIFEST}.js` + ) } -} -export async function handleWebpackExtenalForEdgeRuntime({ - request, - context, - contextInfo, - getResolve, -}: { - request: string - context: string - contextInfo: any - getResolve: () => any -}) { - if (contextInfo.issuerLayer === 'middleware' && isNodeJsModule(request)) { - // allows user to provide and use their polyfills, as we do with buffer. - try { - await getResolve()(context, request) - } catch { - return `root globalThis.__import_unsupported('${request}')` - } - } + files.push( + ...entryFiles + .filter((file) => !file.endsWith('.hot-update.js')) + .map((file) => 'server/' + file) + ) + return files } -function getCodeAnalizer(params: { - dev: boolean - compiler: webpack5.Compiler +function getCreateAssets(params: { compilation: webpack5.Compilation + metadataByEntry: Map }) { - return (parser: webpack5.javascript.JavascriptParser) => { - const { - dev, - compiler: { webpack: wp }, - compilation, - } = params - const { hooks } = parser - - /** - * For an expression this will check the graph to ensure it is being used - * by exports. Then it will store in the module buildInfo a boolean to - * express that it contains dynamic code and, if it is available, the - * module path that is using it. - */ - const handleExpression = () => { - if (!isInMiddlewareLayer(parser)) { - return + const { compilation, metadataByEntry } = params + return (assets: any) => { + for (const entrypoint of compilation.entrypoints.values()) { + if (!entrypoint.name) { + continue } - wp.optimize.InnerGraph.onUsage(parser.state, (used = true) => { - const buildInfo = getModuleBuildInfo(parser.state.module) - if (buildInfo.usingIndirectEval === true || used === false) { - return - } - - if (!buildInfo.usingIndirectEval || used === true) { - buildInfo.usingIndirectEval = used - return - } + // There should always be metadata for the entrypoint. + const metadata = metadataByEntry.get(entrypoint.name) + const page = + metadata?.edgeMiddleware?.page || + metadata?.edgeSSR?.page || + metadata?.edgeApiFunction?.page + if (!page) { + continue + } - buildInfo.usingIndirectEval = new Set([ - ...Array.from(buildInfo.usingIndirectEval), - ...Array.from(used), - ]) + const { namedRegex } = getNamedMiddlewareRegex(page, { + catchAll: !metadata.edgeSSR && !metadata.edgeApiFunction, }) - } + const regexp = metadata?.edgeMiddleware?.matcherRegexp || namedRegex - /** - * This expression handler allows to wrap a dynamic code expression with a - * function call where we can warn about dynamic code not being allowed - * but actually execute the expression. - */ - const handleWrapExpression = (expr: any) => { - if (!isInMiddlewareLayer(parser)) { - return + const edgeFunctionDefinition: EdgeFunctionDefinition = { + env: Array.from(metadata.env), + files: getEntryFiles(entrypoint.getFiles(), metadata), + name: entrypoint.name, + page: page, + regexp, + wasm: Array.from(metadata.wasmBindings, ([name, filePath]) => ({ + name, + filePath, + })), + assets: Array.from(metadata.assetBindings, ([name, filePath]) => ({ + name, + filePath, + })), } - if (dev) { - const { ConstDependency } = wp.dependencies - const dep1 = new ConstDependency( - '__next_eval__(function() { return ', - expr.range[0] - ) - dep1.loc = expr.loc - parser.state.module.addPresentationalDependency(dep1) - const dep2 = new ConstDependency('})', expr.range[1]) - dep2.loc = expr.loc - parser.state.module.addPresentationalDependency(dep2) + if (metadata.edgeApiFunction || metadata.edgeSSR) { + middlewareManifest.functions[page] = edgeFunctionDefinition + } else { + middlewareManifest.middleware[page] = edgeFunctionDefinition } - - handleExpression() - return true } - /** - * This expression handler allows to wrap a WebAssembly.compile invocation with a - * function call where we can warn about WASM code generation not being allowed - * but actually execute the expression. - */ - const handleWrapWasmCompileExpression = (expr: any) => { - if (!isInMiddlewareLayer(parser)) { - return - } - - if (dev) { - const { ConstDependency } = wp.dependencies - const dep1 = new ConstDependency( - '__next_webassembly_compile__(function() { return ', - expr.range[0] - ) - dep1.loc = expr.loc - parser.state.module.addPresentationalDependency(dep1) - const dep2 = new ConstDependency('})', expr.range[1]) - dep2.loc = expr.loc - parser.state.module.addPresentationalDependency(dep2) - } - - handleExpression() - } + middlewareManifest.sortedMiddleware = getSortedRoutes( + Object.keys(middlewareManifest.middleware) + ) - /** - * This expression handler allows to wrap a WebAssembly.instatiate invocation with a - * function call where we can warn about WASM code generation not being allowed - * but actually execute the expression. - * - * Note that we don't update `usingIndirectEval`, i.e. we don't abort a production build - * since we can't determine statically if the first parameter is a module (legit use) or - * a buffer (dynamic code generation). - */ - const handleWrapWasmInstantiateExpression = (expr: any) => { - if (!isInMiddlewareLayer(parser)) { - return - } + assets[MIDDLEWARE_MANIFEST] = new sources.RawSource( + JSON.stringify(middlewareManifest, null, 2) + ) + } +} - if (dev) { - const { ConstDependency } = wp.dependencies - const dep1 = new ConstDependency( - '__next_webassembly_instantiate__(function() { return ', - expr.range[0] - ) +function buildWebpackError({ + message, + loc, + compilation, + entryModule, + parser, +}: { + message: string + loc?: any + compilation: webpack5.Compilation + entryModule?: webpack5.Module + parser?: webpack5.javascript.JavascriptParser +}) { + const error = new compilation.compiler.webpack.WebpackError(message) + error.name = NAME + const module = entryModule ?? parser?.state.current + if (module) { + error.module = module + } + error.loc = loc + return error +} + +function isInMiddlewareLayer(parser: webpack5.javascript.JavascriptParser) { + return parser.state.module?.layer === 'middleware' +} + +function isInMiddlewareFile(parser: webpack5.javascript.JavascriptParser) { + return ( + parser.state.current?.layer === 'middleware' && + /middleware\.\w+$/.test(parser.state.current?.rawRequest) + ) +} + +function isNullLiteral(expr: any) { + return expr.value === null +} + +function isUndefinedIdentifier(expr: any) { + return expr.name === 'undefined' +} + +function isProcessEnvMemberExpression(memberExpression: any): boolean { + return ( + memberExpression.object?.type === 'Identifier' && + memberExpression.object.name === 'process' && + ((memberExpression.property?.type === 'Literal' && + memberExpression.property.value === 'env') || + (memberExpression.property?.type === 'Identifier' && + memberExpression.property.name === 'env')) + ) +} + +function isNodeJsModule(moduleName: string) { + return require('module').builtinModules.includes(moduleName) +} + +function buildUnsupportedApiError({ + apiName, + loc, + ...rest +}: { + apiName: string + loc: any + compilation: webpack5.Compilation + parser: webpack5.javascript.JavascriptParser +}) { + return buildWebpackError({ + message: `A Node.js API is used (${apiName} at line: ${loc.start.line}) which is not supported in the Edge Runtime. +Learn more: https://nextjs.org/docs/api-reference/edge-runtime`, + loc, + ...rest, + }) +} + +function registerUnsupportedApiHooks( + parser: webpack5.javascript.JavascriptParser, + compilation: webpack5.Compilation +) { + for (const expression of EDGE_UNSUPPORTED_NODE_APIS) { + const warnForUnsupportedApi = (node: any) => { + if (!isInMiddlewareLayer(parser)) { + return + } + compilation.warnings.push( + buildUnsupportedApiError({ + compilation, + parser, + apiName: expression, + ...node, + }) + ) + return true + } + parser.hooks.call.for(expression).tap(NAME, warnForUnsupportedApi) + parser.hooks.expression.for(expression).tap(NAME, warnForUnsupportedApi) + parser.hooks.callMemberChain + .for(expression) + .tap(NAME, warnForUnsupportedApi) + parser.hooks.expressionMemberChain + .for(expression) + .tap(NAME, warnForUnsupportedApi) + } + + const warnForUnsupportedProcessApi = (node: any, [callee]: string[]) => { + if (!isInMiddlewareLayer(parser) || callee === 'env') { + return + } + compilation.warnings.push( + buildUnsupportedApiError({ + compilation, + parser, + apiName: `process.${callee}`, + ...node, + }) + ) + return true + } + + parser.hooks.callMemberChain + .for('process') + .tap(NAME, warnForUnsupportedProcessApi) + parser.hooks.expressionMemberChain + .for('process') + .tap(NAME, warnForUnsupportedProcessApi) +} + +function getCodeAnalyzer(params: { + dev: boolean + compiler: webpack5.Compiler + compilation: webpack5.Compilation +}) { + return (parser: webpack5.javascript.JavascriptParser) => { + const { + dev, + compiler: { webpack: wp }, + compilation, + } = params + const { hooks } = parser + + /** + * For an expression this will check the graph to ensure it is being used + * by exports. Then it will store in the module buildInfo a boolean to + * express that it contains dynamic code and, if it is available, the + * module path that is using it. + */ + const handleExpression = () => { + if (!isInMiddlewareLayer(parser)) { + return + } + + wp.optimize.InnerGraph.onUsage(parser.state, (used = true) => { + const buildInfo = getModuleBuildInfo(parser.state.module) + if (buildInfo.usingIndirectEval === true || used === false) { + return + } + + if (!buildInfo.usingIndirectEval || used === true) { + buildInfo.usingIndirectEval = used + return + } + + buildInfo.usingIndirectEval = new Set([ + ...Array.from(buildInfo.usingIndirectEval), + ...Array.from(used), + ]) + }) + } + + /** + * This expression handler allows to wrap a dynamic code expression with a + * function call where we can warn about dynamic code not being allowed + * but actually execute the expression. + */ + const handleWrapExpression = (expr: any) => { + if (!isInMiddlewareLayer(parser)) { + return + } + + if (dev) { + const { ConstDependency } = wp.dependencies + const dep1 = new ConstDependency( + '__next_eval__(function() { return ', + expr.range[0] + ) + dep1.loc = expr.loc + parser.state.module.addPresentationalDependency(dep1) + const dep2 = new ConstDependency('})', expr.range[1]) + dep2.loc = expr.loc + parser.state.module.addPresentationalDependency(dep2) + } + + handleExpression() + return true + } + + /** + * This expression handler allows to wrap a WebAssembly.compile invocation with a + * function call where we can warn about WASM code generation not being allowed + * but actually execute the expression. + */ + const handleWrapWasmCompileExpression = (expr: any) => { + if (!isInMiddlewareLayer(parser)) { + return + } + + if (dev) { + const { ConstDependency } = wp.dependencies + const dep1 = new ConstDependency( + '__next_webassembly_compile__(function() { return ', + expr.range[0] + ) + dep1.loc = expr.loc + parser.state.module.addPresentationalDependency(dep1) + const dep2 = new ConstDependency('})', expr.range[1]) + dep2.loc = expr.loc + parser.state.module.addPresentationalDependency(dep2) + } + + handleExpression() + } + + /** + * This expression handler allows to wrap a WebAssembly.instatiate invocation with a + * function call where we can warn about WASM code generation not being allowed + * but actually execute the expression. + * + * Note that we don't update `usingIndirectEval`, i.e. we don't abort a production build + * since we can't determine statically if the first parameter is a module (legit use) or + * a buffer (dynamic code generation). + */ + const handleWrapWasmInstantiateExpression = (expr: any) => { + if (!isInMiddlewareLayer(parser)) { + return + } + + if (dev) { + const { ConstDependency } = wp.dependencies + const dep1 = new ConstDependency( + '__next_webassembly_instantiate__(function() { return ', + expr.range[0] + ) dep1.loc = expr.loc parser.state.module.addPresentationalDependency(dep1) const dep2 = new ConstDependency('})', expr.range[1]) @@ -526,249 +702,73 @@ function getExtractMetadata(params: { } } -/** - * Checks the value of usingIndirectEval and when it is a set of modules it - * check if any of the modules is actually being used. If the value is - * simply truthy it will return true. - */ -function isUsingIndirectEvalAndUsedByExports(args: { - entryModule: webpack5.Module - moduleGraph: webpack5.ModuleGraph - runtime: any - usingIndirectEval: true | Set - wp: typeof webpack5 -}): boolean { - const { moduleGraph, runtime, entryModule, usingIndirectEval, wp } = args - if (typeof usingIndirectEval === 'boolean') { - return usingIndirectEval - } +export default class MiddlewarePlugin { + dev: boolean - const exportsInfo = moduleGraph.getExportsInfo(entryModule) - for (const exportName of usingIndirectEval) { - if (exportsInfo.getUsed(exportName, runtime) !== wp.UsageState.Unused) { - return true - } + constructor({ dev }: { dev: boolean }) { + this.dev = dev } - return false -} - -function getCreateAssets(params: { - compilation: webpack5.Compilation - metadataByEntry: Map -}) { - const { compilation, metadataByEntry } = params - return (assets: any) => { - for (const entrypoint of compilation.entrypoints.values()) { - if (!entrypoint.name) { - continue - } - - // There should always be metadata for the entrypoint. - const metadata = metadataByEntry.get(entrypoint.name) - const page = - metadata?.edgeMiddleware?.page || - metadata?.edgeSSR?.page || - metadata?.edgeApiFunction?.page - if (!page) { - continue - } - - const { namedRegex } = getNamedMiddlewareRegex(page, { - catchAll: !metadata.edgeSSR && !metadata.edgeApiFunction, + apply(compiler: webpack5.Compiler) { + compiler.hooks.compilation.tap(NAME, (compilation, params) => { + const { hooks } = params.normalModuleFactory + /** + * This is the static code analysis phase. + */ + const codeAnalyzer = getCodeAnalyzer({ + dev: this.dev, + compiler, + compilation, }) - const regexp = metadata?.edgeMiddleware?.matcherRegexp || namedRegex - - const edgeFunctionDefinition: EdgeFunctionDefinition = { - env: Array.from(metadata.env), - files: getEntryFiles(entrypoint.getFiles(), metadata), - name: entrypoint.name, - page: page, - regexp, - wasm: Array.from(metadata.wasmBindings, ([name, filePath]) => ({ - name, - filePath, - })), - assets: Array.from(metadata.assetBindings, ([name, filePath]) => ({ - name, - filePath, - })), - } - - if (metadata.edgeApiFunction || metadata.edgeSSR) { - middlewareManifest.functions[page] = edgeFunctionDefinition - } else { - middlewareManifest.middleware[page] = edgeFunctionDefinition - } - } - - middlewareManifest.sortedMiddleware = getSortedRoutes( - Object.keys(middlewareManifest.middleware) - ) - - assets[MIDDLEWARE_MANIFEST] = new sources.RawSource( - JSON.stringify(middlewareManifest, null, 2) - ) - } -} - -function getEntryFiles(entryFiles: string[], meta: EntryMetadata) { - const files: string[] = [] - if (meta.edgeSSR) { - if (meta.edgeSSR.isServerComponent) { - files.push(`server/${FLIGHT_MANIFEST}.js`) - files.push( - ...entryFiles - .filter( - (file) => - file.startsWith('pages/') && !file.endsWith('.hot-update.js') - ) - .map( - (file) => - 'server/' + - // TODO-APP: seems this should be removed. - file.replace('.js', NEXT_CLIENT_SSR_ENTRY_SUFFIX + '.js') - ) - ) - } - - files.push( - `server/${MIDDLEWARE_BUILD_MANIFEST}.js`, - `server/${MIDDLEWARE_REACT_LOADABLE_MANIFEST}.js` - ) - } - - files.push( - ...entryFiles - .filter((file) => !file.endsWith('.hot-update.js')) - .map((file) => 'server/' + file) - ) - return files -} + hooks.parser.for('javascript/auto').tap(NAME, codeAnalyzer) + hooks.parser.for('javascript/dynamic').tap(NAME, codeAnalyzer) + hooks.parser.for('javascript/esm').tap(NAME, codeAnalyzer) -function registerUnsupportedApiHooks( - parser: webpack5.javascript.JavascriptParser, - compilation: webpack5.Compilation -) { - for (const expression of EDGE_UNSUPPORTED_NODE_APIS) { - const warnForUnsupportedApi = (node: any) => { - if (!isInMiddlewareLayer(parser)) { - return - } - compilation.warnings.push( - buildUnsupportedApiError({ + /** + * Extract all metadata for the entry points in a Map object. + */ + const metadataByEntry = new Map() + compilation.hooks.afterOptimizeModules.tap( + NAME, + getExtractMetadata({ compilation, - parser, - apiName: expression, - ...node, + compiler, + dev: this.dev, + metadataByEntry, }) ) - return true - } - parser.hooks.call.for(expression).tap(NAME, warnForUnsupportedApi) - parser.hooks.expression.for(expression).tap(NAME, warnForUnsupportedApi) - parser.hooks.callMemberChain - .for(expression) - .tap(NAME, warnForUnsupportedApi) - parser.hooks.expressionMemberChain - .for(expression) - .tap(NAME, warnForUnsupportedApi) - } - const warnForUnsupportedProcessApi = (node: any, [callee]: string[]) => { - if (!isInMiddlewareLayer(parser) || callee === 'env') { - return - } - compilation.warnings.push( - buildUnsupportedApiError({ - compilation, - parser, - apiName: `process.${callee}`, - ...node, - }) - ) - return true + /** + * Emit the middleware manifest. + */ + compilation.hooks.processAssets.tap( + { + name: 'NextJsMiddlewareManifest', + stage: (webpack as any).Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + }, + getCreateAssets({ compilation, metadataByEntry }) + ) + }) } - - parser.hooks.callMemberChain - .for('process') - .tap(NAME, warnForUnsupportedProcessApi) - parser.hooks.expressionMemberChain - .for('process') - .tap(NAME, warnForUnsupportedProcessApi) } -function buildUnsupportedApiError({ - apiName, - loc, - ...rest -}: { - apiName: string - loc: any - compilation: webpack5.Compilation - parser: webpack5.javascript.JavascriptParser -}) { - return buildWebpackError({ - message: `A Node.js API is used (${apiName} at line: ${loc.start.line}) which is not supported in the Edge Runtime. -Learn more: https://nextjs.org/docs/api-reference/edge-runtime`, - loc, - ...rest, - }) -} - -function buildWebpackError({ - message, - loc, - compilation, - entryModule, - parser, +export async function handleWebpackExtenalForEdgeRuntime({ + request, + context, + contextInfo, + getResolve, }: { - message: string - loc?: any - compilation: webpack5.Compilation - entryModule?: webpack5.Module - parser?: webpack5.javascript.JavascriptParser + request: string + context: string + contextInfo: any + getResolve: () => any }) { - const error = new compilation.compiler.webpack.WebpackError(message) - error.name = NAME - const module = entryModule ?? parser?.state.current - if (module) { - error.module = module + if (contextInfo.issuerLayer === 'middleware' && isNodeJsModule(request)) { + // allows user to provide and use their polyfills, as we do with buffer. + try { + await getResolve()(context, request) + } catch { + return `root globalThis.__import_unsupported('${request}')` + } } - error.loc = loc - return error -} - -function isInMiddlewareLayer(parser: webpack5.javascript.JavascriptParser) { - return parser.state.module?.layer === 'middleware' -} - -function isInMiddlewareFile(parser: webpack5.javascript.JavascriptParser) { - return ( - parser.state.current?.layer === 'middleware' && - /middleware\.\w+$/.test(parser.state.current?.rawRequest) - ) -} - -function isNullLiteral(expr: any) { - return expr.value === null -} - -function isUndefinedIdentifier(expr: any) { - return expr.name === 'undefined' -} - -function isProcessEnvMemberExpression(memberExpression: any): boolean { - return ( - memberExpression.object?.type === 'Identifier' && - memberExpression.object.name === 'process' && - ((memberExpression.property?.type === 'Literal' && - memberExpression.property.value === 'env') || - (memberExpression.property?.type === 'Identifier' && - memberExpression.property.name === 'env')) - ) -} - -function isNodeJsModule(moduleName: string) { - return require('module').builtinModules.includes(moduleName) } diff --git a/packages/next/build/webpack/plugins/telemetry-plugin.ts b/packages/next/build/webpack/plugins/telemetry-plugin.ts index 16526b6f7419..5844a64408d2 100644 --- a/packages/next/build/webpack/plugins/telemetry-plugin.ts +++ b/packages/next/build/webpack/plugins/telemetry-plugin.ts @@ -93,6 +93,37 @@ const BUILD_FEATURES: Array = [ const ELIMINATED_PACKAGES = new Set() +/** + * Determine if there is a feature of interest in the specified 'module'. + */ +function findFeatureInModule(module: Module): Feature | undefined { + if (module.type !== 'javascript/auto') { + return + } + for (const [feature, path] of FEATURE_MODULE_MAP) { + if (module.identifier().replace(/\\/g, '/').endsWith(path)) { + return feature + } + } +} + +/** + * Find unique origin modules in the specified 'connections', which possibly + * contains more than one connection for a module due to different types of + * dependency. + */ +function findUniqueOriginModulesInConnections( + connections: Connection[] +): Set { + const originModules = new Set() + for (const connection of connections) { + if (!originModules.has(connection.originModule)) { + originModules.add(connection.originModule) + } + } + return originModules +} + /** * Plugin that queries the ModuleGraph to look for modules that correspond to * certain features (e.g. next/image and next/script) and record how many times @@ -162,34 +193,3 @@ export class TelemetryPlugin implements webpack.WebpackPluginInstance { return Array.from(ELIMINATED_PACKAGES) } } - -/** - * Determine if there is a feature of interest in the specified 'module'. - */ -function findFeatureInModule(module: Module): Feature | undefined { - if (module.type !== 'javascript/auto') { - return - } - for (const [feature, path] of FEATURE_MODULE_MAP) { - if (module.identifier().replace(/\\/g, '/').endsWith(path)) { - return feature - } - } -} - -/** - * Find unique origin modules in the specified 'connections', which possibly - * contains more than one connection for a module due to different types of - * dependency. - */ -function findUniqueOriginModulesInConnections( - connections: Connection[] -): Set { - const originModules = new Set() - for (const connection of connections) { - if (!originModules.has(connection.originModule)) { - originModules.add(connection.originModule) - } - } - return originModules -} diff --git a/packages/next/cli/next-info.ts b/packages/next/cli/next-info.ts index c9c468b26ff5..2377b715b26b 100755 --- a/packages/next/cli/next-info.ts +++ b/packages/next/cli/next-info.ts @@ -9,6 +9,25 @@ import { printAndExit } from '../server/lib/utils' import { cliCommand } from '../lib/commands' import isError from '../lib/is-error' +function getPackageVersion(packageName: string) { + try { + return require(`${packageName}/package.json`).version + } catch { + return 'N/A' + } +} + +function getBinaryVersion(binaryName: string) { + try { + return childProcess + .execFileSync(binaryName, ['--version']) + .toString() + .trim() + } catch { + return 'N/A' + } +} + const nextInfo: cliCommand = async (argv) => { const validArgs: arg.Spec = { // Types @@ -92,22 +111,3 @@ const nextInfo: cliCommand = async (argv) => { } export { nextInfo } - -function getPackageVersion(packageName: string) { - try { - return require(`${packageName}/package.json`).version - } catch { - return 'N/A' - } -} - -function getBinaryVersion(binaryName: string) { - try { - return childProcess - .execFileSync(binaryName, ['--version']) - .toString() - .trim() - } catch { - return 'N/A' - } -} diff --git a/packages/next/client/components/hot-reloader.client.tsx b/packages/next/client/components/hot-reloader.client.tsx index c8b9f18d5b59..f96d3b681d85 100644 --- a/packages/next/client/components/hot-reloader.client.tsx +++ b/packages/next/client/components/hot-reloader.client.tsx @@ -100,6 +100,23 @@ function canApplyUpdates() { // } // } +function performFullReload(err: any, sendMessage: any) { + const stackTrace = + err && + ((err.stack && err.stack.split('\n').slice(0, 5).join('\n')) || + err.message || + err + '') + + sendMessage( + JSON.stringify({ + event: 'client-full-reload', + stackTrace, + }) + ) + + window.location.reload() +} + // Attempt to update code on the fly, fall back to a hard reload. function tryApplyUpdates(onHotUpdateSuccess: any, sendMessage: any) { // @ts-expect-error module.hot exists @@ -169,23 +186,6 @@ function tryApplyUpdates(onHotUpdateSuccess: any, sendMessage: any) { ) } -function performFullReload(err: any, sendMessage: any) { - const stackTrace = - err && - ((err.stack && err.stack.split('\n').slice(0, 5).join('\n')) || - err.message || - err + '') - - sendMessage( - JSON.stringify({ - event: 'client-full-reload', - stackTrace, - }) - ) - - window.location.reload() -} - function processMessage( e: any, sendMessage: any, diff --git a/packages/next/client/dev/error-overlay/websocket.ts b/packages/next/client/dev/error-overlay/websocket.ts index 46f81de4c4a7..fe3e47a7802e 100644 --- a/packages/next/client/dev/error-overlay/websocket.ts +++ b/packages/next/client/dev/error-overlay/websocket.ts @@ -32,16 +32,34 @@ export function connectHMR(options: { options.timeout = 5 * 1000 } - init() + function init() { + if (source) source.close() - let timer = setInterval(function () { - if (Date.now() - lastActivity > options.timeout) { - handleDisconnect() + function handleOnline() { + if (options.log) console.log('[HMR] connected') + lastActivity = Date.now() } - }, options.timeout / 2) - function init() { - if (source) source.close() + function handleMessage(event: any) { + lastActivity = Date.now() + + eventCallbacks.forEach((cb) => { + cb(event) + }) + } + + let timer: NodeJS.Timeout + function handleDisconnect() { + clearInterval(timer) + source.close() + setTimeout(init, options.timeout) + } + timer = setInterval(function () { + if (Date.now() - lastActivity > options.timeout) { + handleDisconnect() + } + }, options.timeout / 2) + const { hostname, port } = location const protocol = getSocketProtocol(options.assetPrefix || '') const assetPrefix = options.assetPrefix.replace(/^\/+/, '') @@ -60,22 +78,5 @@ export function connectHMR(options: { source.onmessage = handleMessage } - function handleOnline() { - if (options.log) console.log('[HMR] connected') - lastActivity = Date.now() - } - - function handleMessage(event: any) { - lastActivity = Date.now() - - eventCallbacks.forEach((cb) => { - cb(event) - }) - } - - function handleDisconnect() { - clearInterval(timer) - source.close() - setTimeout(init, options.timeout) - } + init() } diff --git a/packages/next/client/future/image.tsx b/packages/next/client/future/image.tsx index c305631d0147..81cc5e45d964 100644 --- a/packages/next/client/future/image.tsx +++ b/packages/next/client/future/image.tsx @@ -429,6 +429,73 @@ const ImageElement = ({ ) } +function defaultLoader({ + config, + src, + width, + quality, +}: ImageLoaderPropsWithConfig): string { + if (process.env.NODE_ENV !== 'production') { + const missingValues = [] + + // these should always be provided but make sure they are + if (!src) missingValues.push('src') + if (!width) missingValues.push('width') + + if (missingValues.length > 0) { + throw new Error( + `Next Image Optimization requires ${missingValues.join( + ', ' + )} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( + { src, width, quality } + )}` + ) + } + + if (src.startsWith('//')) { + throw new Error( + `Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)` + ) + } + + if ( + !src.startsWith('/') && + (config.domains || experimentalRemotePatterns) + ) { + let parsedSrc: URL + try { + parsedSrc = new URL(src) + } catch (err) { + console.error(err) + throw new Error( + `Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)` + ) + } + + if (process.env.NODE_ENV !== 'test') { + // We use dynamic require because this should only error in development + const { hasMatch } = require('../../shared/lib/match-remote-pattern') + if (!hasMatch(config.domains, experimentalRemotePatterns, parsedSrc)) { + throw new Error( + `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + + `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` + ) + } + } + } + } + + if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) { + // Special case to make svg serve as-is to avoid proxying + // through the built-in Image Optimization API. + return src + } + + return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${ + quality || 75 + }` +} + export default function Image({ src, sizes, @@ -803,70 +870,3 @@ export default function Image({ ) } - -function defaultLoader({ - config, - src, - width, - quality, -}: ImageLoaderPropsWithConfig): string { - if (process.env.NODE_ENV !== 'production') { - const missingValues = [] - - // these should always be provided but make sure they are - if (!src) missingValues.push('src') - if (!width) missingValues.push('width') - - if (missingValues.length > 0) { - throw new Error( - `Next Image Optimization requires ${missingValues.join( - ', ' - )} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( - { src, width, quality } - )}` - ) - } - - if (src.startsWith('//')) { - throw new Error( - `Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)` - ) - } - - if ( - !src.startsWith('/') && - (config.domains || experimentalRemotePatterns) - ) { - let parsedSrc: URL - try { - parsedSrc = new URL(src) - } catch (err) { - console.error(err) - throw new Error( - `Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)` - ) - } - - if (process.env.NODE_ENV !== 'test') { - // We use dynamic require because this should only error in development - const { hasMatch } = require('../../shared/lib/match-remote-pattern') - if (!hasMatch(config.domains, experimentalRemotePatterns, parsedSrc)) { - throw new Error( - `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + - `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` - ) - } - } - } - } - - if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) { - // Special case to make svg serve as-is to avoid proxying - // through the built-in Image Optimization API. - return src - } - - return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${ - quality || 75 - }` -} diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 20e17aff73e3..f4338203a4e3 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -18,6 +18,10 @@ import { ImageConfigContext } from '../shared/lib/image-config-context' import { warnOnce } from '../shared/lib/utils' import { normalizePathTrailingSlash } from './normalize-trailing-slash' +function normalizeSrc(src: string): string { + return src[0] === '/' ? src.slice(1) : src +} + const { experimentalRemotePatterns = [], experimentalUnoptimized } = (process.env.__NEXT_IMAGE_OPTS as any) || {} const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete @@ -55,6 +59,122 @@ type ImageLoaderPropsWithConfig = ImageLoaderProps & { config: Readonly } +function imgixLoader({ + config, + src, + width, + quality, +}: ImageLoaderPropsWithConfig): string { + // Demo: https://static.imgix.net/daisy.png?auto=format&fit=max&w=300 + const url = new URL(`${config.path}${normalizeSrc(src)}`) + const params = url.searchParams + + // auto params can be combined with comma separation, or reiteration + params.set('auto', params.getAll('auto').join(',') || 'format') + params.set('fit', params.get('fit') || 'max') + params.set('w', params.get('w') || width.toString()) + + if (quality) { + params.set('q', quality.toString()) + } + + return url.href +} + +function akamaiLoader({ + config, + src, + width, +}: ImageLoaderPropsWithConfig): string { + return `${config.path}${normalizeSrc(src)}?imwidth=${width}` +} + +function cloudinaryLoader({ + config, + src, + width, + quality, +}: ImageLoaderPropsWithConfig): string { + // Demo: https://res.cloudinary.com/demo/image/upload/w_300,c_limit,q_auto/turtles.jpg + const params = ['f_auto', 'c_limit', 'w_' + width, 'q_' + (quality || 'auto')] + const paramsString = params.join(',') + '/' + return `${config.path}${paramsString}${normalizeSrc(src)}` +} + +function customLoader({ src }: ImageLoaderProps): string { + throw new Error( + `Image with src "${src}" is missing "loader" prop.` + + `\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader` + ) +} + +function defaultLoader({ + config, + src, + width, + quality, +}: ImageLoaderPropsWithConfig): string { + if (process.env.NODE_ENV !== 'production') { + const missingValues = [] + + // these should always be provided but make sure they are + if (!src) missingValues.push('src') + if (!width) missingValues.push('width') + + if (missingValues.length > 0) { + throw new Error( + `Next Image Optimization requires ${missingValues.join( + ', ' + )} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( + { src, width, quality } + )}` + ) + } + + if (src.startsWith('//')) { + throw new Error( + `Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)` + ) + } + + if ( + !src.startsWith('/') && + (config.domains || experimentalRemotePatterns) + ) { + let parsedSrc: URL + try { + parsedSrc = new URL(src) + } catch (err) { + console.error(err) + throw new Error( + `Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)` + ) + } + + if (process.env.NODE_ENV !== 'test') { + // We use dynamic require because this should only error in development + const { hasMatch } = require('../shared/lib/match-remote-pattern') + if (!hasMatch(config.domains, experimentalRemotePatterns, parsedSrc)) { + throw new Error( + `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + + `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` + ) + } + } + } + } + + if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) { + // Special case to make svg serve as-is to avoid proxying + // through the built-in Image Optimization API. + return src + } + + return `${normalizePathTrailingSlash(config.path)}?url=${encodeURIComponent( + src + )}&w=${width}&q=${quality || 75}` +} + const loaders = new Map< LoaderValue, (props: ImageLoaderPropsWithConfig) => string @@ -959,123 +1079,3 @@ export default function Image({ ) } - -function normalizeSrc(src: string): string { - return src[0] === '/' ? src.slice(1) : src -} - -function imgixLoader({ - config, - src, - width, - quality, -}: ImageLoaderPropsWithConfig): string { - // Demo: https://static.imgix.net/daisy.png?auto=format&fit=max&w=300 - const url = new URL(`${config.path}${normalizeSrc(src)}`) - const params = url.searchParams - - // auto params can be combined with comma separation, or reiteration - params.set('auto', params.getAll('auto').join(',') || 'format') - params.set('fit', params.get('fit') || 'max') - params.set('w', params.get('w') || width.toString()) - - if (quality) { - params.set('q', quality.toString()) - } - - return url.href -} - -function akamaiLoader({ - config, - src, - width, -}: ImageLoaderPropsWithConfig): string { - return `${config.path}${normalizeSrc(src)}?imwidth=${width}` -} - -function cloudinaryLoader({ - config, - src, - width, - quality, -}: ImageLoaderPropsWithConfig): string { - // Demo: https://res.cloudinary.com/demo/image/upload/w_300,c_limit,q_auto/turtles.jpg - const params = ['f_auto', 'c_limit', 'w_' + width, 'q_' + (quality || 'auto')] - const paramsString = params.join(',') + '/' - return `${config.path}${paramsString}${normalizeSrc(src)}` -} - -function customLoader({ src }: ImageLoaderProps): string { - throw new Error( - `Image with src "${src}" is missing "loader" prop.` + - `\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader` - ) -} - -function defaultLoader({ - config, - src, - width, - quality, -}: ImageLoaderPropsWithConfig): string { - if (process.env.NODE_ENV !== 'production') { - const missingValues = [] - - // these should always be provided but make sure they are - if (!src) missingValues.push('src') - if (!width) missingValues.push('width') - - if (missingValues.length > 0) { - throw new Error( - `Next Image Optimization requires ${missingValues.join( - ', ' - )} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( - { src, width, quality } - )}` - ) - } - - if (src.startsWith('//')) { - throw new Error( - `Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)` - ) - } - - if ( - !src.startsWith('/') && - (config.domains || experimentalRemotePatterns) - ) { - let parsedSrc: URL - try { - parsedSrc = new URL(src) - } catch (err) { - console.error(err) - throw new Error( - `Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)` - ) - } - - if (process.env.NODE_ENV !== 'test') { - // We use dynamic require because this should only error in development - const { hasMatch } = require('../shared/lib/match-remote-pattern') - if (!hasMatch(config.domains, experimentalRemotePatterns, parsedSrc)) { - throw new Error( - `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + - `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` - ) - } - } - } - } - - if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) { - // Special case to make svg serve as-is to avoid proxying - // through the built-in Image Optimization API. - return src - } - - return `${normalizePathTrailingSlash(config.path)}?url=${encodeURIComponent( - src - )}&w=${width}&q=${quality || 75}` -} diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index 9b5086028300..a4d62af4256a 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -289,193 +289,190 @@ export async function initialize(opts: { webpackHMR?: any } = {}): Promise<{ return { assetPrefix: prefix } } -const wrapApp = - (App: AppComponent) => - (wrappedAppProps: Record): JSX.Element => { - const appProps: AppProps = { - ...wrappedAppProps, - Component: CachedComponent, - err: initialData.err, - router, - } - return {renderApp(App, appProps)} +let RSCComponent: (props: any) => JSX.Element +if (process.env.__NEXT_RSC) { + const getCacheKey = () => { + const { pathname, search } = location + return pathname + search } -export async function hydrate(opts?: { beforeRender?: () => Promise }) { - let initialErr = initialData.err - - try { - const appEntrypoint = await pageLoader.routeLoader.whenEntrypoint('/_app') - if ('error' in appEntrypoint) { - throw appEntrypoint.error - } + const { + createFromFetch, + createFromReadableStream, + } = require('next/dist/compiled/react-server-dom-webpack') + const encoder = new TextEncoder() - const { component: app, exports: mod } = appEntrypoint - CachedApp = app as AppComponent - if (mod && mod.reportWebVitals) { - onPerfEntry = ({ - id, - name, - startTime, - value, - duration, - entryType, - entries, - }: any): void => { - // Combines timestamp with random number for unique ID - const uniqueID: string = `${Date.now()}-${ - Math.floor(Math.random() * (9e12 - 1)) + 1e12 - }` - let perfStartEntry: string | undefined + let initialServerDataBuffer: string[] | undefined = undefined + let initialServerDataWriter: ReadableStreamDefaultController | undefined = + undefined + let initialServerDataLoaded = false + let initialServerDataFlushed = false - if (entries && entries.length) { - perfStartEntry = entries[0].startTime - } + function nextServerDataCallback(seg: [number, string, string]) { + if (seg[0] === 0) { + initialServerDataBuffer = [] + } else { + if (!initialServerDataBuffer) + throw new Error('Unexpected server data: missing bootstrap script.') - const webVitals: NextWebVitalsMetric = { - id: id || uniqueID, - name, - startTime: startTime || perfStartEntry, - value: value == null ? duration : value, - label: - entryType === 'mark' || entryType === 'measure' - ? 'custom' - : 'web-vital', - } - mod.reportWebVitals(webVitals) + if (initialServerDataWriter) { + initialServerDataWriter.enqueue(encoder.encode(seg[2])) + } else { + initialServerDataBuffer.push(seg[2]) } } + } - const pageEntrypoint = - // The dev server fails to serve script assets when there's a hydration - // error, so we need to skip waiting for the entrypoint. - process.env.NODE_ENV === 'development' && initialData.err - ? { error: initialData.err } - : await pageLoader.routeLoader.whenEntrypoint(initialData.page) - if ('error' in pageEntrypoint) { - throw pageEntrypoint.error + // There might be race conditions between `nextServerDataRegisterWriter` and + // `DOMContentLoaded`. The former will be called when React starts to hydrate + // the root, the latter will be called when the DOM is fully loaded. + // For streaming, the former is called first due to partial hydration. + // For non-streaming, the latter can be called first. + // Hence, we use two variables `initialServerDataLoaded` and + // `initialServerDataFlushed` to make sure the writer will be closed and + // `initialServerDataBuffer` will be cleared in the right time. + function nextServerDataRegisterWriter(ctr: ReadableStreamDefaultController) { + if (initialServerDataBuffer) { + initialServerDataBuffer.forEach((val) => { + ctr.enqueue(encoder.encode(val)) + }) + if (initialServerDataLoaded && !initialServerDataFlushed) { + ctr.close() + initialServerDataFlushed = true + initialServerDataBuffer = undefined + } } - CachedComponent = pageEntrypoint.component - if (process.env.NODE_ENV !== 'production') { - const { isValidElementType } = require('next/dist/compiled/react-is') - if (!isValidElementType(CachedComponent)) { - throw new Error( - `The default export is not a React Component in page: "${initialData.page}"` - ) - } + initialServerDataWriter = ctr + } + + // When `DOMContentLoaded`, we can close all pending writers to finish hydration. + const DOMContentLoaded = function () { + if (initialServerDataWriter && !initialServerDataFlushed) { + initialServerDataWriter.close() + initialServerDataFlushed = true + initialServerDataBuffer = undefined } - } catch (error) { - // This catches errors like throwing in the top level of a module - initialErr = getProperError(error) + initialServerDataLoaded = true + } + // It's possible that the DOM is already loaded. + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', DOMContentLoaded, false) + } else { + DOMContentLoaded() } - if (process.env.NODE_ENV === 'development') { - const { - getServerError, - } = require('next/dist/compiled/@next/react-dev-overlay/dist/client') - // Server-side runtime errors need to be re-thrown on the client-side so - // that the overlay is rendered. - if (initialErr) { - if (initialErr === initialData.err) { - setTimeout(() => { - let error - try { - // Generate a new error object. We `throw` it because some browsers - // will set the `stack` when thrown, and we want to ensure ours is - // not overridden when we re-throw it below. - throw new Error(initialErr!.message) - } catch (e) { - error = e as Error - } + const nextServerDataLoadingGlobal = ((self as any).__next_s = + (self as any).__next_s || []) + nextServerDataLoadingGlobal.forEach(nextServerDataCallback) + nextServerDataLoadingGlobal.push = nextServerDataCallback - error.name = initialErr!.name - error.stack = initialErr!.stack - throw getServerError(error, initialErr!.source) - }) - } - // We replaced the server-side error with a client-side error, and should - // no longer rewrite the stack trace to a Node error. - else { - setTimeout(() => { - throw initialErr - }) - } - } + function createResponseCache() { + return new Map() } + const rscCache = createResponseCache() - if (window.__NEXT_PRELOADREADY) { - await window.__NEXT_PRELOADREADY(initialData.dynamicIds) + function fetchFlight(href: string, props?: any) { + const url = new URL(href, location.origin) + const searchParams = url.searchParams + searchParams.append('__flight__', '1') + if (props) { + searchParams.append('__props__', JSON.stringify(props)) + } + return fetch(url.toString()) } - router = createRouter(initialData.page, initialData.query, asPath, { - initialProps: initialData.props, - pageLoader, - App: CachedApp, - Component: CachedComponent, - wrapApp, - err: initialErr, - isFallback: Boolean(initialData.isFallback), - subscription: (info, App, scroll) => - render( - Object.assign< - {}, - Omit, - Pick - >({}, info, { - App, - scroll, - }) as RenderRouteInfo - ), - locale: initialData.locale, - locales: initialData.locales, - defaultLocale, - domainLocales: initialData.domainLocales, - isPreview: initialData.isPreview, - isRsc: initialData.rsc, - }) + function useServerResponse(cacheKey: string, serialized?: string) { + let response = rscCache.get(cacheKey) + if (response) return response - initialMatchesMiddleware = await router._initialMatchesMiddlewarePromise + if (initialServerDataBuffer) { + const readable = new ReadableStream({ + start(controller) { + nextServerDataRegisterWriter(controller) + }, + }) + response = createFromReadableStream(readable) + } else { + if (serialized) { + const readable = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(serialized)) + controller.close() + }, + }) + response = createFromReadableStream(readable) + } else { + response = createFromFetch(fetchFlight(getCacheKey())) + } + } - const renderCtx: RenderRouteInfo = { - App: CachedApp, - initial: true, - Component: CachedComponent, - props: initialData.props, - err: initialErr, + rscCache.set(cacheKey, response) + return response } - if (opts?.beforeRender) { - await opts.beforeRender() + const ServerRoot = ({ + cacheKey, + serialized, + }: { + cacheKey: string + serialized?: string + }) => { + React.useEffect(() => { + rscCache.delete(cacheKey) + }) + const response = useServerResponse(cacheKey, serialized) + return response.readRoot() } - render(renderCtx) + RSCComponent = (props: any) => { + const cacheKey = getCacheKey() + const { __flight__ } = props + return + } } -async function render(renderingProps: RenderRouteInfo): Promise { - if (renderingProps.err) { - await renderError(renderingProps) - return - } +function renderApp(App: AppComponent, appProps: AppProps) { + return +} - try { - await doRender(renderingProps) - } catch (err) { - const renderErr = getProperError(err) - // bubble up cancelation errors - if ((renderErr as Error & { cancelled?: boolean }).cancelled) { - throw renderErr - } +function AppContainer({ + children, +}: React.PropsWithChildren<{}>): React.ReactElement { + return ( + + // TODO: Fix disabled eslint rule + // eslint-disable-next-line @typescript-eslint/no-use-before-define + renderError({ App: CachedApp, err: error }).catch((err) => + console.error('Error rendering page: ', err) + ) + } + > + + + + {children} + + + + + ) +} - if (process.env.NODE_ENV === 'development') { - // Ensure this error is displayed in the overlay in development - setTimeout(() => { - throw renderErr - }) +const wrapApp = + (App: AppComponent) => + (wrappedAppProps: Record): JSX.Element => { + const appProps: AppProps = { + ...wrappedAppProps, + Component: CachedComponent, + err: initialData.err, + router, } - await renderError({ ...renderingProps, err: renderErr }) + return {renderApp(App, appProps)} } -} // This method handles all runtime and debug errors. // 404 and 500 errors are special kind of errors @@ -492,6 +489,8 @@ function renderError(renderErrorProps: RenderErrorProps): Promise { // We need to render an empty so that the `` can // render itself. + // TODO: Fix disabled eslint rule + // eslint-disable-next-line @typescript-eslint/no-use-before-define return doRender({ App: () => null, props: {}, @@ -546,6 +545,8 @@ function renderError(renderErrorProps: RenderErrorProps): Promise { ? renderErrorProps.props : loadGetInitialProps(App, appCtx) ).then((initProps) => + // TODO: Fix disabled eslint rule + // eslint-disable-next-line @typescript-eslint/no-use-before-define doRender({ ...renderErrorProps, err, @@ -557,41 +558,23 @@ function renderError(renderErrorProps: RenderErrorProps): Promise { }) } +// Dummy component that we render as a child of Root so that we can +// toggle the correct styles before the page is rendered. +function Head({ callback }: { callback: () => void }): null { + // We use `useLayoutEffect` to guarantee the callback is executed + // as soon as React flushes the update. + React.useLayoutEffect(() => callback(), [callback]) + return null +} + let reactRoot: any = null // On initial render a hydrate should always happen let shouldHydrate: boolean = true -function renderReactElement( - domEl: HTMLElement, - fn: (cb: () => void) => JSX.Element -): void { - // mark start of hydrate/render - if (ST) { - performance.mark('beforeRender') - } - - const reactEl = fn(shouldHydrate ? markHydrateComplete : markRenderComplete) - if (process.env.__NEXT_REACT_ROOT) { - if (!reactRoot) { - // Unlike with createRoot, you don't need a separate root.render() call here - reactRoot = ReactDOM.hydrateRoot(domEl, reactEl) - // TODO: Remove shouldHydrate variable when React 18 is stable as it can depend on `reactRoot` existing - shouldHydrate = false - } else { - const startTransition = (React as any).startTransition - startTransition(() => { - reactRoot.render(reactEl) - }) - } - } else { - // The check for `.hydrate` is there to support React alternatives like preact - if (shouldHydrate) { - ReactDOM.hydrate(reactEl, domEl) - shouldHydrate = false - } else { - ReactDOM.render(reactEl, domEl) - } - } +function clearMarks(): void { + ;['beforeRender', 'afterHydrate', 'afterRender', 'routeChange'].forEach( + (mark) => performance.clearMarks(mark) + ) } function markHydrateComplete(): void { @@ -642,181 +625,69 @@ function markRenderComplete(): void { ) } -function clearMarks(): void { - ;['beforeRender', 'afterHydrate', 'afterRender', 'routeChange'].forEach( - (mark) => performance.clearMarks(mark) - ) -} - -function AppContainer({ - children, -}: React.PropsWithChildren<{}>): React.ReactElement { - return ( - - renderError({ App: CachedApp, err: error }).catch((err) => - console.error('Error rendering page: ', err) - ) - } - > - - - - {children} - - - - - ) -} - -function renderApp(App: AppComponent, appProps: AppProps) { - return -} - -let RSCComponent: (props: any) => JSX.Element -if (process.env.__NEXT_RSC) { - const getCacheKey = () => { - const { pathname, search } = location - return pathname + search +function renderReactElement( + domEl: HTMLElement, + fn: (cb: () => void) => JSX.Element +): void { + // mark start of hydrate/render + if (ST) { + performance.mark('beforeRender') } - const { - createFromFetch, - createFromReadableStream, - } = require('next/dist/compiled/react-server-dom-webpack') - const encoder = new TextEncoder() - - let initialServerDataBuffer: string[] | undefined = undefined - let initialServerDataWriter: ReadableStreamDefaultController | undefined = - undefined - let initialServerDataLoaded = false - let initialServerDataFlushed = false - - function nextServerDataCallback(seg: [number, string, string]) { - if (seg[0] === 0) { - initialServerDataBuffer = [] + const reactEl = fn(shouldHydrate ? markHydrateComplete : markRenderComplete) + if (process.env.__NEXT_REACT_ROOT) { + if (!reactRoot) { + // Unlike with createRoot, you don't need a separate root.render() call here + reactRoot = ReactDOM.hydrateRoot(domEl, reactEl) + // TODO: Remove shouldHydrate variable when React 18 is stable as it can depend on `reactRoot` existing + shouldHydrate = false } else { - if (!initialServerDataBuffer) - throw new Error('Unexpected server data: missing bootstrap script.') - - if (initialServerDataWriter) { - initialServerDataWriter.enqueue(encoder.encode(seg[2])) - } else { - initialServerDataBuffer.push(seg[2]) - } - } - } - - // There might be race conditions between `nextServerDataRegisterWriter` and - // `DOMContentLoaded`. The former will be called when React starts to hydrate - // the root, the latter will be called when the DOM is fully loaded. - // For streaming, the former is called first due to partial hydration. - // For non-streaming, the latter can be called first. - // Hence, we use two variables `initialServerDataLoaded` and - // `initialServerDataFlushed` to make sure the writer will be closed and - // `initialServerDataBuffer` will be cleared in the right time. - function nextServerDataRegisterWriter(ctr: ReadableStreamDefaultController) { - if (initialServerDataBuffer) { - initialServerDataBuffer.forEach((val) => { - ctr.enqueue(encoder.encode(val)) - }) - if (initialServerDataLoaded && !initialServerDataFlushed) { - ctr.close() - initialServerDataFlushed = true - initialServerDataBuffer = undefined - } - } - - initialServerDataWriter = ctr - } - - // When `DOMContentLoaded`, we can close all pending writers to finish hydration. - const DOMContentLoaded = function () { - if (initialServerDataWriter && !initialServerDataFlushed) { - initialServerDataWriter.close() - initialServerDataFlushed = true - initialServerDataBuffer = undefined - } - initialServerDataLoaded = true - } - // It's possible that the DOM is already loaded. - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', DOMContentLoaded, false) - } else { - DOMContentLoaded() - } - - const nextServerDataLoadingGlobal = ((self as any).__next_s = - (self as any).__next_s || []) - nextServerDataLoadingGlobal.forEach(nextServerDataCallback) - nextServerDataLoadingGlobal.push = nextServerDataCallback - - function createResponseCache() { - return new Map() - } - const rscCache = createResponseCache() - - function fetchFlight(href: string, props?: any) { - const url = new URL(href, location.origin) - const searchParams = url.searchParams - searchParams.append('__flight__', '1') - if (props) { - searchParams.append('__props__', JSON.stringify(props)) - } - return fetch(url.toString()) - } - - function useServerResponse(cacheKey: string, serialized?: string) { - let response = rscCache.get(cacheKey) - if (response) return response - - if (initialServerDataBuffer) { - const readable = new ReadableStream({ - start(controller) { - nextServerDataRegisterWriter(controller) - }, + const startTransition = (React as any).startTransition + startTransition(() => { + reactRoot.render(reactEl) }) - response = createFromReadableStream(readable) - } else { - if (serialized) { - const readable = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(serialized)) - controller.close() - }, - }) - response = createFromReadableStream(readable) - } else { - response = createFromFetch(fetchFlight(getCacheKey())) - } } - - rscCache.set(cacheKey, response) - return response + } else { + // The check for `.hydrate` is there to support React alternatives like preact + if (shouldHydrate) { + ReactDOM.hydrate(reactEl, domEl) + shouldHydrate = false + } else { + ReactDOM.render(reactEl, domEl) + } } +} - const ServerRoot = ({ - cacheKey, - serialized, - }: { - cacheKey: string - serialized?: string - }) => { +function Root({ + callbacks, + children, +}: React.PropsWithChildren<{ + callbacks: Array<() => void> +}>): React.ReactElement { + // We use `useLayoutEffect` to guarantee the callbacks are executed + // as soon as React flushes the update + React.useLayoutEffect( + () => callbacks.forEach((callback) => callback()), + [callbacks] + ) + // We should ask to measure the Web Vitals after rendering completes so we + // don't cause any hydration delay: + React.useEffect(() => { + measureWebVitals(onPerfEntry) + }, []) + + if (process.env.__NEXT_TEST_MODE) { + // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { - rscCache.delete(cacheKey) - }) - const response = useServerResponse(cacheKey, serialized) - return response.readRoot() - } + window.__NEXT_HYDRATED = true - RSCComponent = (props: any) => { - const cacheKey = getCacheKey() - const { __flight__ } = props - return + if (window.__NEXT_HYDRATED_CB) { + window.__NEXT_HYDRATED_CB() + } + }, []) } + + return children as React.ReactElement } function doRender(input: RenderRouteInfo): Promise { @@ -999,43 +870,178 @@ function doRender(input: RenderRouteInfo): Promise { return renderPromise } -function Root({ - callbacks, - children, -}: React.PropsWithChildren<{ - callbacks: Array<() => void> -}>): React.ReactElement { - // We use `useLayoutEffect` to guarantee the callbacks are executed - // as soon as React flushes the update - React.useLayoutEffect( - () => callbacks.forEach((callback) => callback()), - [callbacks] - ) - // We should ask to measure the Web Vitals after rendering completes so we - // don't cause any hydration delay: - React.useEffect(() => { - measureWebVitals(onPerfEntry) - }, []) +async function render(renderingProps: RenderRouteInfo): Promise { + if (renderingProps.err) { + await renderError(renderingProps) + return + } - if (process.env.__NEXT_TEST_MODE) { - // eslint-disable-next-line react-hooks/rules-of-hooks - React.useEffect(() => { - window.__NEXT_HYDRATED = true + try { + await doRender(renderingProps) + } catch (err) { + const renderErr = getProperError(err) + // bubble up cancelation errors + if ((renderErr as Error & { cancelled?: boolean }).cancelled) { + throw renderErr + } - if (window.__NEXT_HYDRATED_CB) { - window.__NEXT_HYDRATED_CB() + if (process.env.NODE_ENV === 'development') { + // Ensure this error is displayed in the overlay in development + setTimeout(() => { + throw renderErr + }) + } + await renderError({ ...renderingProps, err: renderErr }) + } +} + +export async function hydrate(opts?: { beforeRender?: () => Promise }) { + let initialErr = initialData.err + + try { + const appEntrypoint = await pageLoader.routeLoader.whenEntrypoint('/_app') + if ('error' in appEntrypoint) { + throw appEntrypoint.error + } + + const { component: app, exports: mod } = appEntrypoint + CachedApp = app as AppComponent + if (mod && mod.reportWebVitals) { + onPerfEntry = ({ + id, + name, + startTime, + value, + duration, + entryType, + entries, + }: any): void => { + // Combines timestamp with random number for unique ID + const uniqueID: string = `${Date.now()}-${ + Math.floor(Math.random() * (9e12 - 1)) + 1e12 + }` + let perfStartEntry: string | undefined + + if (entries && entries.length) { + perfStartEntry = entries[0].startTime + } + + const webVitals: NextWebVitalsMetric = { + id: id || uniqueID, + name, + startTime: startTime || perfStartEntry, + value: value == null ? duration : value, + label: + entryType === 'mark' || entryType === 'measure' + ? 'custom' + : 'web-vital', + } + mod.reportWebVitals(webVitals) } - }, []) + } + + const pageEntrypoint = + // The dev server fails to serve script assets when there's a hydration + // error, so we need to skip waiting for the entrypoint. + process.env.NODE_ENV === 'development' && initialData.err + ? { error: initialData.err } + : await pageLoader.routeLoader.whenEntrypoint(initialData.page) + if ('error' in pageEntrypoint) { + throw pageEntrypoint.error + } + CachedComponent = pageEntrypoint.component + + if (process.env.NODE_ENV !== 'production') { + const { isValidElementType } = require('next/dist/compiled/react-is') + if (!isValidElementType(CachedComponent)) { + throw new Error( + `The default export is not a React Component in page: "${initialData.page}"` + ) + } + } + } catch (error) { + // This catches errors like throwing in the top level of a module + initialErr = getProperError(error) } - return children as React.ReactElement -} + if (process.env.NODE_ENV === 'development') { + const { + getServerError, + } = require('next/dist/compiled/@next/react-dev-overlay/dist/client') + // Server-side runtime errors need to be re-thrown on the client-side so + // that the overlay is rendered. + if (initialErr) { + if (initialErr === initialData.err) { + setTimeout(() => { + let error + try { + // Generate a new error object. We `throw` it because some browsers + // will set the `stack` when thrown, and we want to ensure ours is + // not overridden when we re-throw it below. + throw new Error(initialErr!.message) + } catch (e) { + error = e as Error + } -// Dummy component that we render as a child of Root so that we can -// toggle the correct styles before the page is rendered. -function Head({ callback }: { callback: () => void }): null { - // We use `useLayoutEffect` to guarantee the callback is executed - // as soon as React flushes the update. - React.useLayoutEffect(() => callback(), [callback]) - return null + error.name = initialErr!.name + error.stack = initialErr!.stack + throw getServerError(error, initialErr!.source) + }) + } + // We replaced the server-side error with a client-side error, and should + // no longer rewrite the stack trace to a Node error. + else { + setTimeout(() => { + throw initialErr + }) + } + } + } + + if (window.__NEXT_PRELOADREADY) { + await window.__NEXT_PRELOADREADY(initialData.dynamicIds) + } + + router = createRouter(initialData.page, initialData.query, asPath, { + initialProps: initialData.props, + pageLoader, + App: CachedApp, + Component: CachedComponent, + wrapApp, + err: initialErr, + isFallback: Boolean(initialData.isFallback), + subscription: (info, App, scroll) => + render( + Object.assign< + {}, + Omit, + Pick + >({}, info, { + App, + scroll, + }) as RenderRouteInfo + ), + locale: initialData.locale, + locales: initialData.locales, + defaultLocale, + domainLocales: initialData.domainLocales, + isPreview: initialData.isPreview, + isRsc: initialData.rsc, + }) + + initialMatchesMiddleware = await router._initialMatchesMiddlewarePromise + + const renderCtx: RenderRouteInfo = { + App: CachedApp, + initial: true, + Component: CachedComponent, + props: initialData.props, + err: initialErr, + } + + if (opts?.beforeRender) { + await opts.beforeRender() + } + + render(renderCtx) } diff --git a/packages/next/client/router.ts b/packages/next/client/router.ts index db3190f72fea..f739b291209b 100644 --- a/packages/next/client/router.ts +++ b/packages/next/client/router.ts @@ -71,6 +71,16 @@ Object.defineProperty(singletonRouter, 'events', { }, }) +function getRouter(): Router { + if (!singletonRouter.router) { + const message = + 'No router instance found.\n' + + 'You should only use "next/router" on the client side of your app.\n' + throw new Error(message) + } + return singletonRouter.router +} + urlPropertyFields.forEach((field: string) => { // Here we need to use Object.defineProperty because we need to return // the property assigned to the actual router @@ -113,16 +123,6 @@ routerEvents.forEach((event) => { }) }) -function getRouter(): Router { - if (!singletonRouter.router) { - const message = - 'No router instance found.\n' + - 'You should only use "next/router" on the client side of your app.\n' - throw new Error(message) - } - return singletonRouter.router -} - // Export the singletonRouter and this is the public API. export default singletonRouter as SingletonRouter diff --git a/packages/next/client/use-intersection.tsx b/packages/next/client/use-intersection.tsx index f13dd22f0b5c..8d3ecd1bece5 100644 --- a/packages/next/client/use-intersection.tsx +++ b/packages/next/client/use-intersection.tsx @@ -25,6 +25,74 @@ type Observer = { const hasIntersectionObserver = typeof IntersectionObserver === 'function' +const observers = new Map() +const idList: Identifier[] = [] + +function createObserver(options: UseIntersectionObserverInit): Observer { + const id = { + root: options.root || null, + margin: options.rootMargin || '', + } + const existing = idList.find( + (obj) => obj.root === id.root && obj.margin === id.margin + ) + let instance: Observer | undefined + + if (existing) { + instance = observers.get(existing) + if (instance) { + return instance + } + } + + const elements = new Map() + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + const callback = elements.get(entry.target) + const isVisible = entry.isIntersecting || entry.intersectionRatio > 0 + if (callback && isVisible) { + callback(isVisible) + } + }) + }, options) + instance = { + id, + observer, + elements, + } + + idList.push(id) + observers.set(id, instance) + return instance +} + +function observe( + element: Element, + callback: ObserveCallback, + options: UseIntersectionObserverInit +): () => void { + const { id, observer, elements } = createObserver(options) + elements.set(element, callback) + + observer.observe(element) + return function unobserve(): void { + elements.delete(element) + observer.unobserve(element) + + // Destroy observer when there's nothing left to watch: + if (elements.size === 0) { + observer.disconnect() + observers.delete(id) + const index = idList.findIndex( + (obj) => obj.root === id.root && obj.margin === id.margin + ) + if (index > -1) { + idList.splice(index, 1) + } + } + } +} + export function useIntersection({ rootRef, rootMargin, @@ -71,71 +139,3 @@ export function useIntersection({ return [setElement, visible, resetVisible] } - -const observers = new Map() -const idList: Identifier[] = [] - -function observe( - element: Element, - callback: ObserveCallback, - options: UseIntersectionObserverInit -): () => void { - const { id, observer, elements } = createObserver(options) - elements.set(element, callback) - - observer.observe(element) - return function unobserve(): void { - elements.delete(element) - observer.unobserve(element) - - // Destroy observer when there's nothing left to watch: - if (elements.size === 0) { - observer.disconnect() - observers.delete(id) - const index = idList.findIndex( - (obj) => obj.root === id.root && obj.margin === id.margin - ) - if (index > -1) { - idList.splice(index, 1) - } - } - } -} - -function createObserver(options: UseIntersectionObserverInit): Observer { - const id = { - root: options.root || null, - margin: options.rootMargin || '', - } - const existing = idList.find( - (obj) => obj.root === id.root && obj.margin === id.margin - ) - let instance: Observer | undefined - - if (existing) { - instance = observers.get(existing) - if (instance) { - return instance - } - } - - const elements = new Map() - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - const callback = elements.get(entry.target) - const isVisible = entry.isIntersecting || entry.intersectionRatio > 0 - if (callback && isVisible) { - callback(isVisible) - } - }) - }, options) - instance = { - id, - observer, - elements, - } - - idList.push(id) - observers.set(id, instance) - return instance -} diff --git a/packages/next/lib/patch-incorrect-lockfile.ts b/packages/next/lib/patch-incorrect-lockfile.ts index b440dbd4c0ec..f14f067c7dc6 100644 --- a/packages/next/lib/patch-incorrect-lockfile.ts +++ b/packages/next/lib/patch-incorrect-lockfile.ts @@ -8,6 +8,42 @@ import nextPkgJson from 'next/package.json' import type { UnwrapPromise } from './coalesced-function' import { isCI } from '../telemetry/ci-info' +let registry: string | undefined + +async function fetchPkgInfo(pkg: string) { + if (!registry) { + try { + const output = execSync('npm config get registry').toString().trim() + if (output.startsWith('http')) { + registry = output + + if (!registry.endsWith('/')) { + registry += '/' + } + } + } catch (_) { + registry = `https://registry.npmjs.org/` + } + } + const res = await fetch(`${registry}${pkg}`) + + if (!res.ok) { + throw new Error( + `Failed to fetch registry info for ${pkg}, got status ${res.status}` + ) + } + const data = await res.json() + const versionData = data.versions[nextPkgJson.version] + + return { + os: versionData.os, + cpu: versionData.cpu, + engines: versionData.engines, + tarball: versionData.dist.tarball, + integrity: versionData.dist.integrity, + } +} + /** * Attempts to patch npm package-lock.json when it * fails to include optionalDependencies for other platforms @@ -151,38 +187,3 @@ export async function patchIncorrectLockfile(dir: string) { console.error(err) } } -let registry: string | undefined - -async function fetchPkgInfo(pkg: string) { - if (!registry) { - try { - const output = execSync('npm config get registry').toString().trim() - if (output.startsWith('http')) { - registry = output - - if (!registry.endsWith('/')) { - registry += '/' - } - } - } catch (_) { - registry = `https://registry.npmjs.org/` - } - } - const res = await fetch(`${registry}${pkg}`) - - if (!res.ok) { - throw new Error( - `Failed to fetch registry info for ${pkg}, got status ${res.status}` - ) - } - const data = await res.json() - const versionData = data.versions[nextPkgJson.version] - - return { - os: versionData.os, - cpu: versionData.cpu, - engines: versionData.engines, - tarball: versionData.dist.tarball, - integrity: versionData.dist.integrity, - } -} diff --git a/packages/next/lib/try-to-parse-path.ts b/packages/next/lib/try-to-parse-path.ts index fac03f16db8d..699d27c434e8 100644 --- a/packages/next/lib/try-to-parse-path.ts +++ b/packages/next/lib/try-to-parse-path.ts @@ -11,6 +11,29 @@ interface ParseResult { tokens?: Token[] } +/** + * If there is an error show our error link but still show original error or + * a formatted one if we can + */ +function reportError({ route, parsedPath }: ParseResult, err: any) { + let errMatches + if (isError(err) && (errMatches = err.message.match(/at (\d{0,})/))) { + const position = parseInt(errMatches[1], 10) + console.error( + `\nError parsing \`${route}\` ` + + `https://nextjs.org/docs/messages/invalid-route-source\n` + + `Reason: ${err.message}\n\n` + + ` ${parsedPath}\n` + + ` ${new Array(position).fill(' ').join('')}^\n` + ) + } else { + console.error( + `\nError parsing ${route} https://nextjs.org/docs/messages/invalid-route-source`, + err + ) + } +} + /** * Attempts to parse a given route with `path-to-regexp` and returns an object * with the result. Whenever an error happens on parse, it will print an error @@ -40,26 +63,3 @@ export function tryToParsePath( return result } - -/** - * If there is an error show our error link but still show original error or - * a formatted one if we can - */ -function reportError({ route, parsedPath }: ParseResult, err: any) { - let errMatches - if (isError(err) && (errMatches = err.message.match(/at (\d{0,})/))) { - const position = parseInt(errMatches[1], 10) - console.error( - `\nError parsing \`${route}\` ` + - `https://nextjs.org/docs/messages/invalid-route-source\n` + - `Reason: ${err.message}\n\n` + - ` ${parsedPath}\n` + - ` ${new Array(position).fill(' ').join('')}^\n` - ) - } else { - console.error( - `\nError parsing ${route} https://nextjs.org/docs/messages/invalid-route-source`, - err - ) - } -} diff --git a/packages/next/pages/_document.tsx b/packages/next/pages/_document.tsx index f875a34d1d0a..b0a4a65f38a7 100644 --- a/packages/next/pages/_document.tsx +++ b/packages/next/pages/_document.tsx @@ -133,6 +133,226 @@ function AmpStyles({ ) } +function getDynamicChunks( + context: HtmlProps, + props: OriginProps, + files: DocumentFiles +) { + const { + dynamicImports, + assetPrefix, + isDevelopment, + devOnlyCacheBusterQueryString, + disableOptimizedLoading, + crossOrigin, + } = context + + return dynamicImports.map((file) => { + if (!file.endsWith('.js') || files.allFiles.includes(file)) return null + + return ( +