Skip to content

Commit

Permalink
feat: make errors retryable to mitigate hooks update
Browse files Browse the repository at this point in the history
  • Loading branch information
theKashey committed Feb 14, 2019
1 parent e2c34f8 commit 9967fde
Show file tree
Hide file tree
Showing 7 changed files with 95 additions and 22 deletions.
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,10 +428,17 @@ You may add one more babel-loader, with only one React-Hot-Loader plugin inside

##### React-Hooks

* React-Hooks should work out of the box if you are using version 4.6.0 or above (`pureSFC` is enabled by default).
* Having dom-patch enabled would solve any possible issue (`ignoreSFC` option is enabled)
React hooks are not _really_ supported by React-Hot-Loader. Mostly due to our internal
processes of re-rendering React Tree, which is requited to reconcile the updated application
before React will try to rerender it, and purge everything by the way.

You can always `cold` component, but any update then would cause a state loss.
* hooks **should work** for versions 4.6.0 and above (`pureSFC` is enabled by default).
* hooks will produce **errors** on every hot-update without patches to `react-dom`.
* hooks **may loss the state** without patches to `react-dom`.
* hooks does not support adding new hooks on the fly

The most safe way to use hooks - `cold` them, so make them not hot-reloadable, but they would work
without pain (any update then would cause a state loss)

* _cold_ components using hooks.

Expand Down
8 changes: 7 additions & 1 deletion src/AppContainer.dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ class AppContainer extends React.Component {
})
}

retryHotLoaderError = () => {
this.setState({ error: null })
}

render() {
const { error, errorInfo } = this.state

Expand All @@ -65,7 +69,9 @@ class AppContainer extends React.Component {
} = this.props

if (error && this.props.errorBoundary) {
return <ErrorReporter error={error} errorInfo={errorInfo} />
return (
<ErrorReporter error={error} errorInfo={errorInfo} component={this} />
)
}

if (this.hotComponentUpdate) {
Expand Down
73 changes: 60 additions & 13 deletions src/errorReporter.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/* global document */
/* eslint-disable react/no-array-index-key */
/* eslint-disable jsx-a11y/accessible-emoji */
/* eslint-disable no-use-before-define */

import React from 'react'
import ReactDom from 'react-dom'

import configuration from './configuration'
import { getComponentDisplayName } from './internal/reactUtils'
import { enterHotUpdate } from './global/generation'

let lastError = []

Expand All @@ -21,7 +23,7 @@ const overlayStyle = {
color: '#000',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',

fontSize: '12px',
margin: 0,
padding: '16px',
maxHeight: '50%',
Expand All @@ -32,26 +34,48 @@ const inlineErrorStyle = {
backgroundColor: '#FEE',
}

const liCounter = {
position: 'absolute',
left: '10px',
}

const listStyle = {}

export const EmptyErrorPlaceholder = ({ component }) => (
<span style={inlineErrorStyle} role="img" aria-label="Rect-Hot-Loader Error">
⚛️🔥🤕 ({component
? getComponentDisplayName(component.constructor || component)
: 'Unknown location'})
{component &&
component.retryHotLoaderError && (
<button onClick={component.retryHotLoaderError} title="Retry">
</button>
)}
</span>
)

const errorHeader = (component, componentStack) => {
if (component || componentStack) {
return (
<span>
(
{component
? getComponentDisplayName(component.constructor || component)
: 'Unknown location'}
{component && ', '}
{componentStack && componentStack.split('\n').filter(Boolean)[0]}
)
</span>
)
}
return null
}

const mapError = ({ error, errorInfo, component }) => (
<div>
<React.Fragment>
<p style={{ color: 'red' }}>
{component && (
<span>
({component
? getComponentDisplayName(component.constructor || component)
: 'Unknown location'})
</span>
)}
{errorHeader(component, errorInfo && errorInfo.componentStack)}{' '}
{error.toString ? error.toString() : error.message || 'undefined error'}
</p>
{errorInfo && errorInfo.componentStack ? (
Expand All @@ -62,6 +86,7 @@ const mapError = ({ error, errorInfo, component }) => (
.split('\n')
.slice(1, 2)
.map((line, i) => <li key={String(i)}>{line}</li>)}
<hr />
{errorInfo.componentStack
.split('\n')
.filter(Boolean)
Expand All @@ -80,7 +105,7 @@ const mapError = ({ error, errorInfo, component }) => (
</div>
)
)}
</div>
</React.Fragment>
)

class ErrorOverlay extends React.Component {
Expand All @@ -90,6 +115,20 @@ class ErrorOverlay extends React.Component {

toggle = () => this.setState({ visible: !this.state.visible })

retry = () =>
this.setState(() => {
const { errors } = this.props
enterHotUpdate()
clearExceptions()
errors
.map(({ component }) => component)
.filter(Boolean)
.filter(({ retryHotLoaderError }) => !!retryHotLoaderError)
.forEach(component => component.retryHotLoaderError())

return {}
})

render() {
const { errors } = this.props
if (!errors.length) {
Expand All @@ -103,10 +142,18 @@ class ErrorOverlay extends React.Component {
<button onClick={this.toggle}>
{visible ? 'collapse' : 'expand'}
</button>
<button onClick={this.retry}>Retry</button>
</h2>
{visible && (
<ul style={listStyle}>
{errors.map((err, i) => <li key={i}>{mapError(err)}</li>)}
{errors.map((err, i) => (
<li key={i}>
<span style={liCounter}>
({i + 1}/{errors.length})
</span>
{mapError(err)}
</li>
))}
</ul>
)}
</div>
Expand All @@ -132,14 +179,14 @@ const initErrorOverlay = () => {
}
}

export const clearExceptions = () => {
export function clearExceptions() {
if (lastError.length) {
lastError = []
initErrorOverlay()
}
}

export const logException = (error, errorInfo, component) => {
export function logException(error, errorInfo, component) {
// do not suppress error

/* eslint-disable no-console */
Expand Down
1 change: 1 addition & 0 deletions src/internal/stack/hydrateLegacyStack.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

function pushState(stack, type, instance) {
stack.type = type
stack.elementType = type
stack.children = []
stack.instance = instance || stack

Expand Down
16 changes: 12 additions & 4 deletions src/reconciler/proxyAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,14 @@ const OLD_RENDER = 'react-hot-loader-original-render'

function componentDidCatch(error, errorInfo) {
this[ERROR_STATE] = {
location: 'boundary',
error,
errorInfo,
generation: getGeneration(),
}
Object.getPrototypeOf(this)[ERROR_STATE] = this[ERROR_STATE]
if (!configuration.errorReporter) {
logException({
error,
errorInfo,
})
logException(error, errorInfo, this)
}
this.forceUpdate()
}
Expand All @@ -95,6 +93,7 @@ function componentRender() {
return this[OLD_RENDER].render.call(this)
} catch (renderError) {
this[ERROR_STATE] = {
location: 'render',
error: renderError,
generation: getGeneration(),
}
Expand All @@ -105,6 +104,11 @@ function componentRender() {
}
}

function retryHotLoaderError() {
delete this[ERROR_STATE]
this.forceUpdate()
}

setComparisonHooks(
() => ({}),
({ prototype }) => {
Expand All @@ -118,9 +122,11 @@ setComparisonHooks(
render: prototype.render,
}
prototype.componentDidCatch = componentDidCatch
prototype.retryHotLoaderError = retryHotLoaderError

prototype.render = componentRender
}
delete prototype[ERROR_STATE]
},
() =>
forEachKnownClass(({ prototype }) => {
Expand All @@ -132,11 +138,13 @@ setComparisonHooks(
// keep render hooked
} else {
delete prototype.componentDidCatch
delete prototype.retryHotLoaderError
if (!prototype[OLD_RENDER].descriptor) {
delete prototype.render
} else {
prototype.render = prototype[OLD_RENDER].descriptor
}
delete prototype[ERROR_STATE]
delete prototype[OLD_RENDER]
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/webpack/patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ function transform(source) {
// early reject
return source;
}
if (source.indexOf(sign) >= 0) {
// already patched
return;
}
for (const key in injectionStart) {
if (
source.indexOf(injectionStart[key][0]) > 0 &&
Expand Down
2 changes: 1 addition & 1 deletion test/reconciler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ describe('reconciler', () => {
console.error.mockRestore()
})

it('should catch error to the boundary', async () => {
it('should catch error to the boundary', () => {
if (!React.Suspense) {
// this test is unstable on React 15
expect(true).toBe(true)
Expand Down

0 comments on commit 9967fde

Please sign in to comment.