Skip to content

Commit 0ef28ab

Browse files
nkzawarauchg
authored andcommitted
Don't discard component state on error (#741)
* render debug page as overlay * handle errors occurrred on rendering cycle for HMR * retrieve props if required on HMR
1 parent 8811a33 commit 0ef28ab

File tree

11 files changed

+205
-220
lines changed

11 files changed

+205
-220
lines changed

client/index.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { createElement } from 'react'
2+
import ReactDOM from 'react-dom'
3+
import HeadManager from './head-manager'
4+
import { rehydrate } from '../lib/css'
5+
import { createRouter } from '../lib/router'
6+
import App from '../lib/app'
7+
import evalScript from '../lib/eval-script'
8+
9+
const {
10+
__NEXT_DATA__: {
11+
component,
12+
errorComponent,
13+
props,
14+
ids,
15+
err,
16+
pathname,
17+
query
18+
}
19+
} = window
20+
21+
const Component = evalScript(component).default
22+
const ErrorComponent = evalScript(errorComponent).default
23+
let lastAppProps
24+
25+
export const router = createRouter(pathname, query, {
26+
Component,
27+
ErrorComponent,
28+
err
29+
})
30+
31+
const headManager = new HeadManager()
32+
const container = document.getElementById('__next')
33+
34+
export default (onError) => {
35+
if (ids && ids.length) rehydrate(ids)
36+
37+
router.subscribe(({ Component, props, err }) => {
38+
render({ Component, props, err }, onError)
39+
})
40+
41+
render({ Component, props, err }, onError)
42+
}
43+
44+
export async function render (props, onError = renderErrorComponent) {
45+
try {
46+
await doRender(props)
47+
} catch (err) {
48+
await onError(err)
49+
}
50+
}
51+
52+
async function renderErrorComponent (err) {
53+
const { pathname, query } = router
54+
const props = await getInitialProps(ErrorComponent, { err, pathname, query })
55+
await doRender({ Component: ErrorComponent, props, err })
56+
}
57+
58+
async function doRender ({ Component, props, err }) {
59+
if (!props && Component &&
60+
Component !== ErrorComponent &&
61+
lastAppProps.Component === ErrorComponent) {
62+
// fetch props if ErrorComponent was replaced with a page component by HMR
63+
const { pathname, query } = router
64+
props = await getInitialProps(Component, { err, pathname, query })
65+
}
66+
67+
Component = Component || lastAppProps.Component
68+
props = props || lastAppProps.props
69+
70+
const appProps = { Component, props, err, router, headManager }
71+
lastAppProps = appProps
72+
ReactDOM.render(createElement(App, appProps), container)
73+
}
74+
75+
function getInitialProps (Component, ctx) {
76+
return Component.getInitialProps ? Component.getInitialProps(ctx) : {}
77+
}

client/next-dev.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,20 @@ import patch from './patch-react'
33
// apply patch first
44
patch((err) => {
55
console.error(err)
6-
next.renderError(err)
6+
7+
Promise.resolve().then(() => {
8+
onError(err)
9+
})
710
})
811

912
require('react-hot-loader/patch')
1013

11-
const next = require('./next')
12-
window.next = next
14+
const next = window.next = require('./')
15+
16+
next.default(onError)
17+
18+
function onError (err) {
19+
// just show the debug screen but don't render ErrorComponent
20+
// so that the current component doesn't lose props
21+
next.render({ err })
22+
}

client/next.js

Lines changed: 2 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,3 @@
1-
import { createElement } from 'react'
2-
import ReactDOM from 'react-dom'
3-
import HeadManager from './head-manager'
4-
import { rehydrate } from '../lib/css'
5-
import { createRouter } from '../lib/router'
6-
import App from '../lib/app'
7-
import evalScript from '../lib/eval-script'
1+
import next from './'
82

9-
const {
10-
__NEXT_DATA__: {
11-
component,
12-
errorComponent,
13-
props,
14-
ids,
15-
err,
16-
pathname,
17-
query
18-
}
19-
} = window
20-
21-
const Component = evalScript(component).default
22-
const ErrorComponent = evalScript(errorComponent).default
23-
24-
export const router = createRouter(pathname, query, {
25-
Component,
26-
ErrorComponent,
27-
ctx: { err }
28-
})
29-
30-
const headManager = new HeadManager()
31-
const container = document.getElementById('__next')
32-
const defaultProps = { Component, ErrorComponent, props, router, headManager }
33-
34-
if (ids && ids.length) rehydrate(ids)
35-
36-
render()
37-
38-
export function render (props = {}) {
39-
try {
40-
doRender(props)
41-
} catch (err) {
42-
renderError(err)
43-
}
44-
}
45-
46-
export async function renderError (err) {
47-
const { pathname, query } = router
48-
const props = await ErrorComponent.getInitialProps({ err, pathname, query })
49-
try {
50-
doRender({ Component: ErrorComponent, props })
51-
} catch (err2) {
52-
console.error(err2)
53-
}
54-
}
55-
56-
function doRender (props) {
57-
const appProps = { ...defaultProps, ...props }
58-
ReactDOM.render(createElement(App, appProps), container)
59-
}
3+
next()

client/webpack-hot-middleware-client.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ const handlers = {
55
reload (route) {
66
if (route === '/_error') {
77
for (const r of Object.keys(Router.components)) {
8-
const { Component } = Router.components[r]
9-
if (Component.__route === '/_error-debug') {
10-
// reload all '/_error-debug'
8+
const { err } = Router.components[r]
9+
if (err) {
10+
// reload all error routes
1111
// which are expected to be errors of '/_error' routes
1212
Router.reload(r)
1313
}
@@ -29,8 +29,8 @@ const handlers = {
2929
return
3030
}
3131

32-
const { Component } = Router.components[route] || {}
33-
if (Component && Component.__route === '/_error-debug') {
32+
const { err } = Router.components[route] || {}
33+
if (err) {
3434
// reload to recover from runtime errors
3535
Router.reload(route)
3636
}

lib/app.js

Lines changed: 26 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,53 @@
11
import React, { Component, PropTypes } from 'react'
22
import { AppContainer } from 'react-hot-loader'
3+
import shallowEquals from './shallow-equals'
34
import { warn } from './utils'
45

6+
const ErrorDebug = process.env.NODE_ENV === 'production'
7+
? null : require('./error-debug').default
8+
59
export default class App extends Component {
610
static childContextTypes = {
7-
router: PropTypes.object,
811
headManager: PropTypes.object
912
}
1013

11-
constructor (props) {
12-
super(props)
13-
this.state = propsToState(props)
14-
this.close = null
15-
}
16-
17-
componentWillReceiveProps (nextProps) {
18-
const state = propsToState(nextProps)
19-
try {
20-
this.setState(state)
21-
} catch (err) {
22-
this.handleError(err)
23-
}
14+
getChildContext () {
15+
const { headManager } = this.props
16+
return { headManager }
2417
}
2518

26-
componentDidMount () {
27-
const { router } = this.props
28-
29-
this.close = router.subscribe((data) => {
30-
const props = data.props || this.state.props
31-
const state = propsToState({
32-
...data,
33-
props,
34-
router
35-
})
19+
render () {
20+
const { Component, props, err, router } = this.props
21+
const containerProps = { Component, props, router }
3622

37-
try {
38-
this.setState(state)
39-
} catch (err) {
40-
this.handleError(err)
41-
}
42-
})
23+
return <div>
24+
<Container {...containerProps} />
25+
{ErrorDebug && err ? <ErrorDebug err={err} /> : null}
26+
</div>
4327
}
4428

45-
componentWillUnmount () {
46-
if (this.close) this.close()
47-
}
29+
}
4830

49-
getChildContext () {
50-
const { router, headManager } = this.props
51-
return { router, headManager }
31+
class Container extends Component {
32+
shouldComponentUpdate (nextProps) {
33+
// need this check not to rerender component which has already thrown an error
34+
return !shallowEquals(this.props, nextProps)
5235
}
5336

5437
render () {
55-
const { Component, props } = this.state
38+
const { Component, props, router } = this.props
39+
const url = createUrl(router)
5640

41+
// includes AppContainer which bypasses shouldComponentUpdate method
42+
// https://github.com/gaearon/react-hot-loader/issues/442
5743
return <AppContainer>
58-
<Component {...props} />
44+
<Component {...props} url={url} />
5945
</AppContainer>
6046
}
61-
62-
async handleError (err) {
63-
console.error(err)
64-
65-
const { router, ErrorComponent } = this.props
66-
const { pathname, query } = router
67-
const props = await ErrorComponent.getInitialProps({ err, pathname, query })
68-
const state = propsToState({ Component: ErrorComponent, props, router })
69-
70-
try {
71-
this.setState(state)
72-
} catch (err2) {
73-
console.error(err2)
74-
}
75-
}
7647
}
7748

78-
function propsToState (props) {
79-
const { Component, router } = props
80-
const url = {
49+
function createUrl (router) {
50+
return {
8151
query: router.query,
8252
pathname: router.pathname,
8353
back: () => router.back(),
@@ -98,9 +68,4 @@ function propsToState (props) {
9868
return router.replace(replaceRoute, replaceUrl)
9969
}
10070
}
101-
102-
return {
103-
Component,
104-
props: { ...props.props, url }
105-
}
10671
}

lib/error-debug.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import React from 'react'
2+
import ansiHTML from 'ansi-html'
3+
import Head from './head'
4+
5+
export default ({ err: { name, message, stack, module } }) => (
6+
<div style={styles.errorDebug}>
7+
<Head>
8+
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
9+
</Head>
10+
{module ? <div style={styles.heading}>Error in {module.rawRequest}</div> : null}
11+
{
12+
name === 'ModuleBuildError'
13+
? <pre style={styles.message} dangerouslySetInnerHTML={{ __html: ansiHTML(encodeHtml(message)) }} />
14+
: <pre style={styles.message}>{stack}</pre>
15+
}
16+
</div>
17+
)
18+
19+
const styles = {
20+
errorDebug: {
21+
background: '#a6004c',
22+
boxSizing: 'border-box',
23+
overflow: 'auto',
24+
padding: '16px',
25+
position: 'fixed',
26+
left: 0,
27+
right: 0,
28+
top: 0,
29+
bottom: 0,
30+
zIndex: 9999
31+
},
32+
33+
message: {
34+
fontFamily: '"SF Mono", "Roboto Mono", "Fira Mono", menlo-regular, monospace',
35+
fontSize: '10px',
36+
color: '#fbe7f1',
37+
margin: 0,
38+
whiteSpace: 'pre-wrap',
39+
wordWrap: 'break-word'
40+
},
41+
42+
heading: {
43+
fontFamily: '-apple-system, BlinkMacSystemFont, Roboto, "Segoe UI", "Fira Sans", Avenir, "Helvetica Neue", "Lucida Grande", sans-serif',
44+
fontSize: '13px',
45+
fontWeight: 'bold',
46+
color: '#ff84bf',
47+
marginBottom: '20px'
48+
}
49+
}
50+
51+
const encodeHtml = str => {
52+
return str.replace(/</g, '&lt;').replace(/>/g, '&gt;')
53+
}
54+
55+
// see color definitions of babel-code-frame:
56+
// https://github.com/babel/babel/blob/master/packages/babel-code-frame/src/index.js
57+
58+
ansiHTML.setColors({
59+
reset: ['fff', 'a6004c'],
60+
darkgrey: 'e54590',
61+
yellow: 'ee8cbb',
62+
green: 'f2a2c7',
63+
magenta: 'fbe7f1',
64+
blue: 'fff',
65+
cyan: 'ef8bb9',
66+
red: 'fff'
67+
})

0 commit comments

Comments
 (0)