Skip to content

Commit d4ee5e8

Browse files
sapphi-redJSerFeng
andauthored
fix(hmr): avoid infinite loop happening with hot.invalidate in circular deps (#19870)
Co-authored-by: fengyu <[email protected]>
1 parent e051936 commit d4ee5e8

File tree

20 files changed

+186
-19
lines changed

20 files changed

+186
-19
lines changed

packages/vite/src/node/server/environment.ts

+12-7
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,16 @@ export class DevEnvironment extends BaseEnvironment {
134134
},
135135
})
136136

137-
this.hot.on('vite:invalidate', async ({ path, message }) => {
138-
invalidateModule(this, {
139-
path,
140-
message,
141-
})
142-
})
137+
this.hot.on(
138+
'vite:invalidate',
139+
async ({ path, message, firstInvalidatedBy }) => {
140+
invalidateModule(this, {
141+
path,
142+
message,
143+
firstInvalidatedBy,
144+
})
145+
},
146+
)
143147

144148
const { optimizeDeps } = this.config
145149
if (context.depsOptimizer) {
@@ -277,6 +281,7 @@ function invalidateModule(
277281
m: {
278282
path: string
279283
message?: string
284+
firstInvalidatedBy: string
280285
},
281286
) {
282287
const mod = environment.moduleGraph.urlToModuleMap.get(m.path)
@@ -299,7 +304,7 @@ function invalidateModule(
299304
file,
300305
[...mod.importers],
301306
mod.lastHMRTimestamp,
302-
true,
307+
m.firstInvalidatedBy,
303308
)
304309
}
305310
}

packages/vite/src/node/server/hmr.ts

+18-4
Original file line numberDiff line numberDiff line change
@@ -625,14 +625,14 @@ export async function handleHMRUpdate(
625625
await hotUpdateEnvironments(server, hmr)
626626
}
627627

628-
type HasDeadEnd = boolean
628+
type HasDeadEnd = string | boolean
629629

630630
export function updateModules(
631631
environment: DevEnvironment,
632632
file: string,
633633
modules: EnvironmentModuleNode[],
634634
timestamp: number,
635-
afterInvalidation?: boolean,
635+
firstInvalidatedBy?: string,
636636
): void {
637637
const { hot } = environment
638638
const updates: Update[] = []
@@ -661,6 +661,19 @@ export function updateModules(
661661
continue
662662
}
663663

664+
// If import.meta.hot.invalidate was called already on that module for the same update,
665+
// it means any importer of that module can't hot update. We should fallback to full reload.
666+
if (
667+
firstInvalidatedBy &&
668+
boundaries.some(
669+
({ acceptedVia }) =>
670+
normalizeHmrUrl(acceptedVia.url) === firstInvalidatedBy,
671+
)
672+
) {
673+
needFullReload = 'circular import invalidate'
674+
continue
675+
}
676+
664677
updates.push(
665678
...boundaries.map(
666679
({ boundary, acceptedVia, isWithinCircularImport }) => ({
@@ -673,6 +686,7 @@ export function updateModules(
673686
? isExplicitImportRequired(acceptedVia.url)
674687
: false,
675688
isWithinCircularImport,
689+
firstInvalidatedBy,
676690
}),
677691
),
678692
)
@@ -685,7 +699,7 @@ export function updateModules(
685699
: ''
686700
environment.logger.info(
687701
colors.green(`page reload `) + colors.dim(file) + reason,
688-
{ clear: !afterInvalidation, timestamp: true },
702+
{ clear: !firstInvalidatedBy, timestamp: true },
689703
)
690704
hot.send({
691705
type: 'full-reload',
@@ -702,7 +716,7 @@ export function updateModules(
702716
environment.logger.info(
703717
colors.green(`hmr update `) +
704718
colors.dim([...new Set(updates.map((u) => u.path))].join(', ')),
705-
{ clear: !afterInvalidation, timestamp: true },
719+
{ clear: !firstInvalidatedBy, timestamp: true },
706720
)
707721
hot.send({
708722
type: 'update',

packages/vite/src/shared/hmr.ts

+19-7
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,17 @@ export class HMRContext implements ViteHotContext {
9797
decline(): void {}
9898

9999
invalidate(message: string): void {
100+
const firstInvalidatedBy =
101+
this.hmrClient.currentFirstInvalidatedBy ?? this.ownerPath
100102
this.hmrClient.notifyListeners('vite:invalidate', {
101103
path: this.ownerPath,
102104
message,
105+
firstInvalidatedBy,
103106
})
104107
this.send('vite:invalidate', {
105108
path: this.ownerPath,
106109
message,
110+
firstInvalidatedBy,
107111
})
108112
this.hmrClient.logger.debug(
109113
`invalidate ${this.ownerPath}${message ? `: ${message}` : ''}`,
@@ -170,6 +174,7 @@ export class HMRClient {
170174
public dataMap = new Map<string, any>()
171175
public customListenersMap: CustomListenersMap = new Map()
172176
public ctxToListenersMap = new Map<string, CustomListenersMap>()
177+
public currentFirstInvalidatedBy: string | undefined
173178

174179
constructor(
175180
public logger: HMRLogger,
@@ -254,7 +259,7 @@ export class HMRClient {
254259
}
255260

256261
private async fetchUpdate(update: Update): Promise<(() => void) | undefined> {
257-
const { path, acceptedPath } = update
262+
const { path, acceptedPath, firstInvalidatedBy } = update
258263
const mod = this.hotModulesMap.get(path)
259264
if (!mod) {
260265
// In a code-splitting project,
@@ -282,13 +287,20 @@ export class HMRClient {
282287
}
283288

284289
return () => {
285-
for (const { deps, fn } of qualifiedCallbacks) {
286-
fn(
287-
deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)),
288-
)
290+
try {
291+
this.currentFirstInvalidatedBy = firstInvalidatedBy
292+
for (const { deps, fn } of qualifiedCallbacks) {
293+
fn(
294+
deps.map((dep) =>
295+
dep === acceptedPath ? fetchedModule : undefined,
296+
),
297+
)
298+
}
299+
const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
300+
this.logger.debug(`hot updated: ${loggedPath}`)
301+
} finally {
302+
this.currentFirstInvalidatedBy = undefined
289303
}
290-
const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
291-
this.logger.debug(`hot updated: ${loggedPath}`)
292304
}
293305
}
294306
}

packages/vite/types/customEvent.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface WebSocketConnectionPayload {
3030
export interface InvalidatePayload {
3131
path: string
3232
message: string | undefined
33+
firstInvalidatedBy: string
3334
}
3435

3536
/**

packages/vite/types/hmrPayload.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export interface Update {
3232
/** @internal */
3333
isWithinCircularImport?: boolean
3434
/** @internal */
35+
firstInvalidatedBy?: string
36+
/** @internal */
3537
invalidates?: string[]
3638
}
3739

playground/hmr-ssr/__tests__/hmr-ssr.spec.ts

+20
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,26 @@ if (!isBuild) {
216216
)
217217
})
218218

219+
test('invalidate in circular dep should not trigger infinite HMR', async () => {
220+
const el = () => hmr('.invalidation-circular-deps')
221+
await untilUpdated(() => el(), 'child')
222+
editFile(
223+
'invalidation-circular-deps/circular-invalidate/child.js',
224+
(code) => code.replace('child', 'child updated'),
225+
)
226+
await untilUpdated(() => el(), 'child updated')
227+
})
228+
229+
test('invalidate in circular dep should be hot updated if possible', async () => {
230+
const el = () => hmr('.invalidation-circular-deps-handled')
231+
await untilUpdated(() => el(), 'child')
232+
editFile(
233+
'invalidation-circular-deps/invalidate-handled-in-circle/child.js',
234+
(code) => code.replace('child', 'child updated'),
235+
)
236+
await untilUpdated(() => el(), 'child updated')
237+
})
238+
219239
test('plugin hmr handler + custom event', async () => {
220240
const el = () => hmr('.custom')
221241
editFile('customFile.js', (code) => code.replace('custom', 'edited'))

playground/hmr-ssr/hmr.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { virtual } from 'virtual:file'
22
import { foo as depFoo, nestedFoo } from './hmrDep'
33
import './importing-updated'
4+
import './invalidation-circular-deps'
45
import './invalidation/parent'
56
import './file-delete-restore'
67
import './optional-chaining/parent'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import './parent'
2+
3+
if (import.meta.hot) {
4+
import.meta.hot.accept(() => {
5+
import.meta.hot.invalidate()
6+
})
7+
}
8+
9+
export const value = 'child'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { value } from './child'
2+
3+
if (import.meta.hot) {
4+
import.meta.hot.accept(() => {
5+
import.meta.hot.invalidate()
6+
})
7+
}
8+
9+
log('(invalidation circular deps) parent is executing')
10+
setTimeout(() => {
11+
globalThis.__HMR__['.invalidation-circular-deps'] = value
12+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import './circular-invalidate/parent'
2+
import './invalidate-handled-in-circle/parent'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import './parent'
2+
3+
if (import.meta.hot) {
4+
import.meta.hot.accept(() => {
5+
import.meta.hot.invalidate()
6+
})
7+
}
8+
9+
export const value = 'child'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { value } from './child'
2+
3+
if (import.meta.hot) {
4+
import.meta.hot.accept(() => {})
5+
}
6+
7+
log('(invalidation circular deps handled) parent is executing')
8+
setTimeout(() => {
9+
globalThis.__HMR__['.invalidation-circular-deps-handled'] = value
10+
})

playground/hmr/__tests__/hmr.spec.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ test('should render', async () => {
2525

2626
if (!isBuild) {
2727
test('should connect', async () => {
28-
expect(browserLogs.length).toBe(3)
28+
expect(browserLogs.length).toBe(5)
2929
expect(browserLogs.some((msg) => msg.includes('connected'))).toBe(true)
3030
browserLogs.length = 0
3131
})
@@ -242,6 +242,30 @@ if (!isBuild) {
242242
)
243243
})
244244

245+
test('invalidate in circular dep should not trigger infinite HMR', async () => {
246+
const el = await page.$('.invalidation-circular-deps')
247+
await untilUpdated(() => el.textContent(), 'child')
248+
editFile(
249+
'invalidation-circular-deps/circular-invalidate/child.js',
250+
(code) => code.replace('child', 'child updated'),
251+
)
252+
await page.waitForEvent('load')
253+
await untilUpdated(
254+
() => page.textContent('.invalidation-circular-deps'),
255+
'child updated',
256+
)
257+
})
258+
259+
test('invalidate in circular dep should be hot updated if possible', async () => {
260+
const el = await page.$('.invalidation-circular-deps-handled')
261+
await untilUpdated(() => el.textContent(), 'child')
262+
editFile(
263+
'invalidation-circular-deps/invalidate-handled-in-circle/child.js',
264+
(code) => code.replace('child', 'child updated'),
265+
)
266+
await untilUpdated(() => el.textContent(), 'child updated')
267+
})
268+
245269
test('plugin hmr handler + custom event', async () => {
246270
const el = await page.$('.custom')
247271
editFile('customFile.js', (code) => code.replace('custom', 'edited'))

playground/hmr/hmr.ts

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { virtual } from 'virtual:file'
22
import { virtual as virtualDep } from 'virtual:file-dep'
33
import { foo as depFoo, nestedFoo } from './hmrDep'
44
import './importing-updated'
5+
import './invalidation-circular-deps'
56
import './file-delete-restore'
67
import './optional-chaining/parent'
78
import './intermediate-file-delete'

playground/hmr/index.html

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
<div class="soft-invalidation"></div>
3030
<div class="invalidation-parent"></div>
3131
<div class="invalidation-root"></div>
32+
<div class="invalidation-circular-deps"></div>
33+
<div class="invalidation-circular-deps-handled"></div>
3234
<div class="custom-communication"></div>
3335
<div class="css-prev"></div>
3436
<div class="css-post"></div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import './parent'
2+
3+
if (import.meta.hot) {
4+
import.meta.hot.accept(() => {
5+
import.meta.hot.invalidate()
6+
})
7+
}
8+
9+
export const value = 'child'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { value } from './child'
2+
3+
if (import.meta.hot) {
4+
import.meta.hot.accept(() => {
5+
import.meta.hot.invalidate()
6+
})
7+
}
8+
9+
console.log('(invalidation circular deps) parent is executing')
10+
setTimeout(() => {
11+
document.querySelector('.invalidation-circular-deps').innerHTML = value
12+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import './circular-invalidate/parent'
2+
import './invalidate-handled-in-circle/parent'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import './parent'
2+
3+
if (import.meta.hot) {
4+
import.meta.hot.accept(() => {
5+
import.meta.hot.invalidate()
6+
})
7+
}
8+
9+
export const value = 'child'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { value } from './child'
2+
3+
if (import.meta.hot) {
4+
import.meta.hot.accept(() => {})
5+
}
6+
7+
console.log('(invalidation circular deps handled) parent is executing')
8+
setTimeout(() => {
9+
document.querySelector('.invalidation-circular-deps-handled').innerHTML =
10+
value
11+
})

0 commit comments

Comments
 (0)