Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Handle error overlay for new router (#41325)
- Loading branch information
1 parent
2060d0e
commit e3df590
Showing
65 changed files
with
7,013 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
95 changes: 95 additions & 0 deletions
95
packages/next/client/components/react-dev-overlay/src/client.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
import * as Bus from './internal/bus' | ||
import { parseStack } from './internal/helpers/parseStack' | ||
|
||
let isRegistered = false | ||
let stackTraceLimit: number | undefined = undefined | ||
|
||
function onUnhandledError(ev: ErrorEvent) { | ||
const error = ev?.error | ||
if (!error || !(error instanceof Error) || typeof error.stack !== 'string') { | ||
// A non-error was thrown, we don't have anything to show. :-( | ||
return | ||
} | ||
|
||
if ( | ||
error.message.match(/(hydration|content does not match|did not match)/i) | ||
) { | ||
error.message += `\n\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error` | ||
} | ||
|
||
const e = error | ||
Bus.emit({ | ||
type: Bus.TYPE_UNHANDLED_ERROR, | ||
reason: error, | ||
frames: parseStack(e.stack!), | ||
}) | ||
} | ||
|
||
function onUnhandledRejection(ev: PromiseRejectionEvent) { | ||
const reason = ev?.reason | ||
if ( | ||
!reason || | ||
!(reason instanceof Error) || | ||
typeof reason.stack !== 'string' | ||
) { | ||
// A non-error was thrown, we don't have anything to show. :-( | ||
return | ||
} | ||
|
||
const e = reason | ||
Bus.emit({ | ||
type: Bus.TYPE_UNHANDLED_REJECTION, | ||
reason: reason, | ||
frames: parseStack(e.stack!), | ||
}) | ||
} | ||
|
||
function register() { | ||
if (isRegistered) { | ||
return | ||
} | ||
isRegistered = true | ||
|
||
try { | ||
const limit = Error.stackTraceLimit | ||
Error.stackTraceLimit = 50 | ||
stackTraceLimit = limit | ||
} catch {} | ||
|
||
window.addEventListener('error', onUnhandledError) | ||
window.addEventListener('unhandledrejection', onUnhandledRejection) | ||
} | ||
|
||
function unregister() { | ||
if (!isRegistered) { | ||
return | ||
} | ||
isRegistered = false | ||
|
||
if (stackTraceLimit !== undefined) { | ||
try { | ||
Error.stackTraceLimit = stackTraceLimit | ||
} catch {} | ||
stackTraceLimit = undefined | ||
} | ||
|
||
window.removeEventListener('error', onUnhandledError) | ||
window.removeEventListener('unhandledrejection', onUnhandledRejection) | ||
} | ||
|
||
function onBuildOk() { | ||
Bus.emit({ type: Bus.TYPE_BUILD_OK }) | ||
} | ||
|
||
function onBuildError(message: string) { | ||
Bus.emit({ type: Bus.TYPE_BUILD_ERROR, message }) | ||
} | ||
|
||
function onRefresh() { | ||
Bus.emit({ type: Bus.TYPE_REFRESH }) | ||
} | ||
|
||
export { getErrorByType } from './internal/helpers/getErrorByType' | ||
export { getServerError } from './internal/helpers/nodeStackFrames' | ||
export { default as ReactDevOverlay } from './internal/ReactDevOverlay' | ||
export { onBuildOk, onBuildError, register, unregister, onRefresh } |
50 changes: 50 additions & 0 deletions
50
packages/next/client/components/react-dev-overlay/src/internal/ErrorBoundary.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import React from 'react' | ||
|
||
type ErrorBoundaryProps = { | ||
onError: (error: Error, componentStack: string | null) => void | ||
globalOverlay?: boolean | ||
isMounted?: boolean | ||
} | ||
type ErrorBoundaryState = { error: Error | null } | ||
|
||
class ErrorBoundary extends React.PureComponent< | ||
ErrorBoundaryProps, | ||
ErrorBoundaryState | ||
> { | ||
state = { error: null } | ||
|
||
static getDerivedStateFromError(error: Error) { | ||
return { error } | ||
} | ||
|
||
componentDidCatch( | ||
error: Error, | ||
// Loosely typed because it depends on the React version and was | ||
// accidentally excluded in some versions. | ||
errorInfo?: { componentStack?: string | null } | ||
) { | ||
this.props.onError(error, errorInfo?.componentStack || null) | ||
if (!this.props.globalOverlay) { | ||
this.setState({ error }) | ||
} | ||
} | ||
|
||
render() { | ||
// The component has to be unmounted or else it would continue to error | ||
return this.state.error || | ||
(this.props.globalOverlay && this.props.isMounted) ? ( | ||
// When the overlay is global for the application and it wraps a component rendering `<html>` | ||
// we have to render the html shell otherwise the shadow root will not be able to attach | ||
this.props.globalOverlay ? ( | ||
<html> | ||
<head></head> | ||
<body></body> | ||
</html> | ||
) : null | ||
) : ( | ||
this.props.children | ||
) | ||
} | ||
} | ||
|
||
export { ErrorBoundary } |
127 changes: 127 additions & 0 deletions
127
packages/next/client/components/react-dev-overlay/src/internal/ReactDevOverlay.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
import * as React from 'react' | ||
|
||
import * as Bus from './bus' | ||
import { ShadowPortal } from './components/ShadowPortal' | ||
import { BuildError } from './container/BuildError' | ||
import { Errors, SupportedErrorEvent } from './container/Errors' | ||
import { ErrorBoundary } from './ErrorBoundary' | ||
import { Base } from './styles/Base' | ||
import { ComponentStyles } from './styles/ComponentStyles' | ||
import { CssReset } from './styles/CssReset' | ||
|
||
type OverlayState = { | ||
nextId: number | ||
buildError: string | null | ||
errors: SupportedErrorEvent[] | ||
} | ||
|
||
function reducer(state: OverlayState, ev: Bus.BusEvent): OverlayState { | ||
switch (ev.type) { | ||
case Bus.TYPE_BUILD_OK: { | ||
return { ...state, buildError: null } | ||
} | ||
case Bus.TYPE_BUILD_ERROR: { | ||
return { ...state, buildError: ev.message } | ||
} | ||
case Bus.TYPE_REFRESH: { | ||
return { ...state, buildError: null, errors: [] } | ||
} | ||
case Bus.TYPE_UNHANDLED_ERROR: | ||
case Bus.TYPE_UNHANDLED_REJECTION: { | ||
return { | ||
...state, | ||
nextId: state.nextId + 1, | ||
errors: [ | ||
...state.errors.filter((err) => { | ||
// Filter out duplicate errors | ||
return err.event.reason !== ev.reason | ||
}), | ||
{ id: state.nextId, event: ev }, | ||
], | ||
} | ||
} | ||
default: { | ||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
const _: never = ev | ||
return state | ||
} | ||
} | ||
} | ||
|
||
type ErrorType = 'runtime' | 'build' | ||
|
||
const shouldPreventDisplay = ( | ||
errorType?: ErrorType | null, | ||
preventType?: ErrorType[] | null | ||
) => { | ||
if (!preventType || !errorType) { | ||
return false | ||
} | ||
return preventType.includes(errorType) | ||
} | ||
|
||
type ReactDevOverlayProps = { | ||
children?: React.ReactNode | ||
preventDisplay?: ErrorType[] | ||
globalOverlay?: boolean | ||
} | ||
|
||
const ReactDevOverlay: React.FunctionComponent<ReactDevOverlayProps> = | ||
function ReactDevOverlay({ children, preventDisplay, globalOverlay }) { | ||
const [state, dispatch] = React.useReducer< | ||
React.Reducer<OverlayState, Bus.BusEvent> | ||
>(reducer, { | ||
nextId: 1, | ||
buildError: null, | ||
errors: [], | ||
}) | ||
|
||
React.useEffect(() => { | ||
Bus.on(dispatch) | ||
return function () { | ||
Bus.off(dispatch) | ||
} | ||
}, [dispatch]) | ||
|
||
const onComponentError = React.useCallback( | ||
(_error: Error, _componentStack: string | null) => { | ||
// TODO: special handling | ||
}, | ||
[] | ||
) | ||
|
||
const hasBuildError = state.buildError != null | ||
const hasRuntimeErrors = Boolean(state.errors.length) | ||
|
||
const isMounted = hasBuildError || hasRuntimeErrors | ||
|
||
return ( | ||
<React.Fragment> | ||
<ErrorBoundary | ||
globalOverlay={globalOverlay} | ||
isMounted={isMounted} | ||
onError={onComponentError} | ||
> | ||
{children ?? null} | ||
</ErrorBoundary> | ||
{isMounted ? ( | ||
<ShadowPortal globalOverlay={globalOverlay}> | ||
<CssReset /> | ||
<Base /> | ||
<ComponentStyles /> | ||
|
||
{shouldPreventDisplay( | ||
hasBuildError ? 'build' : hasRuntimeErrors ? 'runtime' : null, | ||
preventDisplay | ||
) ? null : hasBuildError ? ( | ||
<BuildError message={state.buildError!} /> | ||
) : hasRuntimeErrors ? ( | ||
<Errors errors={state.errors} /> | ||
) : undefined} | ||
</ShadowPortal> | ||
) : undefined} | ||
</React.Fragment> | ||
) | ||
} | ||
|
||
export default ReactDevOverlay |
76 changes: 76 additions & 0 deletions
76
packages/next/client/components/react-dev-overlay/src/internal/bus.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
import type { StackFrame } from 'next/dist/compiled/stacktrace-parser' | ||
|
||
export const TYPE_BUILD_OK = 'build-ok' | ||
export const TYPE_BUILD_ERROR = 'build-error' | ||
export const TYPE_REFRESH = 'fast-refresh' | ||
export const TYPE_UNHANDLED_ERROR = 'unhandled-error' | ||
export const TYPE_UNHANDLED_REJECTION = 'unhandled-rejection' | ||
|
||
export type BuildOk = { type: typeof TYPE_BUILD_OK } | ||
export type BuildError = { | ||
type: typeof TYPE_BUILD_ERROR | ||
message: string | ||
} | ||
export type FastRefresh = { type: typeof TYPE_REFRESH } | ||
export type UnhandledError = { | ||
type: typeof TYPE_UNHANDLED_ERROR | ||
reason: Error | ||
frames: StackFrame[] | ||
} | ||
export type UnhandledRejection = { | ||
type: typeof TYPE_UNHANDLED_REJECTION | ||
reason: Error | ||
frames: StackFrame[] | ||
} | ||
export type BusEvent = | ||
| BuildOk | ||
| BuildError | ||
| FastRefresh | ||
| UnhandledError | ||
| UnhandledRejection | ||
|
||
export type BusEventHandler = (ev: BusEvent) => void | ||
|
||
let handlers: Set<BusEventHandler> = new Set() | ||
let queue: BusEvent[] = [] | ||
|
||
function drain() { | ||
// Draining should never happen synchronously in case multiple handlers are | ||
// registered. | ||
setTimeout(function () { | ||
while ( | ||
// Until we are out of events: | ||
Boolean(queue.length) && | ||
// Or, if all handlers removed themselves as a result of handling the | ||
// event(s) | ||
Boolean(handlers.size) | ||
) { | ||
const ev = queue.shift()! | ||
handlers.forEach((handler) => handler(ev)) | ||
} | ||
}, 1) | ||
} | ||
|
||
export function emit(ev: BusEvent): void { | ||
queue.push(Object.freeze({ ...ev })) | ||
drain() | ||
} | ||
|
||
export function on(fn: BusEventHandler): boolean { | ||
if (handlers.has(fn)) { | ||
return false | ||
} | ||
|
||
handlers.add(fn) | ||
drain() | ||
return true | ||
} | ||
|
||
export function off(fn: BusEventHandler): boolean { | ||
if (handlers.has(fn)) { | ||
handlers.delete(fn) | ||
return true | ||
} | ||
|
||
return false | ||
} |
Oops, something went wrong.