Skip to content

Commit

Permalink
improve error DX on pages with RSC build errors (#52843)
Browse files Browse the repository at this point in the history
### What?
- Visiting a page in the app router without a proper component export doesn't show the dev overlay, but logs errors to the console
- When it does show the error overlay (e.g. during an HMR event), the error message was sharing the module code itself rather than the component path, making it hard to debug

### Why?
`createComponentTree` can throw these errors before the AppRouter tree is mounted, leaving the errors uncaught by the dev overlay.

### How?
This wraps the server root in the `ReactDevOverlay` when in dev mode with a minimal "HMR" for when the server component is edited (to reload the page).

Closes NEXT-308
  • Loading branch information
ztanner committed Jul 20, 2023
1 parent 3e821ef commit 4994a42
Show file tree
Hide file tree
Showing 13 changed files with 99 additions and 13 deletions.
1 change: 1 addition & 0 deletions packages/next/src/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ export function getDefineEnv({
'process.env.__NEXT_WEB_VITALS_ATTRIBUTION': JSON.stringify(
config.experimental.webVitalsAttribution
),
'process.env.__NEXT_ASSET_PREFIX': JSON.stringify(config.assetPrefix),
...(isNodeServer || isEdgeServer
? {
// Fix bad-actors in the npm ecosystem (e.g. `node-formidable`)
Expand Down
59 changes: 59 additions & 0 deletions packages/next/src/client/app-index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ const StrictModeIfEnabled = process.env.__NEXT_STRICT_MODE_APP
: React.Fragment

function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement {
const [hadRuntimeError, setHadRuntimeError] = React.useState(false)

if (process.env.__NEXT_ANALYTICS_ID) {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
Expand All @@ -244,6 +246,63 @@ function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement {
}, [])
}

if (process.env.NODE_ENV !== 'production') {
const ReactDevOverlay: typeof import('./components/react-dev-overlay/internal/ReactDevOverlay').default =
require('./components/react-dev-overlay/internal/ReactDevOverlay')
.default as typeof import('./components/react-dev-overlay/internal/ReactDevOverlay').default

const INITIAL_OVERLAY_STATE: typeof import('./components/react-dev-overlay/internal/error-overlay-reducer').INITIAL_OVERLAY_STATE =
require('./components/react-dev-overlay/internal/error-overlay-reducer').INITIAL_OVERLAY_STATE

const useWebsocket: typeof import('./components/react-dev-overlay/internal/helpers/use-websocket').useWebsocket =
require('./components/react-dev-overlay/internal/helpers/use-websocket').useWebsocket

// subscribe to hmr only if an error was captured, so that we don't have two hmr websockets active
// eslint-disable-next-line react-hooks/rules-of-hooks
const webSocketRef = useWebsocket(
process.env.__NEXT_ASSET_PREFIX || '',
hadRuntimeError
)

// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
const handler = (event: MessageEvent) => {
let obj
try {
obj = JSON.parse(event.data)
} catch {}

if (!obj || !('action' in obj)) {
return
}

// minimal "hot reload" support for RSC errors
if (obj.action === 'serverComponentChanges' && hadRuntimeError) {
window.location.reload()
}
}

const websocket = webSocketRef.current
if (websocket) {
websocket.addEventListener('message', handler)
}

return () =>
websocket && websocket.removeEventListener('message', handler)
}, [webSocketRef, hadRuntimeError])

// if an error is thrown while rendering an RSC stream, this will catch it in dev
// and show the error overlay
return (
<ReactDevOverlay
state={INITIAL_OVERLAY_STATE}
onReactError={() => setHadRuntimeError(true)}
>
{children}
</ReactDevOverlay>
)
}

return children as React.ReactElement
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useRouter } from '../navigation'
import {
ACTION_NOT_FOUND,
ACTION_VERSION_INFO,
INITIAL_OVERLAY_STATE,
errorOverlayReducer,
} from './internal/error-overlay-reducer'
import {
Expand Down Expand Up @@ -459,14 +460,10 @@ export default function HotReload({
notFoundStyles?: React.ReactNode
asNotFound?: boolean
}) {
const [state, dispatch] = useReducer(errorOverlayReducer, {
nextId: 1,
buildError: null,
errors: [],
notFound: false,
refreshState: { type: 'idle' },
versionInfo: { installed: '0.0.0', staleness: 'unknown' },
})
const [state, dispatch] = useReducer(
errorOverlayReducer,
INITIAL_OVERLAY_STATE
)
const dispatcher = useMemo((): Dispatcher => {
return {
onBuildOk() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export const ACTION_UNHANDLED_ERROR = 'unhandled-error'
export const ACTION_UNHANDLED_REJECTION = 'unhandled-rejection'
export const ACTION_VERSION_INFO = 'version-info'
export const ACTION_NOT_FOUND = 'not-found'
export const INITIAL_OVERLAY_STATE: OverlayState = {
nextId: 1,
buildError: null,
errors: [],
notFound: false,
refreshState: { type: 'idle' },
versionInfo: { installed: '0.0.0', staleness: 'unknown' },
}

interface BuildOkAction {
type: typeof ACTION_BUILD_OK
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { useCallback, useContext, useEffect, useRef } from 'react'
import { GlobalLayoutRouterContext } from '../../../../../shared/lib/app-router-context'
import { getSocketProtocol } from './get-socket-protocol'

export function useWebsocket(assetPrefix: string) {
export function useWebsocket(
assetPrefix: string,
shouldSubscribe: boolean = true
) {
const webSocketRef = useRef<WebSocket>()

useEffect(() => {
if (webSocketRef.current) {
if (webSocketRef.current || !shouldSubscribe) {
return
}

Expand All @@ -23,7 +26,7 @@ export function useWebsocket(assetPrefix: string) {
}

webSocketRef.current = new window.WebSocket(`${url}/_next/webpack-hmr`)
}, [assetPrefix])
}, [assetPrefix, shouldSubscribe])

return webSocketRef
}
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ export async function renderToHTMLOrFlight(
!isValidElementType(Component)
) {
throw new Error(
`The default export is not a React Component in page: "${page}"`
`The default export is not a React Component in page: "${pagePath}"`
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function Page() {
return <p>Page</p>
}
17 changes: 16 additions & 1 deletion test/development/acceptance-app/rsc-build-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,22 @@ describe('Error overlay - RSC build errors', () => {

expect(await session.hasRedbox(true)).toBe(true)
expect(await session.getRedboxDescription()).toInclude(
'The default export is not a React Component in page:'
'The default export is not a React Component in page: "/server-with-errors/page-export"'
)

await cleanup()
})

it('should error when page component export is not valid on initial load', async () => {
const { session, cleanup } = await sandbox(
next,
undefined,
'/server-with-errors/page-export-initial-error'
)

expect(await session.hasRedbox(true)).toBe(true)
expect(await session.getRedboxDescription()).toInclude(
'The default export is not a React Component in page: "/server-with-errors/page-export-initial-error"'
)

await cleanup()
Expand Down

0 comments on commit 4994a42

Please sign in to comment.