Skip to content

Commit 380c10e

Browse files
authored
fix(hmr): run HMR handler sequentially (#19793)
1 parent 8bed1de commit 380c10e

File tree

5 files changed

+145
-144
lines changed

5 files changed

+145
-144
lines changed

packages/vite/src/client/client.ts

+8-16
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import type { ErrorPayload, HotPayload } from 'types/hmrPayload'
22
import type { ViteHotContext } from 'types/hot'
3-
import type { InferCustomEventPayload } from 'types/customEvent'
43
import { HMRClient, HMRContext } from '../shared/hmr'
54
import {
65
createWebSocketModuleRunnerTransport,
76
normalizeModuleRunnerTransport,
87
} from '../shared/moduleRunnerTransport'
8+
import { createHMRHandler } from '../shared/hmrHandler'
99
import { ErrorOverlay, overlayId } from './overlay'
1010
import '@vite/env'
1111

@@ -166,15 +166,15 @@ const hmrClient = new HMRClient(
166166
return await importPromise
167167
},
168168
)
169-
transport.connect!(handleMessage)
169+
transport.connect!(createHMRHandler(handleMessage))
170170

171171
async function handleMessage(payload: HotPayload) {
172172
switch (payload.type) {
173173
case 'connected':
174174
console.debug(`[vite] connected.`)
175175
break
176176
case 'update':
177-
notifyListeners('vite:beforeUpdate', payload)
177+
await hmrClient.notifyListeners('vite:beforeUpdate', payload)
178178
if (hasDocument) {
179179
// if this is the first update and there's already an error overlay, it
180180
// means the page opened with existing server compile error and the whole
@@ -238,10 +238,10 @@ async function handleMessage(payload: HotPayload) {
238238
})
239239
}),
240240
)
241-
notifyListeners('vite:afterUpdate', payload)
241+
await hmrClient.notifyListeners('vite:afterUpdate', payload)
242242
break
243243
case 'custom': {
244-
notifyListeners(payload.event, payload.data)
244+
await hmrClient.notifyListeners(payload.event, payload.data)
245245
if (payload.event === 'vite:ws:disconnect') {
246246
if (hasDocument && !willUnload) {
247247
console.log(`[vite] server connection lost. Polling for restart...`)
@@ -255,7 +255,7 @@ async function handleMessage(payload: HotPayload) {
255255
break
256256
}
257257
case 'full-reload':
258-
notifyListeners('vite:beforeFullReload', payload)
258+
await hmrClient.notifyListeners('vite:beforeFullReload', payload)
259259
if (hasDocument) {
260260
if (payload.path && payload.path.endsWith('.html')) {
261261
// if html file is edited, only reload the page if the browser is
@@ -276,11 +276,11 @@ async function handleMessage(payload: HotPayload) {
276276
}
277277
break
278278
case 'prune':
279-
notifyListeners('vite:beforePrune', payload)
279+
await hmrClient.notifyListeners('vite:beforePrune', payload)
280280
await hmrClient.prunePaths(payload.paths)
281281
break
282282
case 'error': {
283-
notifyListeners('vite:error', payload)
283+
await hmrClient.notifyListeners('vite:error', payload)
284284
if (hasDocument) {
285285
const err = payload.err
286286
if (enableOverlay) {
@@ -302,14 +302,6 @@ async function handleMessage(payload: HotPayload) {
302302
}
303303
}
304304

305-
function notifyListeners<T extends string>(
306-
event: T,
307-
data: InferCustomEventPayload<T>,
308-
): void
309-
function notifyListeners(event: string, data: any): void {
310-
hmrClient.notifyListeners(event, data)
311-
}
312-
313305
const enableOverlay = __HMR_ENABLE_OVERLAY__
314306
const hasDocument = 'document' in globalThis
315307

packages/vite/src/module-runner/hmrHandler.ts

+69-115
Original file line numberDiff line numberDiff line change
@@ -1,133 +1,87 @@
11
import type { HotPayload } from 'types/hmrPayload'
22
import { slash, unwrapId } from '../shared/utils'
33
import { ERR_OUTDATED_OPTIMIZED_DEP } from '../shared/constants'
4+
import { createHMRHandler } from '../shared/hmrHandler'
45
import type { ModuleRunner } from './runner'
56

6-
// updates to HMR should go one after another. It is possible to trigger another update during the invalidation for example.
7-
export function createHMRHandler(
7+
export function createHMRHandlerForRunner(
88
runner: ModuleRunner,
99
): (payload: HotPayload) => Promise<void> {
10-
const queue = new Queue()
11-
return (payload) => queue.enqueue(() => handleHotPayload(runner, payload))
12-
}
13-
14-
export async function handleHotPayload(
15-
runner: ModuleRunner,
16-
payload: HotPayload,
17-
): Promise<void> {
18-
const hmrClient = runner.hmrClient
19-
if (!hmrClient || runner.isClosed()) return
20-
switch (payload.type) {
21-
case 'connected':
22-
hmrClient.logger.debug(`connected.`)
23-
break
24-
case 'update':
25-
await hmrClient.notifyListeners('vite:beforeUpdate', payload)
26-
await Promise.all(
27-
payload.updates.map(async (update): Promise<void> => {
28-
if (update.type === 'js-update') {
29-
// runner always caches modules by their full path without /@id/ prefix
30-
update.acceptedPath = unwrapId(update.acceptedPath)
31-
update.path = unwrapId(update.path)
32-
return hmrClient.queueUpdate(update)
33-
}
10+
return createHMRHandler(async (payload) => {
11+
const hmrClient = runner.hmrClient
12+
if (!hmrClient || runner.isClosed()) return
13+
switch (payload.type) {
14+
case 'connected':
15+
hmrClient.logger.debug(`connected.`)
16+
break
17+
case 'update':
18+
await hmrClient.notifyListeners('vite:beforeUpdate', payload)
19+
await Promise.all(
20+
payload.updates.map(async (update): Promise<void> => {
21+
if (update.type === 'js-update') {
22+
// runner always caches modules by their full path without /@id/ prefix
23+
update.acceptedPath = unwrapId(update.acceptedPath)
24+
update.path = unwrapId(update.path)
25+
return hmrClient.queueUpdate(update)
26+
}
3427

35-
hmrClient.logger.error('css hmr is not supported in runner mode.')
36-
}),
37-
)
38-
await hmrClient.notifyListeners('vite:afterUpdate', payload)
39-
break
40-
case 'custom': {
41-
await hmrClient.notifyListeners(payload.event, payload.data)
42-
break
43-
}
44-
case 'full-reload': {
45-
const { triggeredBy } = payload
46-
const clearEntrypointUrls = triggeredBy
47-
? getModulesEntrypoints(
48-
runner,
49-
getModulesByFile(runner, slash(triggeredBy)),
50-
)
51-
: findAllEntrypoints(runner)
28+
hmrClient.logger.error('css hmr is not supported in runner mode.')
29+
}),
30+
)
31+
await hmrClient.notifyListeners('vite:afterUpdate', payload)
32+
break
33+
case 'custom': {
34+
await hmrClient.notifyListeners(payload.event, payload.data)
35+
break
36+
}
37+
case 'full-reload': {
38+
const { triggeredBy } = payload
39+
const clearEntrypointUrls = triggeredBy
40+
? getModulesEntrypoints(
41+
runner,
42+
getModulesByFile(runner, slash(triggeredBy)),
43+
)
44+
: findAllEntrypoints(runner)
5245

53-
if (!clearEntrypointUrls.size) break
46+
if (!clearEntrypointUrls.size) break
5447

55-
hmrClient.logger.debug(`program reload`)
56-
await hmrClient.notifyListeners('vite:beforeFullReload', payload)
57-
runner.evaluatedModules.clear()
48+
hmrClient.logger.debug(`program reload`)
49+
await hmrClient.notifyListeners('vite:beforeFullReload', payload)
50+
runner.evaluatedModules.clear()
5851

59-
for (const url of clearEntrypointUrls) {
60-
try {
61-
await runner.import(url)
62-
} catch (err) {
63-
if (err.code !== ERR_OUTDATED_OPTIMIZED_DEP) {
64-
hmrClient.logger.error(
65-
`An error happened during full reload\n${err.message}\n${err.stack}`,
66-
)
52+
for (const url of clearEntrypointUrls) {
53+
try {
54+
await runner.import(url)
55+
} catch (err) {
56+
if (err.code !== ERR_OUTDATED_OPTIMIZED_DEP) {
57+
hmrClient.logger.error(
58+
`An error happened during full reload\n${err.message}\n${err.stack}`,
59+
)
60+
}
6761
}
6862
}
63+
break
64+
}
65+
case 'prune':
66+
await hmrClient.notifyListeners('vite:beforePrune', payload)
67+
await hmrClient.prunePaths(payload.paths)
68+
break
69+
case 'error': {
70+
await hmrClient.notifyListeners('vite:error', payload)
71+
const err = payload.err
72+
hmrClient.logger.error(
73+
`Internal Server Error\n${err.message}\n${err.stack}`,
74+
)
75+
break
76+
}
77+
case 'ping': // noop
78+
break
79+
default: {
80+
const check: never = payload
81+
return check
6982
}
70-
break
71-
}
72-
case 'prune':
73-
await hmrClient.notifyListeners('vite:beforePrune', payload)
74-
await hmrClient.prunePaths(payload.paths)
75-
break
76-
case 'error': {
77-
await hmrClient.notifyListeners('vite:error', payload)
78-
const err = payload.err
79-
hmrClient.logger.error(
80-
`Internal Server Error\n${err.message}\n${err.stack}`,
81-
)
82-
break
83-
}
84-
case 'ping': // noop
85-
break
86-
default: {
87-
const check: never = payload
88-
return check
89-
}
90-
}
91-
}
92-
93-
class Queue {
94-
private queue: {
95-
promise: () => Promise<void>
96-
resolve: (value?: unknown) => void
97-
reject: (err?: unknown) => void
98-
}[] = []
99-
private pending = false
100-
101-
enqueue(promise: () => Promise<void>) {
102-
return new Promise<any>((resolve, reject) => {
103-
this.queue.push({
104-
promise,
105-
resolve,
106-
reject,
107-
})
108-
this.dequeue()
109-
})
110-
}
111-
112-
dequeue() {
113-
if (this.pending) {
114-
return false
115-
}
116-
const item = this.queue.shift()
117-
if (!item) {
118-
return false
11983
}
120-
this.pending = true
121-
item
122-
.promise()
123-
.then(item.resolve)
124-
.catch(item.reject)
125-
.finally(() => {
126-
this.pending = false
127-
this.dequeue()
128-
})
129-
return true
130-
}
84+
})
13185
}
13286

13387
function getModulesByFile(runner: ModuleRunner, file: string): string[] {

packages/vite/src/module-runner/runner.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
ssrModuleExportsKey,
3131
} from './constants'
3232
import { hmrLogger, silentConsole } from './hmrLogger'
33-
import { createHMRHandler } from './hmrHandler'
33+
import { createHMRHandlerForRunner } from './hmrHandler'
3434
import { enableSourceMapSupport } from './sourcemap/index'
3535
import { ESModulesEvaluator } from './esmEvaluator'
3636

@@ -83,7 +83,7 @@ export class ModuleRunner {
8383
'HMR is not supported by this runner transport, but `hmr` option was set to true',
8484
)
8585
}
86-
this.transport.connect(createHMRHandler(this))
86+
this.transport.connect(createHMRHandlerForRunner(this))
8787
} else {
8888
this.transport.connect?.()
8989
}
+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { HotPayload } from 'types/hmrPayload'
2+
3+
// updates to HMR should go one after another. It is possible to trigger another update during the invalidation for example.
4+
export function createHMRHandler(
5+
handler: (payload: HotPayload) => Promise<void>,
6+
): (payload: HotPayload) => Promise<void> {
7+
const queue = new Queue()
8+
return (payload) => queue.enqueue(() => handler(payload))
9+
}
10+
11+
class Queue {
12+
private queue: {
13+
promise: () => Promise<void>
14+
resolve: (value?: unknown) => void
15+
reject: (err?: unknown) => void
16+
}[] = []
17+
private pending = false
18+
19+
enqueue(promise: () => Promise<void>): Promise<void> {
20+
return new Promise<any>((resolve, reject) => {
21+
this.queue.push({
22+
promise,
23+
resolve,
24+
reject,
25+
})
26+
this.dequeue()
27+
})
28+
}
29+
30+
dequeue(): boolean {
31+
if (this.pending) {
32+
return false
33+
}
34+
const item = this.queue.shift()
35+
if (!item) {
36+
return false
37+
}
38+
this.pending = true
39+
item
40+
.promise()
41+
.then(item.resolve)
42+
.catch(item.reject)
43+
.finally(() => {
44+
this.pending = false
45+
this.dequeue()
46+
})
47+
return true
48+
}
49+
}

playground/hmr/__tests__/hmr.spec.ts

+17-11
Original file line numberDiff line numberDiff line change
@@ -935,27 +935,33 @@ if (!isBuild) {
935935
})
936936

937937
test('deleted file should trigger dispose and prune callbacks', async () => {
938-
browserLogs.length = 0
939938
await page.goto(viteTestUrl)
940939

941940
const parentFile = 'file-delete-restore/parent.js'
942941
const childFile = 'file-delete-restore/child.js'
942+
const originalChildFileCode = readFile(childFile)
943943

944-
// delete the file
945-
editFile(parentFile, (code) =>
946-
code.replace(
947-
"export { value as childValue } from './child'",
948-
"export const childValue = 'not-child'",
949-
),
944+
await untilBrowserLogAfter(
945+
() => {
946+
// delete the file
947+
editFile(parentFile, (code) =>
948+
code.replace(
949+
"export { value as childValue } from './child'",
950+
"export const childValue = 'not-child'",
951+
),
952+
)
953+
removeFile(childFile)
954+
},
955+
[
956+
'file-delete-restore/child.js is disposed',
957+
'file-delete-restore/child.js is pruned',
958+
],
959+
false,
950960
)
951-
const originalChildFileCode = readFile(childFile)
952-
removeFile(childFile)
953961
await untilUpdated(
954962
() => page.textContent('.file-delete-restore'),
955963
'parent:not-child',
956964
)
957-
expect(browserLogs).to.include('file-delete-restore/child.js is disposed')
958-
expect(browserLogs).to.include('file-delete-restore/child.js is pruned')
959965

960966
// restore the file
961967
addFile(childFile, originalChildFileCode)

0 commit comments

Comments
 (0)