From 47a61207384c5fb2776202b8aeb7fbbc24bc6360 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 13 Aug 2022 11:55:55 -0500 Subject: [PATCH] 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) + } }) }) })