Skip to content

Commit

Permalink
Aggregate updates using addStatusHandler and Promise.resolve inst…
Browse files Browse the repository at this point in the history
…ead of `setTimeout` (#42350)

The current `setTimeout` logic adds a constant overhead of 30ms when applying updates, which slows down HMR. As @sokra suggested, we can use the `addStatusHandler` API to have the HMR runtime let us know when its status changes. This also switches to `Promise.resolve` for update aggregation.
  • Loading branch information
alexkirsz committed Nov 7, 2022
1 parent 5b5e422 commit 7bbc1ae
Show file tree
Hide file tree
Showing 8 changed files with 407 additions and 168 deletions.
Expand Up @@ -14,13 +14,17 @@ 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 { useErrorHandler } from './internal/helpers/use-error-handler'
import {
RuntimeErrorHandler,
useErrorHandler,
} from './internal/helpers/use-error-handler'
import {
useSendMessage,
useWebsocket,
Expand All @@ -30,6 +34,7 @@ import {
interface Dispatcher {
onBuildOk(): void
onBuildError(message: string): void
onBeforeRefresh(): void
onRefresh(): void
}

Expand All @@ -38,10 +43,15 @@ type PongEvent = any

let mostRecentCompilationHash: any = null
let __nextDevClientId = Math.round(Math.random() * 100 + Date.now())
let hadRuntimeError = false

// let startLatency = undefined

function onBeforeFastRefresh(dispatcher: Dispatcher, hasUpdates: boolean) {
if (hasUpdates) {
dispatcher.onBeforeRefresh()
}
}

function onFastRefresh(dispatcher: Dispatcher, hasUpdates: boolean) {
dispatcher.onBuildOk()
if (hasUpdates) {
Expand Down Expand Up @@ -104,6 +114,7 @@ function performFullReload(err: any, sendMessage: any) {

// 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
Expand All @@ -114,7 +125,7 @@ function tryApplyUpdates(
}

function handleApplyUpdates(err: any, updatedModules: any) {
if (err || hadRuntimeError || !updatedModules) {
if (err || RuntimeErrorHandler.hadRuntimeError || !updatedModules) {
if (err) {
console.warn(
'[Fast Refresh] performing full reload\n\n' +
Expand All @@ -124,7 +135,7 @@ function tryApplyUpdates(
'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 (hadRuntimeError) {
} else if (RuntimeErrorHandler.hadRuntimeError) {
console.warn(
'[Fast Refresh] performing full reload because your application had an unrecoverable error'
)
Expand All @@ -142,6 +153,7 @@ function tryApplyUpdates(
if (isUpdateAvailable()) {
// While we were updating, there was a new update! Do it again.
tryApplyUpdates(
hasUpdates ? () => {} : onBeforeUpdate,
hasUpdates ? () => dispatcher.onBuildOk() : onHotUpdateSuccess,
sendMessage,
dispatcher
Expand All @@ -161,14 +173,25 @@ function tryApplyUpdates(

// https://webpack.js.org/api/hot-module-replacement/#check
// @ts-expect-error module.hot exists
module.hot.check(/* autoApply */ true).then(
(updatedModules: any) => {
handleApplyUpdates(null, updatedModules)
},
(err: any) => {
handleApplyUpdates(err, null)
}
)
module.hot
.check(/* autoApply */ false)
.then((updatedModules: any) => {
const hasUpdates = Boolean(updatedModules.length)
if (typeof onBeforeUpdate === 'function') {
onBeforeUpdate(hasUpdates)
}
// https://webpack.js.org/api/hot-module-replacement/#apply
// @ts-expect-error module.hot exists
return module.hot.apply()
})
.then(
(updatedModules: any) => {
handleApplyUpdates(null, updatedModules)
},
(err: any) => {
handleApplyUpdates(err, null)
}
)
}

function processMessage(
Expand Down Expand Up @@ -260,6 +283,9 @@ function processMessage(
// 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.
Expand Down Expand Up @@ -287,6 +313,9 @@ function processMessage(
// 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.
Expand All @@ -306,7 +335,7 @@ function processMessage(
clientId: __nextDevClientId,
})
)
if (hadRuntimeError) {
if (RuntimeErrorHandler.hadRuntimeError) {
return window.location.reload()
}
startTransition(() => {
Expand Down Expand Up @@ -361,6 +390,7 @@ export default function HotReload({
nextId: 1,
buildError: null,
errors: [],
refreshState: { type: 'idle' },
})
const dispatcher = useMemo((): Dispatcher => {
return {
Expand All @@ -370,72 +400,29 @@ export default function HotReload({
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(
(ev: WindowEventMap['error']): void => {
if (
ev.error &&
ev.error.digest &&
(ev.error.digest.startsWith('NEXT_REDIRECT') ||
ev.error.digest === 'NEXT_NOT_FOUND')
) {
ev.preventDefault()
return
}

hadRuntimeError = true
const error = ev?.error
if (
!error ||
!(error instanceof Error) ||
typeof error.stack !== 'string'
) {
// A non-error was thrown, we don't have anything to show. :-(
return
}

if (
error.message.match(/(hydration|content does not match|did not match)/i)
) {
error.message += `\n\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error`
}

const e = error
dispatch({
type: ACTION_UNHANDLED_ERROR,
reason: error,
frames: parseStack(e.stack!),
})
},
[]
)
const handleOnUnhandledRejection = useCallback(
(ev: WindowEventMap['unhandledrejection']): void => {
hadRuntimeError = true
const reason = ev?.reason
if (
!reason ||
!(reason instanceof Error) ||
typeof reason.stack !== 'string'
) {
// A non-error was thrown, we don't have anything to show. :-(
return
}

const e = reason
dispatch({
type: ACTION_UNHANDLED_REJECTION,
reason: reason,
frames: parseStack(e.stack!),
})
},
[]
)
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!),
})
}, [])
useErrorHandler(handleOnUnhandledError, handleOnUnhandledRejection)

const webSocketRef = useWebsocket(assetPrefix)
Expand Down
Expand Up @@ -3,6 +3,7 @@ import { SupportedErrorEvent } from './container/Errors'

export const ACTION_BUILD_OK = 'build-ok'
export const ACTION_BUILD_ERROR = 'build-error'
export const ACTION_BEFORE_REFRESH = 'before-fast-refresh'
export const ACTION_REFRESH = 'fast-refresh'
export const ACTION_UNHANDLED_ERROR = 'unhandled-error'
export const ACTION_UNHANDLED_REJECTION = 'unhandled-rejection'
Expand All @@ -14,6 +15,9 @@ interface BuildErrorAction {
type: typeof ACTION_BUILD_ERROR
message: string
}
interface BeforeFastRefreshAction {
type: typeof ACTION_BEFORE_REFRESH
}
interface FastRefreshAction {
type: typeof ACTION_REFRESH
}
Expand All @@ -28,20 +32,44 @@ export interface UnhandledRejectionAction {
frames: StackFrame[]
}

export type FastRefreshState =
| {
type: 'idle'
}
| {
type: 'pending'
errors: SupportedErrorEvent[]
}

export interface OverlayState {
nextId: number
buildError: string | null
errors: SupportedErrorEvent[]
rootLayoutMissingTagsError?: {
missingTags: string[]
}
refreshState: FastRefreshState
}

function pushErrorFilterDuplicates(
errors: SupportedErrorEvent[],
err: SupportedErrorEvent
): SupportedErrorEvent[] {
return [
...errors.filter((e) => {
// Filter out duplicate errors
return e.event.reason !== err.event.reason
}),
err,
]
}

export function errorOverlayReducer(
state: Readonly<OverlayState>,
action: Readonly<
| BuildOkAction
| BuildErrorAction
| BeforeFastRefreshAction
| FastRefreshAction
| UnhandledErrorAction
| UnhandledRejectionAction
Expand All @@ -54,21 +82,56 @@ export function errorOverlayReducer(
case ACTION_BUILD_ERROR: {
return { ...state, buildError: action.message }
}
case ACTION_BEFORE_REFRESH: {
return { ...state, refreshState: { type: 'pending', errors: [] } }
}
case ACTION_REFRESH: {
return { ...state, buildError: null, errors: [] }
return {
...state,
buildError: null,
errors:
// Errors can come in during updates. In this case, UNHANDLED_ERROR
// and UNHANDLED_REJECTION events might be dispatched between the
// BEFORE_REFRESH and the REFRESH event. We want to keep those errors
// around until the next refresh. Otherwise we run into a race
// condition where those errors would be cleared on refresh completion
// before they can be displayed.
state.refreshState.type === 'pending'
? state.refreshState.errors
: [],
refreshState: { type: 'idle' },
}
}
case ACTION_UNHANDLED_ERROR:
case ACTION_UNHANDLED_REJECTION: {
return {
...state,
nextId: state.nextId + 1,
errors: [
...state.errors.filter((err) => {
// Filter out duplicate errors
return err.event.reason !== action.reason
}),
{ id: state.nextId, event: action },
],
switch (state.refreshState.type) {
case 'idle': {
return {
...state,
nextId: state.nextId + 1,
errors: pushErrorFilterDuplicates(state.errors, {
id: state.nextId,
event: action,
}),
}
}
case 'pending': {
return {
...state,
nextId: state.nextId + 1,
refreshState: {
...state.refreshState,
errors: pushErrorFilterDuplicates(state.refreshState.errors, {
id: state.nextId,
event: action,
}),
},
}
}
default:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _: never = state.refreshState
return state
}
}
default: {
Expand Down

0 comments on commit 7bbc1ae

Please sign in to comment.