Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved stacktraces (minor) #4156

Merged
merged 15 commits into from
Apr 18, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 52 additions & 27 deletions client/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createElement } from 'react'
import React from 'react'
import ReactDOM from 'react-dom'
import HeadManager from './head-manager'
import { createRouter } from '../lib/router'
Expand Down Expand Up @@ -66,33 +66,44 @@ const errorContainer = document.getElementById('__next-error')
let lastAppProps
export let router
export let ErrorComponent
let HotAppContainer
let ErrorDebugComponent
let Component
let App
let stripAnsi = (s) => s
let applySourcemaps = (e) => e

export const emitter = new EventEmitter()

export default async ({ ErrorDebugComponent: passedDebugComponent, stripAnsi: passedStripAnsi } = {}) => {
export default async ({
HotAppContainer: passedHotAppContainer,
ErrorDebugComponent: passedDebugComponent,
stripAnsi: passedStripAnsi,
applySourcemaps: passedApplySourcemaps
} = {}) => {
// Wait for all the dynamic chunks to get loaded
for (const chunkName of chunks) {
await pageLoader.waitForChunk(chunkName)
}

stripAnsi = passedStripAnsi || stripAnsi
applySourcemaps = passedApplySourcemaps || applySourcemaps
HotAppContainer = passedHotAppContainer
ErrorDebugComponent = passedDebugComponent
ErrorComponent = await pageLoader.loadPage('/_error')
App = await pageLoader.loadPage('/_app')

let initialErr = err

try {
Component = await pageLoader.loadPage(page)

if (typeof Component !== 'function') {
throw new Error(`The default export is not a React Component in page: "${pathname}"`)
}
} catch (err) {
console.error(stripAnsi(`${err.message}\n${err.stack}`))
Component = ErrorComponent
} catch (error) {
// This catches errors like throwing in the top level of a module
initialErr = error
}

router = createRouter(pathname, query, asPath, {
Expand All @@ -101,54 +112,60 @@ export default async ({ ErrorDebugComponent: passedDebugComponent, stripAnsi: pa
App,
Component,
ErrorComponent,
err
err: initialErr
})

router.subscribe(({ Component, props, hash, err }) => {
render({ Component, props, err, hash, emitter })
})

const hash = location.hash.substring(1)
render({ Component, props, hash, err, emitter })
render({ Component, props, hash, err: initialErr, emitter })

return emitter
}

export async function render (props) {
if (props.err) {
await renderError(props.err)
await renderError(props)
return
}

try {
await doRender(props)
} catch (err) {
if (err.abort) return
await renderError(err)
await renderError({...props, err})
}
}

// This method handles all runtime and debug errors.
// 404 and 500 errors are special kind of errors
// and they are still handle via the main render method.
export async function renderError (error) {
const prod = process.env.NODE_ENV === 'production'
// We need to unmount the current app component because it's
// in the inconsistant state.
// Otherwise, we need to face issues when the issue is fixed and
// it's get notified via HMR
ReactDOM.unmountComponentAtNode(appContainer)

const errorMessage = `${error.message}\n${error.stack}`
console.error(stripAnsi(errorMessage))

if (prod) {
const initProps = {Component: ErrorComponent, router, ctx: {err: error, pathname, query, asPath}}
const props = await loadGetInitialProps(ErrorComponent, initProps)
renderReactElement(createElement(ErrorComponent, props), errorContainer)
} else {
renderReactElement(createElement(ErrorDebugComponent, { error }), errorContainer)
export async function renderError (props) {
const {err} = props

// In development we apply sourcemaps to the error
if (process.env.NODE_ENV !== 'production') {
await applySourcemaps(err)
}

const str = stripAnsi(`${err.message}\n${err.stack}${err.info ? `\n\n${err.info.componentStack}` : ''}`)
console.error(str)

if (process.env.NODE_ENV !== 'production') {
// We need to unmount the current app component because it's
// in the inconsistant state.
// Otherwise, we need to face issues when the issue is fixed and
// it's get notified via HMR
ReactDOM.unmountComponentAtNode(appContainer)
renderReactElement(<ErrorDebugComponent error={err} />, errorContainer)
return
}

// In production we do a normal render with the `ErrorComponent` as component.
// `App` will handle the calling of `getInitialProps`, which will include the `err` on the context
await doRender({...props, err, Component: ErrorComponent})
}

async function doRender ({ Component, props, hash, err, emitter: emitterProp = emitter }) {
Expand All @@ -172,7 +189,15 @@ async function doRender ({ Component, props, hash, err, emitter: emitterProp = e

// We need to clear any existing runtime error messages
ReactDOM.unmountComponentAtNode(errorContainer)
renderReactElement(createElement(App, appProps), appContainer)

// In development we render react-hot-loader's wrapper component
if (HotAppContainer) {
renderReactElement(<HotAppContainer errorReporter={ErrorDebugComponent} warnings={false}>
<App {...appProps} />
</HotAppContainer>, appContainer)
} else {
renderReactElement(<App {...appProps} />, appContainer)
}

emitterProp.emit('after-reactdom-render', { Component, ErrorComponent, appProps })
}
Expand Down
8 changes: 4 additions & 4 deletions client/next-dev.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import stripAnsi from 'strip-ansi'
import initNext, * as next from './'
import ErrorDebugComponent from '../lib/error-debug'
import {ClientDebug} from '../lib/error-debug'
import initOnDemandEntries from './on-demand-entries-client'
import initWebpackHMR from './webpack-hot-middleware-client'

require('@zeit/source-map-support/browser-source-map-support')
import {AppContainer as HotAppContainer} from 'react-hot-loader'
import {applySourcemaps} from './source-map-support'

window.next = next

initNext({ ErrorDebugComponent, stripAnsi })
initNext({ HotAppContainer, ErrorDebugComponent: ClientDebug, applySourcemaps, stripAnsi })
.then((emitter) => {
initOnDemandEntries()
initWebpackHMR()
Expand Down
54 changes: 54 additions & 0 deletions client/source-map-support.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// @flow
import fetch from 'unfetch'
const filenameRE = /\(([^)]+\.js):(\d+):(\d+)\)$/

export async function applySourcemaps (e: any): Promise<void> {
if (!e || typeof e.stack !== 'string' || e.sourceMapsApplied) {
return
}

const lines = e.stack.split('\n')

const result = await Promise.all(lines.map((line) => {
return rewriteTraceLine(line)
}))

e.stack = result.join('\n')
// This is to make sure we don't apply the sourcemaps twice on the same object
e.sourceMapsApplied = true
}

async function rewriteTraceLine (trace: string): Promise<string> {
const m = trace.match(filenameRE)
if (m == null) {
return trace
}

const filePath = m[1]
if (filePath.match(/node_modules/)) {
return trace
}

const mapPath = `${filePath}.map`

const res = await fetch(mapPath)
if (res.status !== 200) {
return trace
}

const mapContents = await res.json()
const {SourceMapConsumer} = require('source-map')
const map = new SourceMapConsumer(mapContents)
const originalPosition = map.originalPositionFor({
line: Number(m[2]),
column: Number(m[3])
})

if (originalPosition.source != null) {
const { source, line, column } = originalPosition
const mappedPosition = `(${source.replace(/^webpack:\/\/\//, '')}:${String(line)}:${String(column)})`
return trace.replace(filenameRE, mappedPosition)
}

return trace
}
45 changes: 14 additions & 31 deletions lib/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ import { execOnce, warn, loadGetInitialProps } from './utils'
import { makePublicRouterInstance } from './router'

export default class App extends Component {
state = {
hasError: null
}

static displayName = 'App'

static async getInitialProps ({ Component, router, ctx }) {
Expand All @@ -24,18 +20,25 @@ export default class App extends Component {

getChildContext () {
const { headManager } = this.props
const {hasError} = this.state
return {
headManager,
router: makePublicRouterInstance(this.props.router),
_containerProps: {...this.props, hasError}
_containerProps: {...this.props}
}
}

componentDidCatch (error, info) {
error.stack = `${error.stack}\n\n${info.componentStack}`
window.next.renderError(error)
this.setState({ hasError: true })
componentDidCatch (err, info) {
// To provide clearer stacktraces in error-debug.js in development
// To provide clearer stacktraces in app.js in production
err.info = info

if (process.env.NODE_ENV === 'production') {
// In production we render _error.js
window.next.renderError({err})
} else {
// In development we throw the error up to AppContainer from react-hot-loader
throw err
}
}

render () {
Expand Down Expand Up @@ -78,28 +81,8 @@ export class Container extends Component {
}

render () {
const { hasError } = this.context._containerProps

if (hasError) {
return null
}

const {children} = this.props

if (process.env.NODE_ENV === 'production') {
return <>{children}</>
} else {
const ErrorDebug = require('./error-debug').default
const { AppContainer } = require('react-hot-loader')

// includes AppContainer which bypasses shouldComponentUpdate method
// https://github.com/gaearon/react-hot-loader/issues/442
return (
<AppContainer warnings={false} errorReporter={ErrorDebug}>
{children}
</AppContainer>
)
}
return <>{children}</>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can remove the empty fragment here and just return children? or is there a clever reason as to why wrap it in a fragment?

Copy link
Member Author

@timneutkens timneutkens Apr 18, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just didn't want to change behavior from what it was before.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good 👍

}
}

Expand Down
70 changes: 56 additions & 14 deletions lib/error-debug.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,68 @@
import React from 'react'
import ansiHTML from 'ansi-html'
import Head from './head'
import {applySourcemaps} from '../client/source-map-support'

export default ({ error, error: { name, message, module } }) => (
<div style={styles.errorDebug}>
<Head>
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
</Head>
{module ? <h1 style={styles.heading}>Error in {module.rawRequest}</h1> : null}
{
name === 'ModuleBuildError'
? <pre style={styles.stack} dangerouslySetInnerHTML={{ __html: ansiHTML(encodeHtml(message)) }} />
: <StackTrace error={error} />
// On the client side the error can come from multiple places for example react-hot-loader or client/index.js
// `componentDidCatch` doesn't support asynchronous execution, so we have to handle sourcemap support here
export class ClientDebug extends React.Component {
state = {
mappedError: null
}
componentDidMount () {
const {error} = this.props

// If sourcemaps were already applied there is no need to set the state
if (error.sourceMapsApplied) {
return
}
</div>
)

const StackTrace = ({ error: { name, message, stack } }) => (
// Since componentDidMount doesn't handle errors we use then/catch here
applySourcemaps(error).then(() => {
this.setState({mappedError: error})
}).catch(console.error)
}

render () {
const {mappedError} = this.state
const {error} = this.props
if (!error.sourceMapsApplied && mappedError === null) {
return <div style={styles.errorDebug}>
<h1 style={styles.heading}>Loading stacktrace...</h1>
</div>
}

return <ErrorDebug error={error} />
}
}

// On the server side the error has sourcemaps already applied, so `ErrorDebug` is rendered directly.
export default function ErrorDebug ({error}) {
const { name, message, module } = error
return (
<div style={styles.errorDebug}>
<Head>
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
</Head>
{module ? <h1 style={styles.heading}>Error in {module.rawRequest}</h1> : null}
{
name === 'ModuleBuildError'
? <pre style={styles.stack} dangerouslySetInnerHTML={{ __html: ansiHTML(encodeHtml(message)) }} />
: <StackTrace error={error} />
}
</div>
)
}

const StackTrace = ({ error: { name, message, stack, info } }) => (
<div>
<div style={styles.heading}>{message || name}</div>
<pre style={styles.stack}>
{stack}
</pre>
{info && <pre style={styles.stack}>
{info.componentStack}
</pre>}
</div>
)

Expand All @@ -36,7 +77,8 @@ const styles = {
right: 0,
top: 0,
bottom: 0,
zIndex: 9999
zIndex: 9999,
color: '#b3adac'
},

stack: {
Expand Down
Loading