Skip to content

Commit

Permalink
[Fast Refresh] New Overlay for Prerender Error (vercel#12485)
Browse files Browse the repository at this point in the history
  • Loading branch information
Timer authored May 4, 2020
1 parent 0d4707d commit e0449a5
Show file tree
Hide file tree
Showing 17 changed files with 351 additions and 131 deletions.
6 changes: 3 additions & 3 deletions packages/next/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,8 @@ export default async function getBaseWebpackConfig(
}
}

const hasReactRefresh =
dev && !isServer && config.experimental.reactRefresh === true
const isReactRefreshEnabled = config.experimental.reactRefresh === true
const hasReactRefresh = dev && !isServer && isReactRefreshEnabled

const distDir = path.join(dir, config.distDir)
const defaultLoaders = {
Expand Down Expand Up @@ -1032,7 +1032,7 @@ export default async function getBaseWebpackConfig(
customAppFile,
isDevelopment: dev,
isServer,
hasReactRefresh,
isReactRefreshEnabled,
assetPrefix: config.assetPrefix || '',
sassOptions: config.experimental.sassOptions,
})
Expand Down
16 changes: 11 additions & 5 deletions packages/next/build/webpack/config/blocks/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,17 @@ export const base = curry(function base(

// https://webpack.js.org/configuration/devtool/#development
config.devtool = ctx.isDevelopment
? ctx.hasReactRefresh
? // `eval-source-map` results in the fastest rebuilds during dev. The
// only drawback is cold boot time, but this is mitigated by the fact
// that we load entries on-demand.
'eval-source-map'
? ctx.isReactRefreshEnabled
? ctx.isServer
? // Non-eval based source maps are very slow to rebuild, so we only
// enable them for the server. Unfortunately, eval source maps are
// not supported by Node.js.
'inline-source-map'
: // `eval-source-map` provides full-fidelity source maps for the
// original source, including columns and original variable names.
// This is desirable so the in-browser debugger can correctly pause
// and show scoped variables with their original names.
'eval-source-map'
: // `cheap-module-source-map` is the old preferred format that was
// required for `react-error-overlay`.
'cheap-module-source-map'
Expand Down
6 changes: 3 additions & 3 deletions packages/next/build/webpack/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ export async function build(
customAppFile,
isDevelopment,
isServer,
hasReactRefresh,
isReactRefreshEnabled,
assetPrefix,
sassOptions,
}: {
rootDirectory: string
customAppFile: string | null
isDevelopment: boolean
isServer: boolean
hasReactRefresh: boolean
isReactRefreshEnabled: boolean
assetPrefix: string
sassOptions: any
}
Expand All @@ -28,7 +28,7 @@ export async function build(
customAppFile,
isDevelopment,
isProduction: !isDevelopment,
hasReactRefresh,
isReactRefreshEnabled,
isServer,
isClient: !isServer,
assetPrefix: assetPrefix
Expand Down
2 changes: 1 addition & 1 deletion packages/next/build/webpack/config/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type ConfigurationContext = {

isDevelopment: boolean
isProduction: boolean
hasReactRefresh: boolean
isReactRefreshEnabled: boolean

isServer: boolean
isClient: boolean
Expand Down
10 changes: 3 additions & 7 deletions packages/next/client/dev/error-overlay/hot-dev-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,10 @@ export default function connect(options) {
},
reportRuntimeError(err) {
if (process.env.__NEXT_FAST_REFRESH) {
// FIXME: this code branch should be eliminated
setTimeout(() => {
// An unhandled rendering error occurred
throw err
})
} else {
ErrorOverlay.reportRuntimeError(err)
return
}

ErrorOverlay.reportRuntimeError(err)
},
prepareError(err) {
// Temporary workaround for https://github.com/facebook/create-react-app/issues/4760
Expand Down
97 changes: 69 additions & 28 deletions packages/next/client/index.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
/* global location */
import { createRouter, makePublicRouterInstance } from 'next/router'
import { parse as parseQs, stringify as stringifyQs } from 'querystring'
import React from 'react'
import ReactDOM from 'react-dom'
import initHeadManager from './head-manager'
import { createRouter, makePublicRouterInstance } from 'next/router'
import mitt from '../next-server/lib/mitt'
import { loadGetInitialProps, getURL, ST } from '../next-server/lib/utils'
import PageLoader from './page-loader'
import * as envConfig from '../next-server/lib/runtime-config'
import { HeadManagerContext } from '../next-server/lib/head-manager-context'
import mitt from '../next-server/lib/mitt'
import { RouterContext } from '../next-server/lib/router-context'
import { parse as parseQs, stringify as stringifyQs } from 'querystring'
import { isDynamicRoute } from '../next-server/lib/router/utils/is-dynamic'
import * as envConfig from '../next-server/lib/runtime-config'
import { getURL, loadGetInitialProps, ST } from '../next-server/lib/utils'
import initHeadManager from './head-manager'
import PageLoader from './page-loader'
import {
observeLayoutShift,
observeLargestContentfulPaint,
observeLayoutShift,
observePaint,
} from './performance-relayer'

Expand Down Expand Up @@ -263,13 +263,50 @@ export async function render(props) {
// 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(props) {
export function renderError(props) {
const { App, err } = props

// In development runtime errors are caught by react-error-overlay
// In production we catch runtime errors using componentDidCatch which will trigger renderError
if (process.env.NODE_ENV !== 'production') {
return webpackHMR.reportRuntimeError(webpackHMR.prepareError(err))
if (process.env.__NEXT_FAST_REFRESH) {
const { getNodeError } = require('@next/react-dev-overlay/lib/client')
// Server-side runtime errors need to be re-thrown on the client-side so
// that the overlay is rendered.
if (isInitialRender) {
setTimeout(() => {
let error
try {
// Generate a new error object. We `throw` it because some browsers
// will set the `stack` when thrown, and we want to ensure ours is
// not overridden when we re-throw it below.
throw new Error(err.message)
} catch (e) {
error = e
}

error.name = err.name
error.stack = err.stack

const node = getNodeError(error)
throw node
})
}

// We need to render an empty <App> so that the `<ReactDevOverlay>` can
// render itself.
return doRender({
App: () => null,
props: {},
Component: () => null,
err: null,
})
}

// Legacy behavior:
return Promise.resolve(
webpackHMR.reportRuntimeError(webpackHMR.prepareError(err))
)
}
if (process.env.__NEXT_PLUGINS) {
// eslint-disable-next-line
Expand All @@ -284,24 +321,28 @@ export async function renderError(props) {

// Make sure we log the error to the console, otherwise users can't track down issues.
console.error(err)
;({ page: ErrorComponent } = await pageLoader.loadPage('/_error'))

// In production we do a normal render with the `ErrorComponent` as component.
// If we've gotten here upon initial render, we can use the props from the server.
// Otherwise, we need to call `getInitialProps` on `App` before mounting.
const AppTree = wrapApp(App)
const appCtx = {
Component: ErrorComponent,
AppTree,
router,
ctx: { err, pathname: page, query, asPath, AppTree },
}

const initProps = props.props
? props.props
: await loadGetInitialProps(App, appCtx)

await doRender({ ...props, err, Component: ErrorComponent, props: initProps })
return pageLoader.loadPage('/_error').then(({ page: ErrorComponent }) => {
// In production we do a normal render with the `ErrorComponent` as component.
// If we've gotten here upon initial render, we can use the props from the server.
// Otherwise, we need to call `getInitialProps` on `App` before mounting.
const AppTree = wrapApp(App)
const appCtx = {
Component: ErrorComponent,
AppTree,
router,
ctx: { err, pathname: page, query, asPath, AppTree },
}
return Promise.resolve(
props.props ? props.props : loadGetInitialProps(App, appCtx)
).then(initProps =>
doRender({
...props,
err,
Component: ErrorComponent,
props: initProps,
})
)
})
}

// If hydrate does not exist, eg in preact.
Expand Down
10 changes: 7 additions & 3 deletions packages/next/server/next-dev-server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import AmpHtmlValidator from 'next/dist/compiled/amphtml-validator'
import { ReactDevOverlay } from '@next/react-dev-overlay/lib/client'
import crypto from 'crypto'
import findUp from 'next/dist/compiled/find-up'
import fs from 'fs'
import { IncomingMessage, ServerResponse } from 'http'
import Worker from 'jest-worker'
import AmpHtmlValidator from 'next/dist/compiled/amphtml-validator'
import findUp from 'next/dist/compiled/find-up'
import { join, relative, resolve, sep } from 'path'
import React from 'react'
import { UrlWithParsedQuery } from 'url'
Expand Down Expand Up @@ -48,7 +49,10 @@ export default class DevServer extends Server {
constructor(options: ServerConstructor & { isNextDevCommand?: boolean }) {
super({ ...options, dev: true })
this.renderOpts.dev = true
;(this.renderOpts as any).ErrorDebug = ErrorDebug
;(this.renderOpts as any).ErrorDebug =
this.nextConfig.experimental?.reactRefresh === true
? ReactDevOverlay
: ErrorDebug
this.devReady = new Promise(resolve => {
this.setDevReady = resolve
})
Expand Down
1 change: 1 addition & 0 deletions packages/react-dev-overlay/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"anser": "1.4.9",
"chalk": "4.0.0",
"classnames": "2.2.6",
"data-uri-to-buffer": "3.0.0",
"shell-quote": "1.7.2",
"source-map": "0.8.0-beta.0",
"stacktrace-parser": "0.1.9",
Expand Down
1 change: 1 addition & 0 deletions packages/react-dev-overlay/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,6 @@ function onRefresh() {
Bus.emit({ type: Bus.TYPE_REFFRESH })
}

export { getNodeError } from './internal/helpers/nodeStackFrames'
export { default as ReactDevOverlay } from './internal/ReactDevOverlay'
export { register, unregister, onRefresh }
4 changes: 3 additions & 1 deletion packages/react-dev-overlay/src/internal/ReactDevOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ function ReactDevOverlay({ children }) {

return (
<React.Fragment>
<ErrorBoundary onError={onComponentError}>{children}</ErrorBoundary>
<ErrorBoundary onError={onComponentError}>
{children ?? null}
</ErrorBoundary>
{state.errors.length ? (
<ShadowPortal>
<CssReset />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const CallStackFrame: React.FC<{
// TODO: render error or external indicator

const f: StackFrame = frame.originalStackFrame ?? frame.sourceStackFrame
const hasSource = Boolean(frame.originalStackFrame?.file)
const hasSource = Boolean(frame.originalCodeFrame)

const open = React.useCallback(() => {
if (!hasSource) return
Expand Down
30 changes: 30 additions & 0 deletions packages/react-dev-overlay/src/internal/helpers/getRawSourceMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import dataUriToBuffer, { MimeBuffer } from 'data-uri-to-buffer'
import { RawSourceMap } from 'source-map'
import { getSourceMapUrl } from './getSourceMapUrl'

export function getRawSourceMap(fileContents: string): RawSourceMap | null {
const sourceUrl = getSourceMapUrl(fileContents)
if (sourceUrl == null) {
return null
}

let buffer: MimeBuffer
try {
buffer = dataUriToBuffer(sourceUrl)
} catch (err) {
console.error('Failed to parse source map URL:', err)
return null
}

if (buffer.type !== 'application/json') {
console.error(`Unknown source map type: ${buffer.typeFull}.`)
return null
}

try {
return JSON.parse(buffer.toString())
} catch {
console.error('Failed to parse source map.')
return null
}
}
15 changes: 15 additions & 0 deletions packages/react-dev-overlay/src/internal/helpers/getSourceMapUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function getSourceMapUrl(fileContents: string): string | null {
const regex = /\/\/[#@] ?sourceMappingURL=([^\s'"]+)\s*$/gm
let match = null
for (;;) {
let next = regex.exec(fileContents)
if (next == null) {
break
}
match = next
}
if (!(match && match[1])) {
return null
}
return match[1].toString()
}
54 changes: 54 additions & 0 deletions packages/react-dev-overlay/src/internal/helpers/nodeStackFrames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { parse, StackFrame } from 'stacktrace-parser'

export function getFilesystemFrame(frame: StackFrame): StackFrame {
const f: StackFrame = { ...frame }

if (typeof f.file === 'string') {
if (
// Posix:
f.file.startsWith('/') ||
// Win32:
/^[a-z]:\\/i.test(f.file) ||
// Win32 UNC:
f.file.startsWith('\\\\')
) {
f.file = `file://${f.file}`
}
}

return f
}

export function getNodeError(error: Error): Error {
let n: Error
try {
throw new Error(error.message)
} catch (e) {
n = e
}

n.name = error.name
try {
n.stack = parse(error.stack)
.map(getFilesystemFrame)
.map(f => {
let str = ` at ${f.methodName}`
if (f.file) {
let loc = f.file
if (f.lineNumber) {
loc += `:${f.lineNumber}`
if (f.column) {
loc += `:${f.column}`
}
}
str += ` (${loc})`
}
return str
})
.join('\n')
} catch {
n.stack = error.stack
}

return n
}
Loading

0 comments on commit e0449a5

Please sign in to comment.