Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(hmr): improve circular import updates #14867

Merged
merged 9 commits into from
Nov 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 2 additions & 6 deletions docs/guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,9 @@ If you are running Vite with WSL2, Vite cannot watch file changes in some condit

### A full reload happens instead of HMR

If HMR is not handled by Vite or a plugin, a full reload will happen.
If HMR is not handled by Vite or a plugin, a full reload will happen as it's the only way to refresh the state.

Also if there is a dependency loop, a full reload will happen. To solve this, try removing the loop.

### High number of HMR updates in console

This can be caused by a circular dependency. To solve this, try breaking the loop.
If HMR is handled but it is within a circular dependency, a full reload will also happen to recover the execution order. To solve this, try breaking the loop. You can run `vite --debug hmr` to log the circular dependency path if a file change triggered it.

## Build

Expand Down
94 changes: 84 additions & 10 deletions packages/vite/src/node/server/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,7 @@ export function updateModules(
const boundaries: { boundary: ModuleNode; acceptedVia: ModuleNode }[] = []
const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries)

moduleGraph.invalidateModule(
mod,
invalidatedModules,
timestamp,
true,
boundaries.map((b) => b.boundary),
)
moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true)

if (needFullReload) {
continue
Expand Down Expand Up @@ -280,6 +274,9 @@ function propagateUpdate(

if (node.isSelfAccepting) {
boundaries.push({ boundary: node, acceptedVia: node })
if (isNodeWithinCircularImports(node, currentChain)) {
return true
}

// additionally check for CSS importers, since a PostCSS plugin like
// Tailwind JIT may register any file as a dependency to a CSS file.
Expand All @@ -304,6 +301,9 @@ function propagateUpdate(
// so that they do get the fresh imported module when/if they are reloaded.
if (node.acceptedHmrExports) {
boundaries.push({ boundary: node, acceptedVia: node })
if (isNodeWithinCircularImports(node, currentChain)) {
return true
}
} else {
if (!node.importers.size) {
return true
Expand All @@ -322,8 +322,12 @@ function propagateUpdate(

for (const importer of node.importers) {
const subChain = currentChain.concat(importer)

if (importer.acceptedHmrDeps.has(node)) {
boundaries.push({ boundary: importer, acceptedVia: node })
if (isNodeWithinCircularImports(importer, subChain)) {
return true
}
continue
}

Expand All @@ -337,12 +341,82 @@ function propagateUpdate(
}
}

if (currentChain.includes(importer)) {
// circular deps is considered dead end
if (
!currentChain.includes(importer) &&
propagateUpdate(importer, traversedModules, boundaries, subChain)
) {
return true
}
}
return false
}

/**
* Check importers recursively if it's an import loop. An accepted module within
* an import loop cannot recover its execution order and should be reloaded.
*
* @param node The node that accepts HMR and is a boundary
* @param nodeChain The chain of nodes/imports that lead to the node.
* (The last node in the chain imports the `node` parameter)
* @param currentChain The current chain tracked from the `node` parameter
*/
function isNodeWithinCircularImports(
node: ModuleNode,
nodeChain: ModuleNode[],
currentChain: ModuleNode[] = [node],
) {
// To help visualize how each parameters work, imagine this import graph:
//
// A -> B -> C -> ACCEPTED -> D -> E -> NODE
// ^--------------------------|
//
// ACCEPTED: the node that accepts HMR. the `node` parameter.
// NODE : the initial node that triggered this HMR.
//
// This function will return true in the above graph, which:
// `node` : ACCEPTED
// `nodeChain` : [NODE, E, D, ACCEPTED]
// `currentChain` : [ACCEPTED, C, B]
//
// It works by checking if any `node` importers are within `nodeChain`, which
// means there's an import loop with a HMR-accepted module in it.

for (const importer of node.importers) {
// Node may import itself which is safe
if (importer === node) continue

// Check circular imports
const importerIndex = nodeChain.indexOf(importer)
if (importerIndex > -1) {
// Log extra debug information so users can fix and remove the circular imports
if (debugHmr) {
// Following explanation above:
// `importer` : E
// `currentChain` reversed : [B, C, ACCEPTED]
// `nodeChain` sliced & reversed : [D, E]
// Combined : [E, B, C, ACCEPTED, D, E]
const importChain = [
importer,
...[...currentChain].reverse(),
...nodeChain.slice(importerIndex, -1).reverse(),
]
debugHmr(
colors.yellow(`circular imports detected: `) +
importChain.map((m) => colors.dim(m.url)).join(' -> '),
)
}
return true
}

if (propagateUpdate(importer, traversedModules, boundaries, subChain)) {
// Continue recursively
if (
!currentChain.includes(importer) &&
isNodeWithinCircularImports(
importer,
nodeChain,
currentChain.concat(importer),
)
) {
return true
}
}
Expand Down
11 changes: 0 additions & 11 deletions packages/vite/src/node/server/moduleGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,6 @@ export class ModuleGraph {
timestamp: number = Date.now(),
isHmr: boolean = false,
/** @internal */
hmrBoundaries: ModuleNode[] = [],
/** @internal */
softInvalidate = false,
): void {
const prevInvalidationState = mod.invalidationState
Expand Down Expand Up @@ -199,14 +197,6 @@ export class ModuleGraph {
mod.ssrModule = null
mod.ssrError = null

// https://github.com/vitejs/vite/issues/3033
// Given b.js -> c.js -> b.js (arrow means top-level import), if c.js self-accepts
// and refetches itself, the execution order becomes c.js -> b.js -> c.js. The import
// order matters here as it will fail. The workaround for now is to not hmr invalidate
// b.js so that c.js refetches the already cached b.js, skipping the import loop.
if (hmrBoundaries.includes(mod)) {
return
}
mod.importers.forEach((importer) => {
if (!importer.acceptedHmrDeps.has(mod)) {
// If the importer statically imports the current module, we can soft-invalidate the importer
Expand All @@ -220,7 +210,6 @@ export class ModuleGraph {
seen,
timestamp,
isHmr,
undefined,
shouldSoftInvalidateImporter,
)
}
Expand Down
20 changes: 19 additions & 1 deletion playground/hmr/__tests__/hmr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,7 +877,25 @@ if (import.meta.hot) {
editFile('self-accept-within-circular/c.js', (code) =>
code.replace(`export const c = 'c'`, `export const c = 'cc'`),
)
await untilUpdated(() => el.textContent(), 'cc')
await untilUpdated(
() => page.textContent('.self-accept-within-circular'),
'cc',
)
sapphi-red marked this conversation as resolved.
Show resolved Hide resolved
})

test('hmr should not reload if no accepted within circular imported files', async () => {
await page.goto(viteTestUrl + '/circular/index.html')
const el = await page.$('.circular')
expect(await el.textContent()).toBe(
'mod-a -> mod-b -> mod-c -> mod-a (expected error)',
)
editFile('circular/mod-b.js', (code) =>
code.replace(`mod-b ->`, `mod-b (edited) ->`),
)
await untilUpdated(
() => el.textContent(),
'mod-a -> mod-b (edited) -> mod-c -> mod-a (expected error)',
)
})

test('assets HMR', async () => {
Expand Down
7 changes: 7 additions & 0 deletions playground/hmr/circular/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { msg } from './mod-a'

document.querySelector('.circular').textContent = msg

if (import.meta.hot) {
import.meta.hot.accept()
}
5 changes: 5 additions & 0 deletions playground/hmr/circular/mod-a.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const value = 'mod-a'

import { value as _value } from './mod-b'

export const msg = `mod-a -> ${_value}`
3 changes: 3 additions & 0 deletions playground/hmr/circular/mod-b.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { value as _value } from './mod-c'

export const value = `mod-b -> ${_value}`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I add import.meta.hot?.accept(() => {}) here, the full refresh starts happening.

index.js (boundary) -> mod-a.js -> mod-b.js (boundary) -> mod-c.js -> mod-a.js

It might be good to improve this case as well in future.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this one is tricky currently since the two boundary could start fetching at the same time, causing potential execution order issues. We could detect and only refresh index.js as the boundary, but I think it's also nice that this triggers a page reload + HMR debug logs so they can fix this and get fine-grained HMR in the first place.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's also nice that this triggers a page reload + HMR debug logs so they can fix this and get fine-grained HMR in the first place.

Ah, that's a good point.

11 changes: 11 additions & 0 deletions playground/hmr/circular/mod-c.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { value as _value } from './mod-a'

// Should error as `_value` is not defined yet within the circular imports
let __value
try {
__value = `${_value} (unexpected no error)`
} catch {
__value = 'mod-a (expected error)'
}

export const value = `mod-c -> ${__value}`
1 change: 1 addition & 0 deletions playground/hmr/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import './invalidation/parent'
import './file-delete-restore'
import './optional-chaining/parent'
import './intermediate-file-delete'
import './circular'
import logo from './logo.svg'
import { msg as softInvalidationMsg } from './soft-invalidation'

Expand Down
1 change: 1 addition & 0 deletions playground/hmr/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@
<button class="intermediate-file-delete-increment">1</button>
<div class="intermediate-file-delete-display"></div>
<image id="logo"></image>
<div class="circular"></div>