Skip to content

Commit

Permalink
Ensure dev overlay is triggered for more _app/_document errors (#24328)
Browse files Browse the repository at this point in the history
This expands on #24070 and ensures we show the dev overlay for additional cases like where `_app` or `_document` have syntax errors causing compilation to not be able to complete. This achieves showing the dev overlay even when compilation fails from a syntax error by doing a third minimal compilation in development with the needed client-side assets to render the dev overlay. 

## Bug

- [x] Related issues linked using `fixes #number`
- [x] Integration tests added

x-ref: #24070
  • Loading branch information
ijjk committed Apr 22, 2021
1 parent c481147 commit 85d87a3
Show file tree
Hide file tree
Showing 9 changed files with 370 additions and 43 deletions.
11 changes: 9 additions & 2 deletions packages/next/build/webpack-config.ts
Expand Up @@ -189,6 +189,7 @@ export default async function getBaseWebpackConfig(
reactProductionProfiling = false,
entrypoints,
rewrites,
isDevFallback = false,
}: {
buildId: string
config: NextConfig
Expand All @@ -199,6 +200,7 @@ export default async function getBaseWebpackConfig(
reactProductionProfiling?: boolean
entrypoints: WebpackEntrypoints
rewrites: CustomRoutes['rewrites']
isDevFallback?: boolean
}
): Promise<webpack.Configuration> {
let plugins: PluginMetaData[] = []
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -1188,6 +1194,7 @@ export default async function getBaseWebpackConfig(
new BuildManifestPlugin({
buildId,
rewrites,
isDevFallback,
}),
!dev &&
!isServer &&
Expand Down
62 changes: 36 additions & 26 deletions packages/next/build/webpack/plugins/build-manifest-plugin.ts
Expand Up @@ -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: [],
Expand Down Expand Up @@ -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) {
Expand All @@ -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
})
Expand Down
21 changes: 16 additions & 5 deletions packages/next/next-server/server/load-components.ts
Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/next/next-server/server/next-server.ts
Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
49 changes: 43 additions & 6 deletions packages/next/server/hot-middleware.ts
Expand Up @@ -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)
}
Expand Down
60 changes: 57 additions & 3 deletions packages/next/server/hot-reloader.ts
Expand Up @@ -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(
Expand Down Expand Up @@ -300,6 +301,51 @@ export default class HotReloader {
])
}

public async buildFallbackError(): Promise<void> {
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<void> {
await this.clean()

Expand Down Expand Up @@ -492,7 +538,7 @@ export default class HotReloader {
)

this.webpackHotMiddleware = new WebpackHotMiddleware(
multiCompiler.compilers[0]
multiCompiler.compilers
)

let booted = false
Expand Down Expand Up @@ -534,9 +580,17 @@ export default class HotReloader {
}

public async stop(): Promise<void> {
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) {
Expand Down

0 comments on commit 85d87a3

Please sign in to comment.