diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 92c992b50b5e1..487f02b622508 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -189,6 +189,7 @@ export default async function getBaseWebpackConfig( reactProductionProfiling = false, entrypoints, rewrites, + isDevFallback = false, }: { buildId: string config: NextConfig @@ -199,6 +200,7 @@ export default async function getBaseWebpackConfig( reactProductionProfiling?: boolean entrypoints: WebpackEntrypoints rewrites: CustomRoutes['rewrites'] + isDevFallback?: boolean } ): Promise { let plugins: PluginMetaData[] = [] @@ -916,7 +918,9 @@ export default async function getBaseWebpackConfig( ? isWebpack5 && !dev ? '../[name].js' : '[name].js' - : `static/chunks/[name]${dev ? '' : '-[chunkhash]'}.js`, + : `static/chunks/${isDevFallback ? 'fallback/' : ''}[name]${ + dev ? '' : '-[chunkhash]' + }.js`, library: isServer ? undefined : '_N_E', libraryTarget: isServer ? 'commonjs2' : 'assign', hotUpdateChunkFilename: isWebpack5 @@ -928,7 +932,9 @@ export default async function getBaseWebpackConfig( // This saves chunks with the name given via `import()` chunkFilename: isServer ? `${dev ? '[name]' : '[name].[contenthash]'}.js` - : `static/chunks/${dev ? '[name]' : '[name].[contenthash]'}.js`, + : `static/chunks/${isDevFallback ? 'fallback/' : ''}${ + dev ? '[name]' : '[name].[contenthash]' + }.js`, strictModuleExceptionHandling: true, crossOriginLoading: crossOrigin, futureEmitAssets: !dev, @@ -1188,6 +1194,7 @@ export default async function getBaseWebpackConfig( new BuildManifestPlugin({ buildId, rewrites, + isDevFallback, }), !dev && !isServer && diff --git a/packages/next/build/webpack/plugins/build-manifest-plugin.ts b/packages/next/build/webpack/plugins/build-manifest-plugin.ts index ad0a2fe94390a..fa7d7b2fbc0b0 100644 --- a/packages/next/build/webpack/plugins/build-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/build-manifest-plugin.ts @@ -95,13 +95,15 @@ const processRoute = (r: Rewrite) => { export default class BuildManifestPlugin { private buildId: string private rewrites: CustomRoutes['rewrites'] + private isDevFallback: boolean constructor(options: { buildId: string rewrites: CustomRoutes['rewrites'] + isDevFallback?: boolean }) { this.buildId = options.buildId - + this.isDevFallback = !!options.isDevFallback this.rewrites = { beforeFiles: [], afterFiles: [], @@ -165,7 +167,6 @@ export default class BuildManifestPlugin { for (const entrypoint of compilation.entrypoints.values()) { if (systemEntrypoints.has(entrypoint.name)) continue - const pagePath = getRouteFromEntrypoint(entrypoint.name) if (!pagePath) { @@ -177,40 +178,49 @@ export default class BuildManifestPlugin { assetMap.pages[pagePath] = [...new Set([...mainFiles, ...filesForPage])] } - // Add the runtime build manifest file (generated later in this file) - // as a dependency for the app. If the flag is false, the file won't be - // downloaded by the client. - assetMap.lowPriorityFiles.push( - `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_buildManifest.js` - ) - - // Add the runtime ssg manifest file as a lazy-loaded file dependency. - // We also stub this file out for development mode (when it is not - // generated). - const srcEmptySsgManifest = `self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()` - - const ssgManifestPath = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_ssgManifest.js` - assetMap.lowPriorityFiles.push(ssgManifestPath) - assets[ssgManifestPath] = new sources.RawSource(srcEmptySsgManifest) + if (!this.isDevFallback) { + // Add the runtime build manifest file (generated later in this file) + // as a dependency for the app. If the flag is false, the file won't be + // downloaded by the client. + assetMap.lowPriorityFiles.push( + `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_buildManifest.js` + ) + // Add the runtime ssg manifest file as a lazy-loaded file dependency. + // We also stub this file out for development mode (when it is not + // generated). + const srcEmptySsgManifest = `self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()` + + const ssgManifestPath = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_ssgManifest.js` + assetMap.lowPriorityFiles.push(ssgManifestPath) + assets[ssgManifestPath] = new sources.RawSource(srcEmptySsgManifest) + } assetMap.pages = Object.keys(assetMap.pages) .sort() // eslint-disable-next-line .reduce((a, c) => ((a[c] = assetMap.pages[c]), a), {} as any) - assets[BUILD_MANIFEST] = new sources.RawSource( + let buildManifestName = BUILD_MANIFEST + + if (this.isDevFallback) { + buildManifestName = `fallback-${BUILD_MANIFEST}` + } + + assets[buildManifestName] = new sources.RawSource( JSON.stringify(assetMap, null, 2) ) - const clientManifestPath = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_buildManifest.js` + if (!this.isDevFallback) { + const clientManifestPath = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_buildManifest.js` - assets[clientManifestPath] = new sources.RawSource( - `self.__BUILD_MANIFEST = ${generateClientManifest( - compiler, - assetMap, - this.rewrites - )};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()` - ) + assets[clientManifestPath] = new sources.RawSource( + `self.__BUILD_MANIFEST = ${generateClientManifest( + compiler, + assetMap, + this.rewrites + )};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()` + ) + } return assets }) diff --git a/packages/next/next-server/server/load-components.ts b/packages/next/next-server/server/load-components.ts index 8422b75ceb475..f7d361ef9c608 100644 --- a/packages/next/next-server/server/load-components.ts +++ b/packages/next/next-server/server/load-components.ts @@ -44,8 +44,8 @@ export async function loadDefaultErrorComponents(distDir: string) { App, Document, Component, - buildManifest: require(join(distDir, BUILD_MANIFEST)), - reactLoadableManifest: require(join(distDir, REACT_LOADABLE_MANIFEST)), + buildManifest: require(join(distDir, `fallback-${BUILD_MANIFEST}`)), + reactLoadableManifest: {}, ComponentMod, } } @@ -74,9 +74,20 @@ export async function loadComponents( } as LoadComponentsReturnType } - const DocumentMod = await requirePage('/_document', distDir, serverless) - const AppMod = await requirePage('/_app', distDir, serverless) - const ComponentMod = await requirePage(pathname, distDir, serverless) + let DocumentMod + let AppMod + let ComponentMod + + try { + DocumentMod = await requirePage('/_document', distDir, serverless) + AppMod = await requirePage('/_app', distDir, serverless) + ComponentMod = await requirePage(pathname, distDir, serverless) + } catch (err) { + if (err.code === 'MODULE_NOT_FOUND') { + throw new Error(`Failed to load ${pathname}`) + } + throw err + } const [ buildManifest, diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 1d627f17a6f72..2f3e50ab5500e 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -92,6 +92,7 @@ import cookie from 'next/dist/compiled/cookie' import escapePathDelimiters from '../lib/router/utils/escape-path-delimiters' import { getUtils } from '../../build/webpack/loaders/next-serverless-loader/utils' import { PreviewData } from 'next/types' +import HotReloader from '../../server/hot-reloader' const getCustomRouteMatcher = pathMatch(true) @@ -2051,6 +2052,8 @@ export default class Server { res.statusCode = 500 if (this.renderOpts.dev) { + await ((this as any).hotReloader as HotReloader).buildFallbackError() + const fallbackResult = await loadDefaultErrorComponents(this.distDir) return this.renderToHTMLWithComponents( req, diff --git a/packages/next/server/hot-middleware.ts b/packages/next/server/hot-middleware.ts index c89cada7f4133..622f9e3517f34 100644 --- a/packages/next/server/hot-middleware.ts +++ b/packages/next/server/hot-middleware.ts @@ -27,25 +27,62 @@ import http from 'http' export class WebpackHotMiddleware { eventStream: EventStream latestStats: webpack.Stats | null + clientLatestStats: webpack.Stats | null closed: boolean + serverError: boolean - constructor(compiler: webpack.Compiler) { + constructor(compilers: webpack.Compiler[]) { this.eventStream = new EventStream() this.latestStats = null + this.clientLatestStats = null + this.serverError = false this.closed = false - compiler.hooks.invalid.tap('webpack-hot-middleware', this.onInvalid) - compiler.hooks.done.tap('webpack-hot-middleware', this.onDone) + compilers[0].hooks.invalid.tap( + 'webpack-hot-middleware', + this.onClientInvalid + ) + compilers[0].hooks.done.tap('webpack-hot-middleware', this.onClientDone) + + compilers[1].hooks.invalid.tap( + 'webpack-hot-middleware', + this.onServerInvalid + ) + compilers[1].hooks.done.tap('webpack-hot-middleware', this.onServerDone) } - onInvalid = () => { - if (this.closed) return + onServerInvalid = () => { + if (!this.serverError) return + + this.serverError = false + + if (this.clientLatestStats) { + this.latestStats = this.clientLatestStats + this.publishStats('built', this.latestStats) + } + } + onClientInvalid = () => { + if (this.closed || this.serverError) return this.latestStats = null this.eventStream.publish({ action: 'building' }) } - onDone = (statsResult: webpack.Stats) => { + onServerDone = (statsResult: webpack.Stats) => { if (this.closed) return // Keep hold of latest stats so they can be propagated to new clients + // this.latestStats = statsResult + // this.publishStats('built', this.latestStats) + this.serverError = statsResult.hasErrors() + + if (this.serverError) { + this.latestStats = statsResult + this.publishStats('built', this.latestStats) + } + } + onClientDone = (statsResult: webpack.Stats) => { + this.clientLatestStats = statsResult + + if (this.closed || this.serverError) return + // Keep hold of latest stats so they can be propagated to new clients this.latestStats = statsResult this.publishStats('built', this.latestStats) } diff --git a/packages/next/server/hot-reloader.ts b/packages/next/server/hot-reloader.ts index 44d1f3d9cdbea..82114149b3bbf 100644 --- a/packages/next/server/hot-reloader.ts +++ b/packages/next/server/hot-reloader.ts @@ -144,6 +144,7 @@ export default class HotReloader { private previewProps: __ApiPreviewProps private watcher: any private rewrites: CustomRoutes['rewrites'] + private fallbackWatcher: any public isWebpack5: any constructor( @@ -300,6 +301,51 @@ export default class HotReloader { ]) } + public async buildFallbackError(): Promise { + if (this.fallbackWatcher) return + + const fallbackConfig = await getBaseWebpackConfig(this.dir, { + dev: true, + isServer: false, + config: this.config, + buildId: this.buildId, + pagesDir: this.pagesDir, + rewrites: { + beforeFiles: [], + afterFiles: [], + fallback: [], + }, + isDevFallback: true, + entrypoints: createEntrypoints( + { + '/_app': 'next/dist/pages/_app', + '/_error': 'next/dist/pages/_error', + }, + 'server', + this.buildId, + this.previewProps, + this.config, + [] + ).client, + }) + const fallbackCompiler = webpack(fallbackConfig) + + this.fallbackWatcher = await new Promise((resolve) => { + let bootedFallbackCompiler = false + fallbackCompiler.watch( + // @ts-ignore webpack supports an array of watchOptions when using a multiCompiler + fallbackConfig.watchOptions, + // Errors are handled separately + (_err: any) => { + if (!bootedFallbackCompiler) { + bootedFallbackCompiler = true + resolve(true) + } + } + ) + }) + } + public async start(): Promise { await this.clean() @@ -492,7 +538,7 @@ export default class HotReloader { ) this.webpackHotMiddleware = new WebpackHotMiddleware( - multiCompiler.compilers[0] + multiCompiler.compilers ) let booted = false @@ -534,9 +580,17 @@ export default class HotReloader { } public async stop(): Promise { - return new Promise((resolve, reject) => { - this.watcher.close((err: any) => (err ? reject(err) : resolve())) + await new Promise((resolve, reject) => { + this.watcher.close((err: any) => (err ? reject(err) : resolve(true))) }) + + if (this.fallbackWatcher) { + await new Promise((resolve, reject) => { + this.fallbackWatcher.close((err: any) => + err ? reject(err) : resolve(true) + ) + }) + } } public async getCompilationErrors(page: string) { diff --git a/test/acceptance/ReactRefreshLogBox.dev.test.js b/test/acceptance/ReactRefreshLogBox.dev.test.js index cad76e203563f..575e91a6b346b 100644 --- a/test/acceptance/ReactRefreshLogBox.dev.test.js +++ b/test/acceptance/ReactRefreshLogBox.dev.test.js @@ -1380,3 +1380,205 @@ test('_document top level error shows logbox', async () => { expect(await session.hasRedbox()).toBe(false) await cleanup() }) + +test('empty _app shows logbox', async () => { + const [session, cleanup] = await sandbox( + undefined, + new Map([ + [ + 'pages/_app.js', + ` + + `, + ], + ]) + ) + expect(await session.hasRedbox(true)).toBe(true) + expect(await session.getRedboxDescription()).toMatchInlineSnapshot( + `"Error: The default export is not a React Component in page: \\"/_app\\""` + ) + + await session.patch( + 'pages/_app.js', + ` + function MyApp({ Component, pageProps }) { + return ; + } + export default MyApp + ` + ) + expect(await session.hasRedbox()).toBe(false) + await cleanup() +}) + +test('empty _document shows logbox', async () => { + const [session, cleanup] = await sandbox( + undefined, + new Map([ + [ + 'pages/_document.js', + ` + + `, + ], + ]) + ) + expect(await session.hasRedbox(true)).toBe(true) + expect(await session.getRedboxDescription()).toMatchInlineSnapshot( + `"Error: The default export is not a React Component in page: \\"/_document\\""` + ) + + await session.patch( + 'pages/_document.js', + ` + import Document, { Html, Head, Main, NextScript } from 'next/document' + + class MyDocument extends Document { + static async getInitialProps(ctx) { + const initialProps = await Document.getInitialProps(ctx) + return { ...initialProps } + } + + render() { + return ( + + + +
+ + + + ) + } + } + + export default MyDocument + ` + ) + expect(await session.hasRedbox()).toBe(false) + await cleanup() +}) + +test('_app syntax error shows logbox', async () => { + const [session, cleanup] = await sandbox( + undefined, + new Map([ + [ + 'pages/_app.js', + ` + function MyApp({ Component, pageProps }) { + return <; + } + export default MyApp + `, + ], + ]), + undefined, + /ready - started server on/i + ) + expect(await session.hasRedbox(true)).toBe(true) + expect(await session.getRedboxSource()).toMatchInlineSnapshot(` + "./pages/_app.js:3:20 + Syntax error: Unexpected token + + 1 | + 2 | function MyApp({ Component, pageProps }) { + > 3 | return <; + | ^ + 4 | } + 5 | export default MyApp + 6 |" +`) + + await session.patch( + 'pages/_app.js', + ` + function MyApp({ Component, pageProps }) { + return ; + } + export default MyApp + ` + ) + expect(await session.hasRedbox()).toBe(false) + await cleanup() +}) + +test('_document syntax error shows logbox', async () => { + const [session, cleanup] = await sandbox( + undefined, + new Map([ + [ + 'pages/_document.js', + ` + import Document, { Html, Head, Main, NextScript } from 'next/document' + + class MyDocument extends Document {{ + static async getInitialProps(ctx) { + const initialProps = await Document.getInitialProps(ctx) + return { ...initialProps } + } + + render() { + return ( + + + +
+ + + + ) + } + } + + export default MyDocument + `, + ], + ]), + undefined, + /ready - started server on/i + ) + expect(await session.hasRedbox(true)).toBe(true) + expect(await session.getRedboxSource()).toMatchInlineSnapshot(` + "./pages/_document.js:4:45 + Syntax error: Unexpected token + + 2 | import Document, { Html, Head, Main, NextScript } from 'next/document' + 3 | + > 4 | class MyDocument extends Document {{ + | ^ + 5 | static async getInitialProps(ctx) { + 6 | const initialProps = await Document.getInitialProps(ctx) + 7 | return { ...initialProps }" +`) + + await session.patch( + 'pages/_document.js', + ` + import Document, { Html, Head, Main, NextScript } from 'next/document' + + class MyDocument extends Document { + static async getInitialProps(ctx) { + const initialProps = await Document.getInitialProps(ctx) + return { ...initialProps } + } + + render() { + return ( + + + +
+ + + + ) + } + } + + export default MyDocument + ` + ) + expect(await session.hasRedbox()).toBe(false) + await cleanup() +}) diff --git a/test/acceptance/helpers.js b/test/acceptance/helpers.js index 45bb367cfe995..5a92e5479dd36 100644 --- a/test/acceptance/helpers.js +++ b/test/acceptance/helpers.js @@ -9,7 +9,8 @@ const rootSandboxDirectory = path.join(__dirname, '__tmp__') export async function sandbox( id = nanoid(), initialFiles = new Map(), - defaultFiles = true + defaultFiles = true, + readyLog ) { const sandboxDirectory = path.join(rootSandboxDirectory, id) @@ -35,6 +36,7 @@ export async function sandbox( const appPort = await findPort() const app = await launchApp(sandboxDirectory, appPort, { env: { __NEXT_TEST_WITH_DEVTOOL: 1 }, + bootupMarker: readyLog, }) const browser = await webdriver(appPort, '/') return [ diff --git a/test/lib/next-test-utils.js b/test/lib/next-test-utils.js index 14da3a40744c5..a64ea6581317d 100644 --- a/test/lib/next-test-utils.js +++ b/test/lib/next-test-utils.js @@ -189,6 +189,7 @@ export function runNextCommandDev(argv, stdOut, opts = {}) { start: /started server/i, } if ( + (opts.bootupMarker && opts.bootupMarker.test(message)) || bootupMarkers[opts.nextStart || stdOut ? 'start' : 'dev'].test(message) ) { if (!didResolve) {