Skip to content

Commit e775721

Browse files
authored
Hot reload error page (#190)
* add detach-plugin * detach-plugin: remove unused property * watch-pages-plugin: replace _error.js when user defined one was added/removed * dynamic-entry-plugin: delete cache * fix HMR settings for _error.js * render: pass error only on dev * hot-reload: enable to hot-reload error page * server: check if /_error has compilation errors * webapck-dev-client: fix reloading /_error
1 parent 7186fe8 commit e775721

File tree

8 files changed

+191
-34
lines changed

8 files changed

+191
-34
lines changed

client/webpack-dev-client/index.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,18 @@ const onSocketMsg = {
107107
}
108108
},
109109
reload (route) {
110+
if (route === '/_error') {
111+
for (const r of Object.keys(next.router.components)) {
112+
const { Component } = next.router.components[r]
113+
if (Component.__route === '/_error-debug') {
114+
// reload all '/_error-debug'
115+
// which are expected to be errors of '/_error' routes
116+
next.router.reload(r)
117+
}
118+
}
119+
return
120+
}
121+
110122
next.router.reload(route)
111123
},
112124
close () {

server/build/loaders/hot-self-accept-loader.js

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,34 @@ module.exports = function (content) {
55

66
const route = getRoute(this)
77

8-
return content + `
8+
return `${content}
99
if (module.hot) {
1010
module.hot.accept()
11+
12+
var Component = module.exports.default || module.exports
13+
Component.__route = ${JSON.stringify(route)}
14+
1115
if (module.hot.status() !== 'idle') {
12-
var Component = module.exports.default || module.exports
13-
next.router.update('${route}', Component)
16+
var components = next.router.components
17+
for (var r in components) {
18+
if (!components.hasOwnProperty(r)) continue
19+
20+
if (components[r].Component.__route === ${JSON.stringify(route)}) {
21+
next.router.update(r, Component)
22+
}
23+
}
1424
}
1525
}
1626
`
1727
}
1828

29+
const nextPagesDir = resolve(__dirname, '..', '..', '..', 'pages')
30+
1931
function getRoute (loaderContext) {
2032
const pagesDir = resolve(loaderContext.options.context, 'pages')
21-
const path = loaderContext.resourcePath
22-
return '/' + relative(pagesDir, path).replace(/((^|\/)index)?\.js$/, '')
33+
const { resourcePath } = loaderContext
34+
const dir = [pagesDir, nextPagesDir]
35+
.find((d) => resourcePath.indexOf(d) === 0)
36+
const path = relative(dir, resourcePath)
37+
return '/' + path.replace(/((^|\/)index)?\.js$/, '')
2338
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
2+
export default class DetachPlugin {
3+
apply (compiler) {
4+
compiler.pluginDetachFns = new Map()
5+
compiler.plugin = plugin(compiler.plugin)
6+
compiler.apply = apply
7+
compiler.detach = detach
8+
compiler.getDetachablePlugins = getDetachablePlugins
9+
}
10+
}
11+
12+
export function detachable (Plugin) {
13+
const { apply } = Plugin.prototype
14+
15+
Plugin.prototype.apply = function (compiler) {
16+
const fns = []
17+
18+
const { plugin } = compiler
19+
compiler.plugin = function (name, fn) {
20+
fns.push(plugin.call(this, name, fn))
21+
}
22+
23+
// collect the result of `plugin` call in `apply`
24+
apply.call(this, compiler)
25+
26+
compiler.plugin = plugin
27+
28+
return fns
29+
}
30+
}
31+
32+
function plugin (original) {
33+
return function (name, fn) {
34+
original.call(this, name, fn)
35+
36+
return () => {
37+
const names = Array.isArray(name) ? name : [name]
38+
for (const n of names) {
39+
const plugins = this._plugins[n] || []
40+
const i = plugins.indexOf(fn)
41+
if (i >= 0) plugins.splice(i, 1)
42+
}
43+
}
44+
}
45+
}
46+
47+
function apply (...plugins) {
48+
for (const p of plugins) {
49+
const fn = p.apply(this)
50+
if (!fn) continue
51+
52+
const fns = this.pluginDetachFns.get(p) || new Set()
53+
54+
const _fns = Array.isArray(fn) ? fn : [fn]
55+
for (const f of _fns) fns.add(f)
56+
57+
this.pluginDetachFns.set(p, fns)
58+
}
59+
}
60+
61+
function detach (...plugins) {
62+
for (const p of plugins) {
63+
const fns = this.pluginDetachFns.get(p) || new Set()
64+
for (const fn of fns) {
65+
if (typeof fn === 'function') fn()
66+
}
67+
this.pluginDetachFns.delete(p)
68+
}
69+
}
70+
71+
function getDetachablePlugins () {
72+
return new Set(this.pluginDetachFns.keys())
73+
}

server/build/plugins/dynamic-entry-plugin.js

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import SingleEntryPlugin from 'webpack/lib/SingleEntryPlugin'
22
import MultiEntryPlugin from 'webpack/lib/MultiEntryPlugin'
3+
import { detachable } from './detach-plugin'
4+
5+
detachable(SingleEntryPlugin)
6+
detachable(MultiEntryPlugin)
37

48
export default class DynamicEntryPlugin {
59
apply (compiler) {
@@ -8,8 +12,9 @@ export default class DynamicEntryPlugin {
812
compiler.removeEntry = removeEntry
913
compiler.hasEntry = hasEntry
1014

11-
compiler.plugin('compilation', (compilation) => {
12-
compilation.addEntry = compilationAddEntry(compilation.addEntry)
15+
compiler.plugin('emit', (compilation, callback) => {
16+
compiler.cache = compilation.cache
17+
callback()
1318
})
1419
}
1520
}
@@ -37,21 +42,27 @@ function addEntry (entry, name = 'main') {
3742
}
3843

3944
function removeEntry (name = 'main') {
45+
for (const p of this.getDetachablePlugins()) {
46+
if (!(p instanceof SingleEntryPlugin || p instanceof MultiEntryPlugin)) continue
47+
if (p.name !== name) continue
48+
49+
if (this.cache) {
50+
for (const id of Object.keys(this.cache)) {
51+
const m = this.cache[id]
52+
if (m.name === name) {
53+
// cache of `MultiModule` is based on `name`,
54+
// so delete it here for the case
55+
// a new entry is added with the same name later
56+
delete this.cache[id]
57+
}
58+
}
59+
}
60+
61+
this.detach(p)
62+
}
4063
this.entryNames.delete(name)
4164
}
4265

4366
function hasEntry (name = 'main') {
4467
return this.entryNames.has(name)
4568
}
46-
47-
function compilationAddEntry (original) {
48-
return function (context, entry, name, callback) {
49-
if (!this.compiler.entryNames.has(name)) {
50-
// skip removed entry
51-
callback()
52-
return
53-
}
54-
55-
return original.call(this, context, entry, name, callback)
56-
}
57-
}

server/build/plugins/watch-pages-plugin.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { resolve, relative, join, extname } from 'path'
33
export default class WatchPagesPlugin {
44
constructor (dir) {
55
this.dir = resolve(dir, 'pages')
6+
this.prevFileDependencies = null
67
}
78

89
apply (compiler) {
@@ -11,19 +12,27 @@ export default class WatchPagesPlugin {
1112
compilation.contextDependencies =
1213
compilation.contextDependencies.concat([this.dir])
1314

15+
this.prevFileDependencies = compilation.fileDependencies
16+
1417
callback()
1518
})
1619

1720
const isPageFile = this.isPageFile.bind(this)
1821
const getEntryName = (f) => {
1922
return join('bundles', relative(compiler.options.context, f))
2023
}
24+
const errorPageName = join('bundles', 'pages', '_error.js')
2125

2226
compiler.plugin('watch-run', (watching, callback) => {
2327
Object.keys(compiler.fileTimestamps)
2428
.filter(isPageFile)
29+
.filter((f) => this.prevFileDependencies.indexOf(f) < 0)
2530
.forEach((f) => {
2631
const name = getEntryName(f)
32+
if (name === errorPageName) {
33+
compiler.removeEntry(name)
34+
}
35+
2736
if (compiler.hasEntry(name)) return
2837

2938
const entries = ['webpack/hot/dev-server', f]
@@ -35,6 +44,13 @@ export default class WatchPagesPlugin {
3544
.forEach((f) => {
3645
const name = getEntryName(f)
3746
compiler.removeEntry(name)
47+
48+
if (name === errorPageName) {
49+
compiler.addEntry([
50+
'webpack/hot/dev-server',
51+
join(__dirname, '..', '..', '..', 'pages', '_error.js')
52+
], name)
53+
}
3854
})
3955

4056
callback()

server/build/webpack.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import UnlinkFilePlugin from './plugins/unlink-file-plugin'
66
import WatchPagesPlugin from './plugins/watch-pages-plugin'
77
import WatchRemoveEventPlugin from './plugins/watch-remove-event-plugin'
88
import DynamicEntryPlugin from './plugins/dynamic-entry-plugin'
9+
import DetachPlugin from './plugins/detach-plugin'
910

1011
export default async function createCompiler (dir, { hotReload = false } = {}) {
1112
dir = resolve(dir)
@@ -22,7 +23,9 @@ export default async function createCompiler (dir, { hotReload = false } = {}) {
2223

2324
const errorEntry = join('bundles', 'pages', '_error.js')
2425
const defaultErrorPath = join(nextPagesDir, '_error.js')
25-
if (!entry[errorEntry]) entry[errorEntry] = defaultErrorPath
26+
if (!entry[errorEntry]) {
27+
entry[errorEntry] = defaultEntries.concat([defaultErrorPath])
28+
}
2629

2730
const errorDebugEntry = join('bundles', 'pages', '_error-debug.js')
2831
const errorDebugPath = join(nextPagesDir, '_error-debug.js')
@@ -42,6 +45,7 @@ export default async function createCompiler (dir, { hotReload = false } = {}) {
4245
})
4346
].concat(hotReload ? [
4447
new webpack.HotModuleReplacementPlugin(),
48+
new DetachPlugin(),
4549
new DynamicEntryPlugin(),
4650
new UnlinkFilePlugin(),
4751
new WatchRemoveEventPlugin(),
@@ -70,7 +74,10 @@ export default async function createCompiler (dir, { hotReload = false } = {}) {
7074
.concat(hotReload ? [{
7175
test: /\.js$/,
7276
loader: 'hot-self-accept-loader',
73-
include: join(dir, 'pages')
77+
include: [
78+
join(dir, 'pages'),
79+
nextPagesDir
80+
]
7481
}] : [])
7582
.concat([{
7683
test: /\.js$/,

server/index.js

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,19 @@ export default class Server {
8383
try {
8484
html = await render(req.url, ctx, opts)
8585
} catch (err) {
86-
if (err.code === 'ENOENT') {
87-
res.statusCode = 404
88-
} else {
89-
console.error(err)
86+
const _err = this.getCompilationError('/_error')
87+
if (_err) {
9088
res.statusCode = 500
89+
html = await render('/_error-debug', { ...ctx, err: _err }, opts)
90+
} else {
91+
if (err.code === 'ENOENT') {
92+
res.statusCode = 404
93+
} else {
94+
console.error(err)
95+
res.statusCode = 500
96+
}
97+
html = await render('/_error', { ...ctx, err }, opts)
9198
}
92-
html = await render('/_error', { ...ctx, err }, opts)
9399
}
94100
}
95101

@@ -111,13 +117,20 @@ export default class Server {
111117
try {
112118
json = await renderJSON(req.url, opts)
113119
} catch (err) {
114-
if (err.code === 'ENOENT') {
115-
res.statusCode = 404
116-
} else {
117-
console.error(err)
120+
const _err = this.getCompilationError('/_error.json')
121+
if (_err) {
118122
res.statusCode = 500
123+
json = await renderJSON('/_error-debug.json', opts)
124+
json = { ...json, err: errorToJSON(_err) }
125+
} else {
126+
if (err.code === 'ENOENT') {
127+
res.statusCode = 404
128+
} else {
129+
console.error(err)
130+
res.statusCode = 500
131+
}
132+
json = await renderJSON('/_error.json', opts)
119133
}
120-
json = await renderJSON('/_error.json', opts)
121134
}
122135
}
123136

@@ -129,9 +142,19 @@ export default class Server {
129142

130143
async render404 (req, res) {
131144
const { dir, dev } = this
145+
const opts = { dir, dev }
146+
147+
let html
148+
149+
const err = this.getCompilationError('/_error')
150+
if (err) {
151+
res.statusCode = 500
152+
html = await render('/_error-debug', { req, res, err }, opts)
153+
} else {
154+
res.statusCode = 404
155+
html = await render('/_error', { req, res }, opts)
156+
}
132157

133-
res.statusCode = 404
134-
const html = await render('/_error', { req, res }, { dir, dev })
135158
sendHTML(res, html)
136159
}
137160

server/render.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export async function render (url, ctx = {}, {
4444
component,
4545
props,
4646
ids: ids,
47-
err: ctx.err ? errorToJSON(ctx.err) : null
47+
err: (ctx.err && dev) ? errorToJSON(ctx.err) : null
4848
},
4949
dev,
5050
staticMarkup,

0 commit comments

Comments
 (0)