Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve error DX on pages with RSC build errors #52843

Merged
merged 13 commits into from Jul 20, 2023
1 change: 1 addition & 0 deletions packages/next/src/build/webpack-config.ts
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
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
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
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
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
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
@@ -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
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