Skip to content

Commit 7bbc1ae

Browse files
authored
Aggregate updates using addStatusHandler and Promise.resolve instead of setTimeout (vercel#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.
1 parent 5b5e422 commit 7bbc1ae

File tree

8 files changed

+407
-168
lines changed

8 files changed

+407
-168
lines changed

packages/next/client/components/react-dev-overlay/hot-reloader-client.tsx

Lines changed: 60 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ import { errorOverlayReducer } from './internal/error-overlay-reducer'
1414
import {
1515
ACTION_BUILD_OK,
1616
ACTION_BUILD_ERROR,
17+
ACTION_BEFORE_REFRESH,
1718
ACTION_REFRESH,
1819
ACTION_UNHANDLED_ERROR,
1920
ACTION_UNHANDLED_REJECTION,
2021
} from './internal/error-overlay-reducer'
2122
import { parseStack } from './internal/helpers/parseStack'
2223
import ReactDevOverlay from './internal/ReactDevOverlay'
23-
import { useErrorHandler } from './internal/helpers/use-error-handler'
24+
import {
25+
RuntimeErrorHandler,
26+
useErrorHandler,
27+
} from './internal/helpers/use-error-handler'
2428
import {
2529
useSendMessage,
2630
useWebsocket,
@@ -30,6 +34,7 @@ import {
3034
interface Dispatcher {
3135
onBuildOk(): void
3236
onBuildError(message: string): void
37+
onBeforeRefresh(): void
3338
onRefresh(): void
3439
}
3540

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

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

4347
// let startLatency = undefined
4448

49+
function onBeforeFastRefresh(dispatcher: Dispatcher, hasUpdates: boolean) {
50+
if (hasUpdates) {
51+
dispatcher.onBeforeRefresh()
52+
}
53+
}
54+
4555
function onFastRefresh(dispatcher: Dispatcher, hasUpdates: boolean) {
4656
dispatcher.onBuildOk()
4757
if (hasUpdates) {
@@ -104,6 +114,7 @@ function performFullReload(err: any, sendMessage: any) {
104114

105115
// Attempt to update code on the fly, fall back to a hard reload.
106116
function tryApplyUpdates(
117+
onBeforeUpdate: (hasUpdates: boolean) => void,
107118
onHotUpdateSuccess: (hasUpdates: boolean) => void,
108119
sendMessage: any,
109120
dispatcher: Dispatcher
@@ -114,7 +125,7 @@ function tryApplyUpdates(
114125
}
115126

116127
function handleApplyUpdates(err: any, updatedModules: any) {
117-
if (err || hadRuntimeError || !updatedModules) {
128+
if (err || RuntimeErrorHandler.hadRuntimeError || !updatedModules) {
118129
if (err) {
119130
console.warn(
120131
'[Fast Refresh] performing full reload\n\n' +
@@ -124,7 +135,7 @@ function tryApplyUpdates(
124135
'It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.\n' +
125136
'Fast Refresh requires at least one parent function component in your React tree.'
126137
)
127-
} else if (hadRuntimeError) {
138+
} else if (RuntimeErrorHandler.hadRuntimeError) {
128139
console.warn(
129140
'[Fast Refresh] performing full reload because your application had an unrecoverable error'
130141
)
@@ -142,6 +153,7 @@ function tryApplyUpdates(
142153
if (isUpdateAvailable()) {
143154
// While we were updating, there was a new update! Do it again.
144155
tryApplyUpdates(
156+
hasUpdates ? () => {} : onBeforeUpdate,
145157
hasUpdates ? () => dispatcher.onBuildOk() : onHotUpdateSuccess,
146158
sendMessage,
147159
dispatcher
@@ -161,14 +173,25 @@ function tryApplyUpdates(
161173

162174
// https://webpack.js.org/api/hot-module-replacement/#check
163175
// @ts-expect-error module.hot exists
164-
module.hot.check(/* autoApply */ true).then(
165-
(updatedModules: any) => {
166-
handleApplyUpdates(null, updatedModules)
167-
},
168-
(err: any) => {
169-
handleApplyUpdates(err, null)
170-
}
171-
)
176+
module.hot
177+
.check(/* autoApply */ false)
178+
.then((updatedModules: any) => {
179+
const hasUpdates = Boolean(updatedModules.length)
180+
if (typeof onBeforeUpdate === 'function') {
181+
onBeforeUpdate(hasUpdates)
182+
}
183+
// https://webpack.js.org/api/hot-module-replacement/#apply
184+
// @ts-expect-error module.hot exists
185+
return module.hot.apply()
186+
})
187+
.then(
188+
(updatedModules: any) => {
189+
handleApplyUpdates(null, updatedModules)
190+
},
191+
(err: any) => {
192+
handleApplyUpdates(err, null)
193+
}
194+
)
172195
}
173196

174197
function processMessage(
@@ -260,6 +283,9 @@ function processMessage(
260283
// Attempt to apply hot updates or reload.
261284
if (isHotUpdate) {
262285
tryApplyUpdates(
286+
function onBeforeHotUpdate(hasUpdates: boolean) {
287+
onBeforeFastRefresh(dispatcher, hasUpdates)
288+
},
263289
function onSuccessfulHotUpdate(hasUpdates: any) {
264290
// Only dismiss it when we're sure it's a hot update.
265291
// Otherwise it would flicker right before the reload.
@@ -287,6 +313,9 @@ function processMessage(
287313
// Attempt to apply hot updates or reload.
288314
if (isHotUpdate) {
289315
tryApplyUpdates(
316+
function onBeforeHotUpdate(hasUpdates: boolean) {
317+
onBeforeFastRefresh(dispatcher, hasUpdates)
318+
},
290319
function onSuccessfulHotUpdate(hasUpdates: any) {
291320
// Only dismiss it when we're sure it's a hot update.
292321
// Otherwise it would flicker right before the reload.
@@ -306,7 +335,7 @@ function processMessage(
306335
clientId: __nextDevClientId,
307336
})
308337
)
309-
if (hadRuntimeError) {
338+
if (RuntimeErrorHandler.hadRuntimeError) {
310339
return window.location.reload()
311340
}
312341
startTransition(() => {
@@ -361,6 +390,7 @@ export default function HotReload({
361390
nextId: 1,
362391
buildError: null,
363392
errors: [],
393+
refreshState: { type: 'idle' },
364394
})
365395
const dispatcher = useMemo((): Dispatcher => {
366396
return {
@@ -370,72 +400,29 @@ export default function HotReload({
370400
onBuildError(message: string): void {
371401
dispatch({ type: ACTION_BUILD_ERROR, message })
372402
},
403+
onBeforeRefresh(): void {
404+
dispatch({ type: ACTION_BEFORE_REFRESH })
405+
},
373406
onRefresh(): void {
374407
dispatch({ type: ACTION_REFRESH })
375408
},
376409
}
377410
}, [dispatch])
378411

379-
const handleOnUnhandledError = useCallback(
380-
(ev: WindowEventMap['error']): void => {
381-
if (
382-
ev.error &&
383-
ev.error.digest &&
384-
(ev.error.digest.startsWith('NEXT_REDIRECT') ||
385-
ev.error.digest === 'NEXT_NOT_FOUND')
386-
) {
387-
ev.preventDefault()
388-
return
389-
}
390-
391-
hadRuntimeError = true
392-
const error = ev?.error
393-
if (
394-
!error ||
395-
!(error instanceof Error) ||
396-
typeof error.stack !== 'string'
397-
) {
398-
// A non-error was thrown, we don't have anything to show. :-(
399-
return
400-
}
401-
402-
if (
403-
error.message.match(/(hydration|content does not match|did not match)/i)
404-
) {
405-
error.message += `\n\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error`
406-
}
407-
408-
const e = error
409-
dispatch({
410-
type: ACTION_UNHANDLED_ERROR,
411-
reason: error,
412-
frames: parseStack(e.stack!),
413-
})
414-
},
415-
[]
416-
)
417-
const handleOnUnhandledRejection = useCallback(
418-
(ev: WindowEventMap['unhandledrejection']): void => {
419-
hadRuntimeError = true
420-
const reason = ev?.reason
421-
if (
422-
!reason ||
423-
!(reason instanceof Error) ||
424-
typeof reason.stack !== 'string'
425-
) {
426-
// A non-error was thrown, we don't have anything to show. :-(
427-
return
428-
}
429-
430-
const e = reason
431-
dispatch({
432-
type: ACTION_UNHANDLED_REJECTION,
433-
reason: reason,
434-
frames: parseStack(e.stack!),
435-
})
436-
},
437-
[]
438-
)
412+
const handleOnUnhandledError = useCallback((error: Error): void => {
413+
dispatch({
414+
type: ACTION_UNHANDLED_ERROR,
415+
reason: error,
416+
frames: parseStack(error.stack!),
417+
})
418+
}, [])
419+
const handleOnUnhandledRejection = useCallback((reason: Error): void => {
420+
dispatch({
421+
type: ACTION_UNHANDLED_REJECTION,
422+
reason: reason,
423+
frames: parseStack(reason.stack!),
424+
})
425+
}, [])
439426
useErrorHandler(handleOnUnhandledError, handleOnUnhandledRejection)
440427

441428
const webSocketRef = useWebsocket(assetPrefix)

packages/next/client/components/react-dev-overlay/internal/error-overlay-reducer.ts

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SupportedErrorEvent } from './container/Errors'
33

44
export const ACTION_BUILD_OK = 'build-ok'
55
export const ACTION_BUILD_ERROR = 'build-error'
6+
export const ACTION_BEFORE_REFRESH = 'before-fast-refresh'
67
export const ACTION_REFRESH = 'fast-refresh'
78
export const ACTION_UNHANDLED_ERROR = 'unhandled-error'
89
export const ACTION_UNHANDLED_REJECTION = 'unhandled-rejection'
@@ -14,6 +15,9 @@ interface BuildErrorAction {
1415
type: typeof ACTION_BUILD_ERROR
1516
message: string
1617
}
18+
interface BeforeFastRefreshAction {
19+
type: typeof ACTION_BEFORE_REFRESH
20+
}
1721
interface FastRefreshAction {
1822
type: typeof ACTION_REFRESH
1923
}
@@ -28,20 +32,44 @@ export interface UnhandledRejectionAction {
2832
frames: StackFrame[]
2933
}
3034

35+
export type FastRefreshState =
36+
| {
37+
type: 'idle'
38+
}
39+
| {
40+
type: 'pending'
41+
errors: SupportedErrorEvent[]
42+
}
43+
3144
export interface OverlayState {
3245
nextId: number
3346
buildError: string | null
3447
errors: SupportedErrorEvent[]
3548
rootLayoutMissingTagsError?: {
3649
missingTags: string[]
3750
}
51+
refreshState: FastRefreshState
52+
}
53+
54+
function pushErrorFilterDuplicates(
55+
errors: SupportedErrorEvent[],
56+
err: SupportedErrorEvent
57+
): SupportedErrorEvent[] {
58+
return [
59+
...errors.filter((e) => {
60+
// Filter out duplicate errors
61+
return e.event.reason !== err.event.reason
62+
}),
63+
err,
64+
]
3865
}
3966

4067
export function errorOverlayReducer(
4168
state: Readonly<OverlayState>,
4269
action: Readonly<
4370
| BuildOkAction
4471
| BuildErrorAction
72+
| BeforeFastRefreshAction
4573
| FastRefreshAction
4674
| UnhandledErrorAction
4775
| UnhandledRejectionAction
@@ -54,21 +82,56 @@ export function errorOverlayReducer(
5482
case ACTION_BUILD_ERROR: {
5583
return { ...state, buildError: action.message }
5684
}
85+
case ACTION_BEFORE_REFRESH: {
86+
return { ...state, refreshState: { type: 'pending', errors: [] } }
87+
}
5788
case ACTION_REFRESH: {
58-
return { ...state, buildError: null, errors: [] }
89+
return {
90+
...state,
91+
buildError: null,
92+
errors:
93+
// Errors can come in during updates. In this case, UNHANDLED_ERROR
94+
// and UNHANDLED_REJECTION events might be dispatched between the
95+
// BEFORE_REFRESH and the REFRESH event. We want to keep those errors
96+
// around until the next refresh. Otherwise we run into a race
97+
// condition where those errors would be cleared on refresh completion
98+
// before they can be displayed.
99+
state.refreshState.type === 'pending'
100+
? state.refreshState.errors
101+
: [],
102+
refreshState: { type: 'idle' },
103+
}
59104
}
60105
case ACTION_UNHANDLED_ERROR:
61106
case ACTION_UNHANDLED_REJECTION: {
62-
return {
63-
...state,
64-
nextId: state.nextId + 1,
65-
errors: [
66-
...state.errors.filter((err) => {
67-
// Filter out duplicate errors
68-
return err.event.reason !== action.reason
69-
}),
70-
{ id: state.nextId, event: action },
71-
],
107+
switch (state.refreshState.type) {
108+
case 'idle': {
109+
return {
110+
...state,
111+
nextId: state.nextId + 1,
112+
errors: pushErrorFilterDuplicates(state.errors, {
113+
id: state.nextId,
114+
event: action,
115+
}),
116+
}
117+
}
118+
case 'pending': {
119+
return {
120+
...state,
121+
nextId: state.nextId + 1,
122+
refreshState: {
123+
...state.refreshState,
124+
errors: pushErrorFilterDuplicates(state.refreshState.errors, {
125+
id: state.nextId,
126+
event: action,
127+
}),
128+
},
129+
}
130+
}
131+
default:
132+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
133+
const _: never = state.refreshState
134+
return state
72135
}
73136
}
74137
default: {

0 commit comments

Comments
 (0)