mirror of
https://github.com/terribleplan/next.js.git
synced 2024-01-19 02:48:18 +00:00
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
This commit is contained in:
parent
7186fe8b7e
commit
e775721f34
|
@ -107,6 +107,18 @@ const onSocketMsg = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
reload (route) {
|
reload (route) {
|
||||||
|
if (route === '/_error') {
|
||||||
|
for (const r of Object.keys(next.router.components)) {
|
||||||
|
const { Component } = next.router.components[r]
|
||||||
|
if (Component.__route === '/_error-debug') {
|
||||||
|
// reload all '/_error-debug'
|
||||||
|
// which are expected to be errors of '/_error' routes
|
||||||
|
next.router.reload(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
next.router.reload(route)
|
next.router.reload(route)
|
||||||
},
|
},
|
||||||
close () {
|
close () {
|
||||||
|
|
|
@ -5,19 +5,34 @@ module.exports = function (content) {
|
||||||
|
|
||||||
const route = getRoute(this)
|
const route = getRoute(this)
|
||||||
|
|
||||||
return content + `
|
return `${content}
|
||||||
if (module.hot) {
|
if (module.hot) {
|
||||||
module.hot.accept()
|
module.hot.accept()
|
||||||
|
|
||||||
|
var Component = module.exports.default || module.exports
|
||||||
|
Component.__route = ${JSON.stringify(route)}
|
||||||
|
|
||||||
if (module.hot.status() !== 'idle') {
|
if (module.hot.status() !== 'idle') {
|
||||||
var Component = module.exports.default || module.exports
|
var components = next.router.components
|
||||||
next.router.update('${route}', Component)
|
for (var r in components) {
|
||||||
|
if (!components.hasOwnProperty(r)) continue
|
||||||
|
|
||||||
|
if (components[r].Component.__route === ${JSON.stringify(route)}) {
|
||||||
|
next.router.update(r, Component)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextPagesDir = resolve(__dirname, '..', '..', '..', 'pages')
|
||||||
|
|
||||||
function getRoute (loaderContext) {
|
function getRoute (loaderContext) {
|
||||||
const pagesDir = resolve(loaderContext.options.context, 'pages')
|
const pagesDir = resolve(loaderContext.options.context, 'pages')
|
||||||
const path = loaderContext.resourcePath
|
const { resourcePath } = loaderContext
|
||||||
return '/' + relative(pagesDir, path).replace(/((^|\/)index)?\.js$/, '')
|
const dir = [pagesDir, nextPagesDir]
|
||||||
|
.find((d) => resourcePath.indexOf(d) === 0)
|
||||||
|
const path = relative(dir, resourcePath)
|
||||||
|
return '/' + path.replace(/((^|\/)index)?\.js$/, '')
|
||||||
}
|
}
|
||||||
|
|
73
server/build/plugins/detach-plugin.js
Normal file
73
server/build/plugins/detach-plugin.js
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
|
||||||
|
export default class DetachPlugin {
|
||||||
|
apply (compiler) {
|
||||||
|
compiler.pluginDetachFns = new Map()
|
||||||
|
compiler.plugin = plugin(compiler.plugin)
|
||||||
|
compiler.apply = apply
|
||||||
|
compiler.detach = detach
|
||||||
|
compiler.getDetachablePlugins = getDetachablePlugins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detachable (Plugin) {
|
||||||
|
const { apply } = Plugin.prototype
|
||||||
|
|
||||||
|
Plugin.prototype.apply = function (compiler) {
|
||||||
|
const fns = []
|
||||||
|
|
||||||
|
const { plugin } = compiler
|
||||||
|
compiler.plugin = function (name, fn) {
|
||||||
|
fns.push(plugin.call(this, name, fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect the result of `plugin` call in `apply`
|
||||||
|
apply.call(this, compiler)
|
||||||
|
|
||||||
|
compiler.plugin = plugin
|
||||||
|
|
||||||
|
return fns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plugin (original) {
|
||||||
|
return function (name, fn) {
|
||||||
|
original.call(this, name, fn)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const names = Array.isArray(name) ? name : [name]
|
||||||
|
for (const n of names) {
|
||||||
|
const plugins = this._plugins[n] || []
|
||||||
|
const i = plugins.indexOf(fn)
|
||||||
|
if (i >= 0) plugins.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply (...plugins) {
|
||||||
|
for (const p of plugins) {
|
||||||
|
const fn = p.apply(this)
|
||||||
|
if (!fn) continue
|
||||||
|
|
||||||
|
const fns = this.pluginDetachFns.get(p) || new Set()
|
||||||
|
|
||||||
|
const _fns = Array.isArray(fn) ? fn : [fn]
|
||||||
|
for (const f of _fns) fns.add(f)
|
||||||
|
|
||||||
|
this.pluginDetachFns.set(p, fns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detach (...plugins) {
|
||||||
|
for (const p of plugins) {
|
||||||
|
const fns = this.pluginDetachFns.get(p) || new Set()
|
||||||
|
for (const fn of fns) {
|
||||||
|
if (typeof fn === 'function') fn()
|
||||||
|
}
|
||||||
|
this.pluginDetachFns.delete(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDetachablePlugins () {
|
||||||
|
return new Set(this.pluginDetachFns.keys())
|
||||||
|
}
|
|
@ -1,5 +1,9 @@
|
||||||
import SingleEntryPlugin from 'webpack/lib/SingleEntryPlugin'
|
import SingleEntryPlugin from 'webpack/lib/SingleEntryPlugin'
|
||||||
import MultiEntryPlugin from 'webpack/lib/MultiEntryPlugin'
|
import MultiEntryPlugin from 'webpack/lib/MultiEntryPlugin'
|
||||||
|
import { detachable } from './detach-plugin'
|
||||||
|
|
||||||
|
detachable(SingleEntryPlugin)
|
||||||
|
detachable(MultiEntryPlugin)
|
||||||
|
|
||||||
export default class DynamicEntryPlugin {
|
export default class DynamicEntryPlugin {
|
||||||
apply (compiler) {
|
apply (compiler) {
|
||||||
|
@ -8,8 +12,9 @@ export default class DynamicEntryPlugin {
|
||||||
compiler.removeEntry = removeEntry
|
compiler.removeEntry = removeEntry
|
||||||
compiler.hasEntry = hasEntry
|
compiler.hasEntry = hasEntry
|
||||||
|
|
||||||
compiler.plugin('compilation', (compilation) => {
|
compiler.plugin('emit', (compilation, callback) => {
|
||||||
compilation.addEntry = compilationAddEntry(compilation.addEntry)
|
compiler.cache = compilation.cache
|
||||||
|
callback()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,21 +42,27 @@ function addEntry (entry, name = 'main') {
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeEntry (name = 'main') {
|
function removeEntry (name = 'main') {
|
||||||
|
for (const p of this.getDetachablePlugins()) {
|
||||||
|
if (!(p instanceof SingleEntryPlugin || p instanceof MultiEntryPlugin)) continue
|
||||||
|
if (p.name !== name) continue
|
||||||
|
|
||||||
|
if (this.cache) {
|
||||||
|
for (const id of Object.keys(this.cache)) {
|
||||||
|
const m = this.cache[id]
|
||||||
|
if (m.name === name) {
|
||||||
|
// cache of `MultiModule` is based on `name`,
|
||||||
|
// so delete it here for the case
|
||||||
|
// a new entry is added with the same name later
|
||||||
|
delete this.cache[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.detach(p)
|
||||||
|
}
|
||||||
this.entryNames.delete(name)
|
this.entryNames.delete(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasEntry (name = 'main') {
|
function hasEntry (name = 'main') {
|
||||||
return this.entryNames.has(name)
|
return this.entryNames.has(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
function compilationAddEntry (original) {
|
|
||||||
return function (context, entry, name, callback) {
|
|
||||||
if (!this.compiler.entryNames.has(name)) {
|
|
||||||
// skip removed entry
|
|
||||||
callback()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return original.call(this, context, entry, name, callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { resolve, relative, join, extname } from 'path'
|
||||||
export default class WatchPagesPlugin {
|
export default class WatchPagesPlugin {
|
||||||
constructor (dir) {
|
constructor (dir) {
|
||||||
this.dir = resolve(dir, 'pages')
|
this.dir = resolve(dir, 'pages')
|
||||||
|
this.prevFileDependencies = null
|
||||||
}
|
}
|
||||||
|
|
||||||
apply (compiler) {
|
apply (compiler) {
|
||||||
|
@ -11,6 +12,8 @@ export default class WatchPagesPlugin {
|
||||||
compilation.contextDependencies =
|
compilation.contextDependencies =
|
||||||
compilation.contextDependencies.concat([this.dir])
|
compilation.contextDependencies.concat([this.dir])
|
||||||
|
|
||||||
|
this.prevFileDependencies = compilation.fileDependencies
|
||||||
|
|
||||||
callback()
|
callback()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -18,12 +21,18 @@ export default class WatchPagesPlugin {
|
||||||
const getEntryName = (f) => {
|
const getEntryName = (f) => {
|
||||||
return join('bundles', relative(compiler.options.context, f))
|
return join('bundles', relative(compiler.options.context, f))
|
||||||
}
|
}
|
||||||
|
const errorPageName = join('bundles', 'pages', '_error.js')
|
||||||
|
|
||||||
compiler.plugin('watch-run', (watching, callback) => {
|
compiler.plugin('watch-run', (watching, callback) => {
|
||||||
Object.keys(compiler.fileTimestamps)
|
Object.keys(compiler.fileTimestamps)
|
||||||
.filter(isPageFile)
|
.filter(isPageFile)
|
||||||
|
.filter((f) => this.prevFileDependencies.indexOf(f) < 0)
|
||||||
.forEach((f) => {
|
.forEach((f) => {
|
||||||
const name = getEntryName(f)
|
const name = getEntryName(f)
|
||||||
|
if (name === errorPageName) {
|
||||||
|
compiler.removeEntry(name)
|
||||||
|
}
|
||||||
|
|
||||||
if (compiler.hasEntry(name)) return
|
if (compiler.hasEntry(name)) return
|
||||||
|
|
||||||
const entries = ['webpack/hot/dev-server', f]
|
const entries = ['webpack/hot/dev-server', f]
|
||||||
|
@ -35,6 +44,13 @@ export default class WatchPagesPlugin {
|
||||||
.forEach((f) => {
|
.forEach((f) => {
|
||||||
const name = getEntryName(f)
|
const name = getEntryName(f)
|
||||||
compiler.removeEntry(name)
|
compiler.removeEntry(name)
|
||||||
|
|
||||||
|
if (name === errorPageName) {
|
||||||
|
compiler.addEntry([
|
||||||
|
'webpack/hot/dev-server',
|
||||||
|
join(__dirname, '..', '..', '..', 'pages', '_error.js')
|
||||||
|
], name)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
callback()
|
callback()
|
||||||
|
|
|
@ -6,6 +6,7 @@ import UnlinkFilePlugin from './plugins/unlink-file-plugin'
|
||||||
import WatchPagesPlugin from './plugins/watch-pages-plugin'
|
import WatchPagesPlugin from './plugins/watch-pages-plugin'
|
||||||
import WatchRemoveEventPlugin from './plugins/watch-remove-event-plugin'
|
import WatchRemoveEventPlugin from './plugins/watch-remove-event-plugin'
|
||||||
import DynamicEntryPlugin from './plugins/dynamic-entry-plugin'
|
import DynamicEntryPlugin from './plugins/dynamic-entry-plugin'
|
||||||
|
import DetachPlugin from './plugins/detach-plugin'
|
||||||
|
|
||||||
export default async function createCompiler (dir, { hotReload = false } = {}) {
|
export default async function createCompiler (dir, { hotReload = false } = {}) {
|
||||||
dir = resolve(dir)
|
dir = resolve(dir)
|
||||||
|
@ -22,7 +23,9 @@ export default async function createCompiler (dir, { hotReload = false } = {}) {
|
||||||
|
|
||||||
const errorEntry = join('bundles', 'pages', '_error.js')
|
const errorEntry = join('bundles', 'pages', '_error.js')
|
||||||
const defaultErrorPath = join(nextPagesDir, '_error.js')
|
const defaultErrorPath = join(nextPagesDir, '_error.js')
|
||||||
if (!entry[errorEntry]) entry[errorEntry] = defaultErrorPath
|
if (!entry[errorEntry]) {
|
||||||
|
entry[errorEntry] = defaultEntries.concat([defaultErrorPath])
|
||||||
|
}
|
||||||
|
|
||||||
const errorDebugEntry = join('bundles', 'pages', '_error-debug.js')
|
const errorDebugEntry = join('bundles', 'pages', '_error-debug.js')
|
||||||
const errorDebugPath = join(nextPagesDir, '_error-debug.js')
|
const errorDebugPath = join(nextPagesDir, '_error-debug.js')
|
||||||
|
@ -42,6 +45,7 @@ export default async function createCompiler (dir, { hotReload = false } = {}) {
|
||||||
})
|
})
|
||||||
].concat(hotReload ? [
|
].concat(hotReload ? [
|
||||||
new webpack.HotModuleReplacementPlugin(),
|
new webpack.HotModuleReplacementPlugin(),
|
||||||
|
new DetachPlugin(),
|
||||||
new DynamicEntryPlugin(),
|
new DynamicEntryPlugin(),
|
||||||
new UnlinkFilePlugin(),
|
new UnlinkFilePlugin(),
|
||||||
new WatchRemoveEventPlugin(),
|
new WatchRemoveEventPlugin(),
|
||||||
|
@ -70,7 +74,10 @@ export default async function createCompiler (dir, { hotReload = false } = {}) {
|
||||||
.concat(hotReload ? [{
|
.concat(hotReload ? [{
|
||||||
test: /\.js$/,
|
test: /\.js$/,
|
||||||
loader: 'hot-self-accept-loader',
|
loader: 'hot-self-accept-loader',
|
||||||
include: join(dir, 'pages')
|
include: [
|
||||||
|
join(dir, 'pages'),
|
||||||
|
nextPagesDir
|
||||||
|
]
|
||||||
}] : [])
|
}] : [])
|
||||||
.concat([{
|
.concat([{
|
||||||
test: /\.js$/,
|
test: /\.js$/,
|
||||||
|
|
|
@ -83,13 +83,19 @@ export default class Server {
|
||||||
try {
|
try {
|
||||||
html = await render(req.url, ctx, opts)
|
html = await render(req.url, ctx, opts)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 'ENOENT') {
|
const _err = this.getCompilationError('/_error')
|
||||||
res.statusCode = 404
|
if (_err) {
|
||||||
} else {
|
|
||||||
console.error(err)
|
|
||||||
res.statusCode = 500
|
res.statusCode = 500
|
||||||
|
html = await render('/_error-debug', { ...ctx, err: _err }, opts)
|
||||||
|
} else {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
res.statusCode = 404
|
||||||
|
} else {
|
||||||
|
console.error(err)
|
||||||
|
res.statusCode = 500
|
||||||
|
}
|
||||||
|
html = await render('/_error', { ...ctx, err }, opts)
|
||||||
}
|
}
|
||||||
html = await render('/_error', { ...ctx, err }, opts)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,13 +117,20 @@ export default class Server {
|
||||||
try {
|
try {
|
||||||
json = await renderJSON(req.url, opts)
|
json = await renderJSON(req.url, opts)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 'ENOENT') {
|
const _err = this.getCompilationError('/_error.json')
|
||||||
res.statusCode = 404
|
if (_err) {
|
||||||
} else {
|
|
||||||
console.error(err)
|
|
||||||
res.statusCode = 500
|
res.statusCode = 500
|
||||||
|
json = await renderJSON('/_error-debug.json', opts)
|
||||||
|
json = { ...json, err: errorToJSON(_err) }
|
||||||
|
} else {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
res.statusCode = 404
|
||||||
|
} else {
|
||||||
|
console.error(err)
|
||||||
|
res.statusCode = 500
|
||||||
|
}
|
||||||
|
json = await renderJSON('/_error.json', opts)
|
||||||
}
|
}
|
||||||
json = await renderJSON('/_error.json', opts)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,9 +142,19 @@ export default class Server {
|
||||||
|
|
||||||
async render404 (req, res) {
|
async render404 (req, res) {
|
||||||
const { dir, dev } = this
|
const { dir, dev } = this
|
||||||
|
const opts = { dir, dev }
|
||||||
|
|
||||||
|
let html
|
||||||
|
|
||||||
|
const err = this.getCompilationError('/_error')
|
||||||
|
if (err) {
|
||||||
|
res.statusCode = 500
|
||||||
|
html = await render('/_error-debug', { req, res, err }, opts)
|
||||||
|
} else {
|
||||||
|
res.statusCode = 404
|
||||||
|
html = await render('/_error', { req, res }, opts)
|
||||||
|
}
|
||||||
|
|
||||||
res.statusCode = 404
|
|
||||||
const html = await render('/_error', { req, res }, { dir, dev })
|
|
||||||
sendHTML(res, html)
|
sendHTML(res, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -44,7 +44,7 @@ export async function render (url, ctx = {}, {
|
||||||
component,
|
component,
|
||||||
props,
|
props,
|
||||||
ids: ids,
|
ids: ids,
|
||||||
err: ctx.err ? errorToJSON(ctx.err) : null
|
err: (ctx.err && dev) ? errorToJSON(ctx.err) : null
|
||||||
},
|
},
|
||||||
dev,
|
dev,
|
||||||
staticMarkup,
|
staticMarkup,
|
||||||
|
|
Loading…
Reference in a new issue