rsnext/packages/next/client/components/react-dev-overlay/hot-reloader-client.tsx
Hannes Bornö 04c2509daa
Fast refresh should recover from event handler errors in app dir (#43882)
Component state should not be lost due to a full reload after an error occurs in an event handler. Only do a full reload if an error was caught by the error overlay error boundary.

Closes NEXT-182

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/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`
- [ ] [e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs) tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm build && pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
2022-12-13 04:48:18 +00:00

478 lines
13 KiB
TypeScript

import type { ReactNode } from 'react'
import React, {
useCallback,
useEffect,
useReducer,
useMemo,
// @ts-expect-error TODO-APP: startTransition exists
startTransition,
} from 'react'
import stripAnsi from 'next/dist/compiled/strip-ansi'
import formatWebpackMessages from '../../dev/error-overlay/format-webpack-messages'
import { useRouter } from '../navigation'
import { errorOverlayReducer } from './internal/error-overlay-reducer'
import {
ACTION_BUILD_OK,
ACTION_BUILD_ERROR,
ACTION_BEFORE_REFRESH,
ACTION_REFRESH,
ACTION_UNHANDLED_ERROR,
ACTION_UNHANDLED_REJECTION,
} from './internal/error-overlay-reducer'
import { parseStack } from './internal/helpers/parseStack'
import ReactDevOverlay from './internal/ReactDevOverlay'
import {
RuntimeErrorHandler,
useErrorHandler,
} from './internal/helpers/use-error-handler'
import {
useSendMessage,
useWebsocket,
useWebsocketPing,
} from './internal/helpers/use-websocket'
interface Dispatcher {
onBuildOk(): void
onBuildError(message: string): void
onBeforeRefresh(): void
onRefresh(): void
}
// TODO-APP: add actual type
type PongEvent = any
let mostRecentCompilationHash: any = null
let __nextDevClientId = Math.round(Math.random() * 100 + Date.now())
// let startLatency = undefined
function onBeforeFastRefresh(dispatcher: Dispatcher, hasUpdates: boolean) {
if (hasUpdates) {
dispatcher.onBeforeRefresh()
}
}
function onFastRefresh(dispatcher: Dispatcher, hasUpdates: boolean) {
dispatcher.onBuildOk()
if (hasUpdates) {
dispatcher.onRefresh()
}
}
// There is a newer version of the code available.
function handleAvailableHash(hash: string) {
// Update last known compilation hash.
mostRecentCompilationHash = hash
}
// Is there a newer version of this code available?
function isUpdateAvailable() {
/* globals __webpack_hash__ */
// __webpack_hash__ is the hash of the current compilation.
// It's a global variable injected by Webpack.
// @ts-expect-error __webpack_hash__ exists
return mostRecentCompilationHash !== __webpack_hash__
}
// Webpack disallows updates in other states.
function canApplyUpdates() {
// @ts-expect-error module.hot exists
return module.hot.status() === 'idle'
}
function afterApplyUpdates(fn: any) {
if (canApplyUpdates()) {
fn()
} else {
function handler(status: any) {
if (status === 'idle') {
// @ts-expect-error module.hot exists
module.hot.removeStatusHandler(handler)
fn()
}
}
// @ts-expect-error module.hot exists
module.hot.addStatusHandler(handler)
}
}
function performFullReload(err: any, sendMessage: any) {
const stackTrace =
err &&
((err.stack && err.stack.split('\n').slice(0, 5).join('\n')) ||
err.message ||
err + '')
sendMessage(
JSON.stringify({
event: 'client-full-reload',
stackTrace,
hadRuntimeError: !!RuntimeErrorHandler.hadRuntimeError,
})
)
window.location.reload()
}
// Attempt to update code on the fly, fall back to a hard reload.
function tryApplyUpdates(
onBeforeUpdate: (hasUpdates: boolean) => void,
onHotUpdateSuccess: (hasUpdates: boolean) => void,
sendMessage: any,
dispatcher: Dispatcher
) {
if (!isUpdateAvailable() || !canApplyUpdates()) {
dispatcher.onBuildOk()
return
}
function handleApplyUpdates(err: any, updatedModules: any[] | null) {
if (err || RuntimeErrorHandler.hadRuntimeError || !updatedModules) {
if (err) {
console.warn(
'[Fast Refresh] performing full reload\n\n' +
"Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.\n" +
'You might have a file which exports a React component but also exports a value that is imported by a non-React component file.\n' +
'Consider migrating the non-React component export to a separate file and importing it into both files.\n\n' +
'It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.\n' +
'Fast Refresh requires at least one parent function component in your React tree.'
)
} else if (RuntimeErrorHandler.hadRuntimeError) {
console.warn(
'[Fast Refresh] performing full reload because your application had an unrecoverable error'
)
}
performFullReload(err, sendMessage)
return
}
const hasUpdates = Boolean(updatedModules.length)
if (typeof onHotUpdateSuccess === 'function') {
// Maybe we want to do something.
onHotUpdateSuccess(hasUpdates)
}
if (isUpdateAvailable()) {
// While we were updating, there was a new update! Do it again.
tryApplyUpdates(
hasUpdates ? () => {} : onBeforeUpdate,
hasUpdates ? () => dispatcher.onBuildOk() : onHotUpdateSuccess,
sendMessage,
dispatcher
)
} else {
dispatcher.onBuildOk()
if (process.env.__NEXT_TEST_MODE) {
afterApplyUpdates(() => {
if (self.__NEXT_HMR_CB) {
self.__NEXT_HMR_CB()
self.__NEXT_HMR_CB = null
}
})
}
}
}
// https://webpack.js.org/api/hot-module-replacement/#check
// @ts-expect-error module.hot exists
module.hot
.check(/* autoApply */ false)
.then((updatedModules: any[] | null) => {
if (!updatedModules) {
return null
}
if (typeof onBeforeUpdate === 'function') {
const hasUpdates = Boolean(updatedModules.length)
onBeforeUpdate(hasUpdates)
}
// https://webpack.js.org/api/hot-module-replacement/#apply
// @ts-expect-error module.hot exists
return module.hot.apply()
})
.then(
(updatedModules: any[] | null) => {
handleApplyUpdates(null, updatedModules)
},
(err: any) => {
handleApplyUpdates(err, null)
}
)
}
function processMessage(
e: any,
sendMessage: any,
router: ReturnType<typeof useRouter>,
dispatcher: Dispatcher
) {
const obj = JSON.parse(e.data)
switch (obj.action) {
case 'building': {
console.log('[Fast Refresh] rebuilding')
break
}
case 'built':
case 'sync': {
if (obj.hash) {
handleAvailableHash(obj.hash)
}
const { errors, warnings } = obj
const hasErrors = Boolean(errors && errors.length)
// Compilation with errors (e.g. syntax error or missing modules).
if (hasErrors) {
sendMessage(
JSON.stringify({
event: 'client-error',
errorCount: errors.length,
clientId: __nextDevClientId,
})
)
// "Massage" webpack messages.
var formatted = formatWebpackMessages({
errors: errors,
warnings: [],
})
// Only show the first error.
dispatcher.onBuildError(formatted.errors[0])
// Also log them to the console.
for (let i = 0; i < formatted.errors.length; i++) {
console.error(stripAnsi(formatted.errors[i]))
}
// Do not attempt to reload now.
// We will reload on next success instead.
if (process.env.__NEXT_TEST_MODE) {
if (self.__NEXT_HMR_CB) {
self.__NEXT_HMR_CB(formatted.errors[0])
self.__NEXT_HMR_CB = null
}
}
return
}
const hasWarnings = Boolean(warnings && warnings.length)
if (hasWarnings) {
sendMessage(
JSON.stringify({
event: 'client-warning',
warningCount: warnings.length,
clientId: __nextDevClientId,
})
)
// Compilation with warnings (e.g. ESLint).
const isHotUpdate = obj.action !== 'sync'
// Print warnings to the console.
const formattedMessages = formatWebpackMessages({
warnings: warnings,
errors: [],
})
for (let i = 0; i < formattedMessages.warnings.length; i++) {
if (i === 5) {
console.warn(
'There were more warnings in other files.\n' +
'You can find a complete log in the terminal.'
)
break
}
console.warn(stripAnsi(formattedMessages.warnings[i]))
}
// Attempt to apply hot updates or reload.
if (isHotUpdate) {
tryApplyUpdates(
function onBeforeHotUpdate(hasUpdates: boolean) {
onBeforeFastRefresh(dispatcher, hasUpdates)
},
function onSuccessfulHotUpdate(hasUpdates: any) {
// Only dismiss it when we're sure it's a hot update.
// Otherwise it would flicker right before the reload.
onFastRefresh(dispatcher, hasUpdates)
},
sendMessage,
dispatcher
)
}
return
}
sendMessage(
JSON.stringify({
event: 'client-success',
clientId: __nextDevClientId,
})
)
const isHotUpdate =
obj.action !== 'sync' ||
((!window.__NEXT_DATA__ || window.__NEXT_DATA__.page !== '/_error') &&
isUpdateAvailable())
// Attempt to apply hot updates or reload.
if (isHotUpdate) {
tryApplyUpdates(
function onBeforeHotUpdate(hasUpdates: boolean) {
onBeforeFastRefresh(dispatcher, hasUpdates)
},
function onSuccessfulHotUpdate(hasUpdates: any) {
// Only dismiss it when we're sure it's a hot update.
// Otherwise it would flicker right before the reload.
onFastRefresh(dispatcher, hasUpdates)
},
sendMessage,
dispatcher
)
}
return
}
// TODO-APP: make server component change more granular
case 'serverComponentChanges': {
sendMessage(
JSON.stringify({
event: 'server-component-reload-page',
clientId: __nextDevClientId,
})
)
if (RuntimeErrorHandler.hadRuntimeError) {
return window.location.reload()
}
startTransition(() => {
router.refresh()
dispatcher.onRefresh()
})
if (process.env.__NEXT_TEST_MODE) {
if (self.__NEXT_HMR_CB) {
self.__NEXT_HMR_CB()
self.__NEXT_HMR_CB = null
}
}
return
}
case 'reloadPage': {
sendMessage(
JSON.stringify({
event: 'client-reload-page',
clientId: __nextDevClientId,
})
)
return window.location.reload()
}
case 'removedPage': {
// TODO-APP: potentially only refresh if the currently viewed page was removed.
router.refresh()
return
}
case 'addedPage': {
// TODO-APP: potentially only refresh if the currently viewed page was added.
router.refresh()
return
}
case 'pong': {
const { invalid } = obj
if (invalid) {
// Payload can be invalid even if the page does exist.
// So, we check if it can be created.
router.refresh()
}
return
}
default: {
throw new Error('Unexpected action ' + obj.action)
}
}
}
export default function HotReload({
assetPrefix,
children,
}: {
assetPrefix: string
children?: ReactNode
}) {
const [state, dispatch] = useReducer(errorOverlayReducer, {
nextId: 1,
buildError: null,
errors: [],
refreshState: { type: 'idle' },
})
const dispatcher = useMemo((): Dispatcher => {
return {
onBuildOk(): void {
dispatch({ type: ACTION_BUILD_OK })
},
onBuildError(message: string): void {
dispatch({ type: ACTION_BUILD_ERROR, message })
},
onBeforeRefresh(): void {
dispatch({ type: ACTION_BEFORE_REFRESH })
},
onRefresh(): void {
dispatch({ type: ACTION_REFRESH })
},
}
}, [dispatch])
const handleOnUnhandledError = useCallback((error: Error): void => {
dispatch({
type: ACTION_UNHANDLED_ERROR,
reason: error,
frames: parseStack(error.stack!),
})
}, [])
const handleOnUnhandledRejection = useCallback((reason: Error): void => {
dispatch({
type: ACTION_UNHANDLED_REJECTION,
reason: reason,
frames: parseStack(reason.stack!),
})
}, [])
const handleOnReactError = useCallback(() => {
RuntimeErrorHandler.hadRuntimeError = true
}, [])
useErrorHandler(handleOnUnhandledError, handleOnUnhandledRejection)
const webSocketRef = useWebsocket(assetPrefix)
useWebsocketPing(webSocketRef)
const sendMessage = useSendMessage(webSocketRef)
const router = useRouter()
useEffect(() => {
const handler = (event: MessageEvent<PongEvent>) => {
if (
event.data.indexOf('action') === -1 &&
// TODO-APP: clean this up for consistency
event.data.indexOf('pong') === -1
) {
return
}
try {
processMessage(event, sendMessage, router, dispatcher)
} catch (ex) {
console.warn('Invalid HMR message: ' + event.data + '\n', ex)
}
}
const websocket = webSocketRef.current
if (websocket) {
websocket.addEventListener('message', handler)
}
return () => websocket && websocket.removeEventListener('message', handler)
}, [sendMessage, router, webSocketRef, dispatcher])
return (
<ReactDevOverlay onReactError={handleOnReactError} state={state}>
{children}
</ReactDevOverlay>
)
}