Skip to content

Commit

Permalink
feat: impliment flexible error boundaries
Browse files Browse the repository at this point in the history
  • Loading branch information
theKashey committed Dec 11, 2018
1 parent bdd2659 commit 1846019
Show file tree
Hide file tree
Showing 12 changed files with 210 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { EDIT_ME } from './_editMe'

class ChildrenAsFunctionComponent extends React.Component {
render() {
return <div>{this.props.children('passed-argument')}</div>
return <div>{this.props_.children('passed-argument')}</div>
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ const ErrorBoundary = ({
<p style={{ color: 'red' }}>{error && error.toString()}</p>
<div>Stacktrace:</div>
<div style={{ color: 'red', marginTop: '10px' }}>
{errorInfo.componentStack
.split('\n')
.map((line, i) => <div key={i}>{line}</div>)}
{errorInfo &&
errorInfo.componentStack &&
errorInfo.componentStack
.split('\n')
.map((line, i) => <div key={i}>{line}</div>)}
</div>
</div>
)
Expand Down
13 changes: 7 additions & 6 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ interface ErrorReporterProps {
}

export interface AppContainerProps {
errorBoundary?: boolean
errorReporter?: React.ComponentType<ErrorReporterProps>
}

export interface AppChildren {
children?: React.ReactElement<any>
errorReporter?:
| React.ComponentClass<ErrorReporterProps>
| React.StatelessComponent<ErrorReporterProps>
}

export class AppContainer extends React.Component<
AppContainerProps,
React.ComponentState
AppContainerProps & AppChildren
> {}

/**
Expand All @@ -26,7 +27,7 @@ export class AppContainer extends React.Component<
*/
export function hot(
module: any,
): <T = React.ComponentType<any>>(Component: T) => T
): <T = React.ComponentType<any>>(Component: T, props?: AppContainerProps) => T

/**
* Marks component as `cold`, and making it invisible for React-Hot-Loader.
Expand Down
6 changes: 5 additions & 1 deletion root.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import * as React from 'react'
import { AppContainerProps } from './index'

export function hot<T = React.ComponentType<any>>(Component: T): T
export function hot<T = React.ComponentType<any>>(
Component: T,
props?: AppContainerProps,
): T
12 changes: 9 additions & 3 deletions src/AppContainer.dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import defaultPolyfill, { polyfill } from 'react-lifecycles-compat'
import logger from './logger'
import { get as getGeneration } from './global/generation'
import configuration from './configuration'
import { logException } from './errorReporter'
import { EmptyErrorPlaceholder, logException } from './errorReporter'

class AppContainer extends React.Component {
static getDerivedStateFromProps(nextProps, prevState) {
Expand Down Expand Up @@ -52,10 +52,11 @@ class AppContainer extends React.Component {
const { error, errorInfo } = this.state

const {
errorReporter: ErrorReporter = configuration.errorReporter,
errorReporter: ErrorReporter = configuration.errorReporter ||
EmptyErrorPlaceholder,
} = this.props

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

Expand All @@ -81,6 +82,11 @@ AppContainer.propTypes = {
return undefined
},
errorReporter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
errorBoundary: PropTypes.bool,
}

AppContainer.defaultProps = {
errorBoundary: true,
}

// trying first react-lifecycles-compat.polyfill, then trying react-lifecycles-compat, which could be .default
Expand Down
21 changes: 18 additions & 3 deletions src/errorReporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,22 @@ const overlayStyle = {

margin: 0,
padding: '16px',
maxHeight: '100%',
maxHeight: '50%',
overflow: 'auto',
}

const listStyle = {}

export const EmptyErrorPlaceholder = () => (
<span style={{ backgroundColor: '#FEE' }}>⚛️🔥🤕</span>
)

const mapError = ({ error, errorInfo }) => (
<div>
<p style={{ color: 'red' }}>
{error.toString ? error.toString() : error.message || 'undefined error'}
</p>
{errorInfo && (
{errorInfo && errorInfo.componentStack ? (
<div>
<div>Stacktrace:</div>
<ul style={{ color: 'red', marginTop: '10px' }}>
Expand All @@ -38,6 +42,17 @@ const mapError = ({ error, errorInfo }) => (
.map((line, i) => <li key={String(i)}>{line}</li>)}
</ul>
</div>
) : (
error.stack && (
<div>
<div>Stacktrace:</div>
<ul style={{ color: 'red', marginTop: '10px' }}>
{error.stack
.split('\n')
.map((line, i) => <li key={String(i)}>{line}</li>)}
</ul>
</div>
)
)}
</div>
)
Expand All @@ -49,7 +64,7 @@ class ErrorOverlay extends React.Component {
}
return (
<div style={overlayStyle}>
<h2 style={{ margin: 0 }}>⚛️🔥: hot update was not successful</h2>
<h2 style={{ margin: 0 }}>⚛️🔥😭: hot update was not successful</h2>
<ul style={listStyle}>
{lastError.map((err, i) => <li key={i}>{mapError(err)}</li>)}
</ul>
Expand Down
28 changes: 25 additions & 3 deletions src/global/generation.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
let generation = 1
let hotComparisonCounter = 0
const nullFunction = () => {}
let onHotComparisonOpen = nullFunction
let onHotComparisonElement = nullFunction
let onHotComparisonClose = nullFunction

export const setComparisonHooks = (open, element, close) => {
onHotComparisonOpen = open
onHotComparisonElement = element
onHotComparisonClose = close
}

export const getElementComparisonHook = () => onHotComparisonElement

export const hotComparisonOpen = () => hotComparisonCounter > 0

const incrementHot = () => hotComparisonCounter++
const decrementHot = () => hotComparisonCounter--
const incrementHot = () => {
if (!hotComparisonCounter) {
onHotComparisonOpen()
}
hotComparisonCounter++
}
const decrementHot = () => {
hotComparisonCounter--
if (!hotComparisonCounter) {
onHotComparisonClose()
}
}

export const enterHotUpdate = () => {
Promise.resolve(incrementHot()).then(decrementHot)
Promise.resolve(incrementHot()).then(() => setTimeout(decrementHot, 0))
}

export const increment = () => {
Expand Down
4 changes: 2 additions & 2 deletions src/hot.dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const hot = sourceModule => {

// TODO: Ensure that all exports from this file are react components.

return WrappedComponent => {
return (WrappedComponent, props) => {
clearFailbackTimer(failbackTimer)
// register proxy for wrapped component
reactHotLoader.register(
Expand Down Expand Up @@ -119,7 +119,7 @@ const hot = sourceModule => {

render() {
return (
<AppContainer>
<AppContainer {...props}>
<WrappedComponent {...this.props} />
</AppContainer>
)
Expand Down
9 changes: 9 additions & 0 deletions src/proxy/createClassProxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
isReactClass,
isReactClassInstance,
} from '../internal/reactUtils'
import { getElementComparisonHook } from '../global/generation'

const has = Object.prototype.hasOwnProperty

Expand All @@ -30,6 +31,7 @@ const blackListedClassMembers = [
'render',
'componentWillMount',
'componentDidMount',
'componentDidCatch',
'componentWillReceiveProps',
'componentWillUnmount',
'hotComponentRender',
Expand Down Expand Up @@ -102,6 +104,10 @@ const copyMethodDescriptors = (target, source) => {
return target
}

const knownClassComponents = []

export const forEachKnownClass = cb => knownClassComponents.forEach(cb)

function createClassProxy(InitialComponent, proxyKey, options = {}) {
const renderOptions = {
...defaultRenderOptions,
Expand Down Expand Up @@ -250,6 +256,8 @@ function createClassProxy(InitialComponent, proxyKey, options = {}) {

defineProxyMethods(ProxyComponent, InitialComponent.prototype)

knownClassComponents.push(ProxyComponent)

ProxyFacade = ProxyComponent
} else if (!proxyConfig.allowSFC) {
proxyConfig.pureRender = false
Expand Down Expand Up @@ -410,6 +418,7 @@ function createClassProxy(InitialComponent, proxyKey, options = {}) {
Object.setPrototypeOf(ProxyComponent.prototype, NextComponent.prototype)
defineProxyMethods(ProxyComponent, NextComponent.prototype)
if (proxyGeneration > 1) {
getElementComparisonHook()(ProxyComponent)
injectedMembers = mergeComponents(
ProxyComponent,
NextComponent,
Expand Down
4 changes: 4 additions & 0 deletions src/reactHotLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const updateForward = (target, { render }) => {
export const hotComponentCompare = (oldType, newType, setNewType) => {
let defaultResult = oldType === newType

if ((oldType && !newType) || (!oldType && newType)) {
return false
}

if (isRegisteredComponent(oldType) || isRegisteredComponent(newType)) {
if (resolveType(oldType) !== resolveType(newType)) {
return false
Expand Down
90 changes: 89 additions & 1 deletion src/reconciler/proxyAdapter.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import React from 'react'
import reactHotLoader from '../reactHotLoader'
import { enterHotUpdate, get as getGeneration } from '../global/generation'
import {
enterHotUpdate,
get as getGeneration,
setComparisonHooks,
} from '../global/generation'
import { getProxyByType, setStandInOptions } from './proxies'
import reconcileHotReplacement, {
flushScheduledUpdates,
unscheduleUpdate,
} from './index'
import configuration from '../configuration'
import { forEachKnownClass } from '../proxy/createClassProxy'
import { EmptyErrorPlaceholder, logException } from '../errorReporter'

export const RENDERED_GENERATION = 'REACT_HOT_LOADER_RENDERED_GENERATION'

Expand Down Expand Up @@ -56,6 +64,86 @@ export function proxyWrapper(element) {
return element
}

const ERROR_STATE = 'react-hot-loader-catched-error'
const OLD_RENDER = 'react-hot-loader-original-render'

function componentDidCatch(error, errorInfo) {
this[ERROR_STATE] = {
error,
errorInfo,
generation: getGeneration(),
}
Object.getPrototypeOf(this)[ERROR_STATE] = this[ERROR_STATE]
if (!configuration.errorReporter) {
logException({
error,
errorInfo,
})
}
this.forceUpdate()
}

function componentRender() {
const { error, errorInfo, generation } = this[ERROR_STATE] || {}

if (error && generation === getGeneration()) {
return React.createElement(
configuration.errorReporter || EmptyErrorPlaceholder,
{ error, errorInfo },
)
}
try {
return this[OLD_RENDER].render.call(this)
} catch (renderError) {
this[ERROR_STATE] = {
error: renderError,
generation: getGeneration(),
}
if (!configuration.errorReporter) {
logException(renderError)
}
return componentRender.call(this)
}
}

setComparisonHooks(
() => ({}),
({ prototype }) => {
if (!prototype[OLD_RENDER]) {
const renderDescriptior = Object.getOwnPropertyDescriptor(
prototype,
'render',
)
prototype[OLD_RENDER] = {
descriptor: renderDescriptior ? renderDescriptior.value : undefined,
render: prototype.render,
}
prototype.componentDidCatch = componentDidCatch

prototype.render = componentRender
}
},
() =>
forEachKnownClass(({ prototype }) => {
if (prototype[OLD_RENDER]) {
const { generation } = prototype[ERROR_STATE] || {}

if (generation === getGeneration()) {
// still in error.
// keep render hooked
} else {
delete prototype.componentDidCatch
if (!prototype[OLD_RENDER].descriptor) {
delete prototype.render
} else {
prototype.render = prototype[OLD_RENDER].descriptor
}
delete prototype[OLD_RENDER]
}
}
}),
)

setStandInOptions({
componentWillRender: asyncReconciledRender,
componentDidRender: proxyWrapper,
Expand Down

0 comments on commit 1846019

Please sign in to comment.