Skip to content

Commit

Permalink
[DevOverlay] Decouple Error Overlay with DevTools Indicator (#74999)
Browse files Browse the repository at this point in the history
### Why?

As we rendered the error overlay OR dev indicator at the `container/errors` component, decouple the indicator from the overlay and let them have their own display state.

### How?

- Converted `app/react-dev-overlay` to a functional component and ported the error boundary logic into a component.
- Decoupled the display state for the DevTools Indicator and the Error Overlay.
  - Changed the display state as visible or invisible (prev: 'minimized' | 'fullscreen' | 'hidden').

#### Before

https://github.com/user-attachments/assets/024697ac-ba8e-409a-bf87-dcde1c97cf0c

#### After

https://github.com/user-attachments/assets/30c6e034-9c56-4852-8ec7-4e8f6ef793e5

Closes NDX-643
Closes NDX-669
  • Loading branch information
devjiwonchoi authored Jan 21, 2025
1 parent dc0f46c commit 352c315
Show file tree
Hide file tree
Showing 14 changed files with 597 additions and 424 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { GlobalErrorComponent } from '../../../error-boundary'

import { PureComponent } from 'react'
import { RuntimeErrorHandler } from '../../../errors/runtime-error-handler'

type DevToolsErrorBoundaryProps = {
children: React.ReactNode
onError: (value: boolean) => void
globalError: [GlobalErrorComponent, React.ReactNode]
}

type DevToolsErrorBoundaryState = {
isReactError: boolean
reactError: unknown
}

function ErroredHtml({
globalError: [GlobalError, globalErrorStyles],
error,
}: {
globalError: [GlobalErrorComponent, React.ReactNode]
error: unknown
}) {
if (!error) {
return (
<html>
<head />
<body />
</html>
)
}
return (
<>
{globalErrorStyles}
<GlobalError error={error} />
</>
)
}

export class DevToolsErrorBoundary extends PureComponent<
DevToolsErrorBoundaryProps,
DevToolsErrorBoundaryState
> {
state = { isReactError: false, reactError: null }

static getDerivedStateFromError(error: Error) {
if (!error.stack) {
return { isReactError: false, reactError: null }
}

RuntimeErrorHandler.hadRuntimeError = true

return {
isReactError: true,
reactError: error,
}
}

componentDidCatch() {
this.props.onError(this.state.isReactError)
}

render() {
const fallback = (
<ErroredHtml
globalError={this.props.globalError}
error={this.state.reactError}
/>
)

return this.state.isReactError ? fallback : this.props.children
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { Meta, StoryObj } from '@storybook/react'
import type { OverlayState } from '../../shared'

import ReactDevOverlay from './react-dev-overlay'
import { ACTION_UNHANDLED_ERROR } from '../../shared'

const meta: Meta<typeof ReactDevOverlay> = {
component: ReactDevOverlay,
parameters: {
layout: 'fullscreen',
},
}

export default meta
type Story = StoryObj<typeof ReactDevOverlay>

const state: OverlayState = {
nextId: 0,
buildError: null,
errors: [
{
id: 1,
event: {
type: ACTION_UNHANDLED_ERROR,
reason: Object.assign(new Error('First error message'), {
__NEXT_ERROR_CODE: 'E001',
}),
componentStackFrames: [
{
file: 'app/page.tsx',
component: 'Home',
lineNumber: 10,
column: 5,
canOpenInEditor: true,
},
],
frames: [
{
file: 'app/page.tsx',
methodName: 'Home',
arguments: [],
lineNumber: 10,
column: 5,
},
],
},
},
{
id: 2,
event: {
type: ACTION_UNHANDLED_ERROR,
reason: Object.assign(new Error('Second error message'), {
__NEXT_ERROR_CODE: 'E002',
}),
frames: [],
},
},
{
id: 3,
event: {
type: ACTION_UNHANDLED_ERROR,
reason: Object.assign(new Error('Third error message'), {
__NEXT_ERROR_CODE: 'E003',
}),
frames: [],
},
},
],
refreshState: { type: 'idle' },
rootLayoutMissingTags: [],
notFound: false,
staticIndicator: false,
debugInfo: { devtoolsFrontendUrl: undefined },
versionInfo: {
installed: '14.2.0',
staleness: 'fresh',
},
}

export const Default: Story = {
args: {
state,
children: <div>Application Content</div>,
},
}
Original file line number Diff line number Diff line change
@@ -1,115 +1,57 @@
import type { OverlayState } from '../../shared'
import type { Dispatcher } from '../../app/hot-reloader-client'

import React from 'react'
import type { GlobalErrorComponent } from '../../../error-boundary'

import { useState } from 'react'
import { DevToolsErrorBoundary } from './error-boundary'
import { ShadowPortal } from '../internal/components/shadow-portal'
import { BuildError } from '../internal/container/build-error'
import { Errors } from '../internal/container/errors'
import { Base } from '../internal/styles/base'
import { ComponentStyles } from '../internal/styles/component-styles'
import { CssReset } from '../internal/styles/css-reset'
import { RootLayoutMissingTagsError } from '../internal/container/root-layout-missing-tags-error'
import { RuntimeErrorHandler } from '../internal/helpers/runtime-error-handler'
import { Colors } from '../internal/styles/colors'
import type { GlobalErrorComponent } from '../../../error-boundary'

function ErroredHtml({
globalError: [GlobalError, globalErrorStyles],
error,
import { ErrorOverlay } from '../internal/components/errors/error-overlay/error-overlay'
import { DevToolsIndicator } from '../internal/components/errors/dev-tools-indicator/dev-tools-indicator'
import { useErrorHook } from '../internal/container/runtime-error/use-error-hook'

export default function ReactDevOverlay({
state,
globalError,
children,
}: {
state: OverlayState
globalError: [GlobalErrorComponent, React.ReactNode]
error: unknown
children: React.ReactNode
}) {
if (!error) {
return (
<html>
<head />
<body />
</html>
)
}
const [isErrorOverlayOpen, setIsErrorOverlayOpen] = useState(false)
const { readyErrors } = useErrorHook({ errors: state.errors, isAppDir: true })

return (
<>
{globalErrorStyles}
<GlobalError error={error} />
<DevToolsErrorBoundary
onError={setIsErrorOverlayOpen}
globalError={globalError}
>
{children}
</DevToolsErrorBoundary>

<ShadowPortal>
<CssReset />
<Base />
<Colors />
<ComponentStyles />

<DevToolsIndicator
state={state}
readyErrorsLength={readyErrors.length}
setIsErrorOverlayOpen={setIsErrorOverlayOpen}
/>

<ErrorOverlay
state={state}
readyErrors={readyErrors}
isErrorOverlayOpen={isErrorOverlayOpen}
setIsErrorOverlayOpen={setIsErrorOverlayOpen}
/>
</ShadowPortal>
</>
)
}

interface ReactDevOverlayState {
reactError?: unknown
isReactError: boolean
}
export default class ReactDevOverlay extends React.PureComponent<
{
state: OverlayState
dispatcher?: Dispatcher
globalError: [GlobalErrorComponent, React.ReactNode]
children: React.ReactNode
},
ReactDevOverlayState
> {
state = {
reactError: null,
isReactError: false,
}

static getDerivedStateFromError(error: Error): ReactDevOverlayState {
if (!error.stack) return { isReactError: false }

RuntimeErrorHandler.hadRuntimeError = true
return {
isReactError: true,
}
}

render() {
const { state, children, globalError } = this.props
const { isReactError, reactError } = this.state

const hasBuildError = state.buildError != null
const hasStaticIndicator = state.staticIndicator
const debugInfo = state.debugInfo

const isTurbopack = !!process.env.TURBOPACK

return (
<>
{isReactError ? (
<ErroredHtml globalError={globalError} error={reactError} />
) : (
children
)}
<ShadowPortal>
<CssReset />
<Base />
<Colors />
<ComponentStyles />
{state.rootLayoutMissingTags?.length ? (
<RootLayoutMissingTagsError
missingTags={state.rootLayoutMissingTags}
isTurbopack={isTurbopack}
/>
) : hasBuildError ? (
<BuildError
message={state.buildError!}
versionInfo={state.versionInfo}
isTurbopack={isTurbopack}
/>
) : (
<Errors
isTurbopack={isTurbopack}
isAppDir={true}
initialDisplayState={isReactError ? 'fullscreen' : 'minimized'}
errors={state.errors}
versionInfo={state.versionInfo}
hasStaticIndicator={hasStaticIndicator}
debugInfo={debugInfo}
/>
)}
</ShadowPortal>
</>
)
}
}
Loading

0 comments on commit 352c315

Please sign in to comment.