Skip to content

Commit

Permalink
[DevOverlay] Decouple Error Overlay with DevTools Indicator
Browse files Browse the repository at this point in the history
  • Loading branch information
devjiwonchoi committed Jan 18, 2025
1 parent 386a89b commit db209da
Show file tree
Hide file tree
Showing 13 changed files with 589 additions and 400 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { PureComponent } from 'react'
import { RuntimeErrorHandler } from '../internal/helpers/runtime-error-handler'

type ReactDevOverlayProps = {
children: React.ReactNode[]
onError: (value: boolean) => void
}

type ReactDevOverlayState = {
isReactError: boolean
}

export class ErrorBoundary extends PureComponent<
ReactDevOverlayProps,
ReactDevOverlayState
> {
state = { isReactError: false }

componentDidUpdate(
_prevProps: ReactDevOverlayProps,
prevState: ReactDevOverlayState
) {
if (prevState.isReactError !== this.state.isReactError) {
this.props.onError(this.state.isReactError)
}
}

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

RuntimeErrorHandler.hadRuntimeError = true

return {
isReactError: true,
}
}

render() {
const { children } = this.props
const [content, devtools] = children

const fallback = (
<html>
<head></head>
<body></body>
</html>
)

return (
<>
{this.state.isReactError ? fallback : content}
{devtools}
</>
)
}
}
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,89 +1,48 @@
import type { OverlayState } from '../../shared'
import type { Dispatcher } from '../../app/hot-reloader-client'

import React from 'react'

import { useState } from 'react'
import { ErrorBoundary } 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'

interface ReactDevOverlayState {
isReactError: boolean
}
export default class ReactDevOverlay extends React.PureComponent<
{
state: OverlayState
dispatcher?: Dispatcher
children: React.ReactNode
},
ReactDevOverlayState
> {
state = { isReactError: false }

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

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

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

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

const isTurbopack = !!process.env.TURBOPACK

return (
<>
{isReactError ? (
<html>
<head></head>
<body></body>
</html>
) : (
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>
</>
)
}
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,
children,
}: {
state: OverlayState
children: React.ReactNode
}) {
const [isErrorOverlayOpen, setIsErrorOverlayOpen] = useState(false)
const { readyErrors } = useErrorHook({ errors: state.errors, isAppDir: true })

return (
<ErrorBoundary onError={setIsErrorOverlayOpen}>
{children}

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

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

<ErrorOverlay
state={state}
readyErrors={readyErrors}
isErrorOverlayOpen={isErrorOverlayOpen}
setIsErrorOverlayOpen={setIsErrorOverlayOpen}
/>
</ShadowPortal>
</ErrorBoundary>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'
import { DevToolsIndicator } from './dev-tools-indicator'
import { withShadowPortal } from '../../../storybook/with-shadow-portal'
import type { VersionInfo } from '../../../../../../../../server/dev/parse-version-info'
import type { OverlayState } from '../../../../../shared'

const meta: Meta<typeof DevToolsIndicator> = {
component: DevToolsIndicator,
Expand Down Expand Up @@ -42,65 +43,51 @@ const mockVersionInfo: VersionInfo = {
staleness: 'stale-major',
}

// Mock error for stories
const mockError = {
id: 1,
runtime: true as const,
error: new Error('Test error'),
frames: [
{
error: true,
reason: null,
external: false,
ignored: false,
sourceStackFrame: {
file: 'test.js',
methodName: '<unknown>',
arguments: [],
lineNumber: 1,
column: 1,
},
},
],
const state: OverlayState = {
nextId: 1,
buildError: null,
errors: [],
refreshState: { type: 'idle' },
rootLayoutMissingTags: [],
versionInfo: mockVersionInfo,
notFound: false,
staticIndicator: false,
debugInfo: { devtoolsFrontendUrl: undefined },
}

export const NoErrors: Story = {
args: {
hasStaticIndicator: false,
readyErrors: [],
fullscreen: () => console.log('Fullscreen clicked'),
hide: () => console.log('Hide clicked'),
versionInfo: mockVersionInfo,
isTurbopack: false,
readyErrorsLength: 0,
state,
setIsErrorOverlayOpen: () => {},
},
}

export const SingleError: Story = {
args: {
hasStaticIndicator: false,
readyErrors: [mockError],
fullscreen: () => console.log('Fullscreen clicked'),
hide: () => console.log('Hide clicked'),
versionInfo: mockVersionInfo,
readyErrorsLength: 1,
state,
setIsErrorOverlayOpen: () => {},
},
}

export const MultipleErrors: Story = {
args: {
hasStaticIndicator: false,
readyErrors: [mockError, { ...mockError, id: 2 }, { ...mockError, id: 3 }],
fullscreen: () => console.log('Fullscreen clicked'),
hide: () => console.log('Hide clicked'),
versionInfo: mockVersionInfo,
readyErrorsLength: 3,
state,
setIsErrorOverlayOpen: () => {},
},
}

export const WithStaticIndicator: Story = {
args: {
hasStaticIndicator: true,
readyErrors: [mockError],
fullscreen: () => console.log('Fullscreen clicked'),
hide: () => console.log('Hide clicked'),
versionInfo: mockVersionInfo,
readyErrorsLength: 3,
state: {
...state,
staticIndicator: true,
},
setIsErrorOverlayOpen: () => {
console.log('setIsErrorOverlayOpen called')
},
},
}
Loading

0 comments on commit db209da

Please sign in to comment.