Skip to content

Commit

Permalink
Handle development error when Server Component throws (#38550)
Browse files Browse the repository at this point in the history
Handles the case where an error is introduced which causes a Fast Refresh and then it's fixed.


## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm lint`
- [ ] The examples guidelines are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing.md#adding-examples)
  • Loading branch information
timneutkens committed Jul 12, 2022
1 parent c2b40d0 commit cd57098
Show file tree
Hide file tree
Showing 7 changed files with 93 additions and 31 deletions.
25 changes: 5 additions & 20 deletions packages/next/client/app-index.tsx
Expand Up @@ -212,19 +212,6 @@ function ServerRoot({ cacheKey }: { cacheKey: string }) {
return root
}

function ErrorOverlay({
children,
}: React.PropsWithChildren<{}>): React.ReactElement {
if (process.env.NODE_ENV === 'production') {
return <>{children}</>
} else {
const {
ReactDevOverlay,
} = require('next/dist/compiled/@next/react-dev-overlay/dist/client')
return <ReactDevOverlay globalOverlay>{children}</ReactDevOverlay>
}
}

function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement {
if (process.env.__NEXT_TEST_MODE) {
// eslint-disable-next-line react-hooks/rules-of-hooks
Expand All @@ -247,12 +234,10 @@ function RSCComponent() {

export function hydrate() {
renderReactElement(appElement!, () => (
<ErrorOverlay>
<React.StrictMode>
<Root>
<RSCComponent />
</Root>
</React.StrictMode>
</ErrorOverlay>
<React.StrictMode>
<Root>
<RSCComponent />
</Root>
</React.StrictMode>
))
}
15 changes: 14 additions & 1 deletion packages/next/client/components/app-router.client.tsx
Expand Up @@ -36,6 +36,19 @@ export function fetchServerResponse(
return createFromFetch(fetchFlight(url, flightRouterStateData))
}

function ErrorOverlay({
children,
}: React.PropsWithChildren<{}>): React.ReactElement {
if (process.env.NODE_ENV === 'production') {
return <>{children}</>
} else {
const {
ReactDevOverlay,
} = require('next/dist/compiled/@next/react-dev-overlay/dist/client')
return <ReactDevOverlay globalOverlay>{children}</ReactDevOverlay>
}
}

// TODO: move this back into AppRouter
let initialParallelRoutes: CacheNode['parallelRoutes'] =
typeof window === 'undefined' ? null! : new Map()
Expand Down Expand Up @@ -248,7 +261,7 @@ export default function AppRouter({
url: canonicalUrl,
}}
>
{cache.subTreeData}
<ErrorOverlay>{cache.subTreeData}</ErrorOverlay>
{hotReloader}
</AppTreeContext.Provider>
</AppRouterContext.Provider>
Expand Down
30 changes: 28 additions & 2 deletions packages/next/client/components/hot-reloader.client.tsx
@@ -1,7 +1,15 @@
import { useCallback, useContext, useEffect, useRef } from 'react'
import {
useCallback,
useContext,
useEffect,
useRef,
// @ts-expect-error TODO: startTransition exists
startTransition,
} from 'react'
import { FullAppTreeContext } from '../../shared/lib/app-router-context'
import {
register,
unregister,
onBuildError,
onBuildOk,
onRefresh,
Expand Down Expand Up @@ -305,7 +313,15 @@ function processMessage(
clientId: __nextDevClientId,
})
)
return router.reload()
if (hadRuntimeError) {
return window.location.reload()
}
startTransition(() => {
router.reload()
onRefresh()
})

return
}
case 'reloadPage': {
sendMessage(
Expand Down Expand Up @@ -393,6 +409,16 @@ export default function HotReload({ assetPrefix }: { assetPrefix: string }) {

useEffect(() => {
register()
const onError = () => {
hadRuntimeError = true
}
window.addEventListener('error', onError)
window.addEventListener('unhandledrejection', onError)
return () => {
unregister()
window.removeEventListener('error', onError)
window.removeEventListener('unhandledrejection', onError)
}
}, [])

useEffect(() => {
Expand Down
10 changes: 10 additions & 0 deletions packages/next/client/dev/error-overlay/hot-dev-client.js
Expand Up @@ -260,6 +260,16 @@ function processMessage(e) {
)
return handleSuccess()
}

case 'serverComponentChanges': {
sendMessage(
JSON.stringify({
event: 'server-component-reload-page',
clientId: window.__nextDevClientId,
})
)
return window.location.reload()
}
default: {
if (customHmrEventHandler) {
customHmrEventHandler(obj)
Expand Down
5 changes: 5 additions & 0 deletions packages/next/taskfile.js
Expand Up @@ -1982,6 +1982,11 @@ export default async function (task) {
opts
)
await task.watch('server/**/*.+(wasm)', 'server_wasm', opts)
await task.watch(
'../react-dev-overlay/dist/**/*.js',
'ncc_next__react_dev_overlay',
opts
)
}

export async function shared(task, opts) {
Expand Down
25 changes: 19 additions & 6 deletions packages/react-dev-overlay/src/internal/ErrorBoundary.tsx
Expand Up @@ -2,6 +2,8 @@ import React from 'react'

type ErrorBoundaryProps = {
onError: (error: Error, componentStack: string | null) => void
globalOverlay?: boolean
isMounted?: boolean
}
type ErrorBoundaryState = { error: Error | null }

Expand All @@ -10,22 +12,33 @@ class ErrorBoundary extends React.PureComponent<
ErrorBoundaryState
> {
state = { error: null }

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)
this.setState({ error })
if (!this.props.globalOverlay) {
this.setState({ error })
}
}

render() {
return this.state.error
? // The component has to be unmounted or else it would continue to error
null
: this.props.children
// 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
)
}
}

Expand Down
14 changes: 12 additions & 2 deletions packages/react-dev-overlay/src/internal/ReactDevOverlay.tsx
Expand Up @@ -31,7 +31,13 @@ function reducer(state: OverlayState, ev: Bus.BusEvent): OverlayState {
return {
...state,
nextId: state.nextId + 1,
errors: [...state.errors, { id: state.nextId, event: ev }],
errors: [
...state.errors.filter((err) => {
// Filter out duplicate errors
return err.event.reason !== ev.reason
}),
{ id: state.nextId, event: ev },
],
}
}
default: {
Expand Down Expand Up @@ -82,7 +88,11 @@ const ReactDevOverlay: React.FunctionComponent = function ReactDevOverlay({

return (
<React.Fragment>
<ErrorBoundary onError={onComponentError}>
<ErrorBoundary
globalOverlay={globalOverlay}
isMounted={isMounted}
onError={onComponentError}
>
{children ?? null}
</ErrorBoundary>
{isMounted ? (
Expand Down

0 comments on commit cd57098

Please sign in to comment.