Fixing Vite HMR Full Reloads from Circular Barrel Imports

Editing one component triggers a full page reload instead of an in-place patch, and the terminal prints [vite] hmr update /src/components/Button.tsx immediately followed by page reload. The usual culprit is a circular dependency introduced by a re-exporting index.ts barrel: the cycle has no clean HMR accept boundary, so Vite gives up and reloads the page. This is a specific failure of the invalidation model described in Optimizing Vite Dev Server and HMR, which is the place to start if the broader update flow is unfamiliar.

The reason this matters is not the reload itself — a reload is fast on a Vite dev server — but everything the reload throws away. A full reload discards in-memory component state: an open modal closes, a half-filled form empties, a scroll position resets, a chart you were tuning re-fetches. On a screen you touch fifty times an hour, that difference between a sub-100ms Fast Refresh patch and a full navigation is the entire value proposition of HMR. When a barrel silently converts every edit in a directory into a reload, the team stops trusting HMR and starts reaching for console.log and manual reloads, which is a much larger productivity tax than the milliseconds suggest.

The failure sits at a precise point in the pipeline. Vite serves each source file as an individual ES module over native ESM in dev, and the HMR runtime tracks which modules “accept” updates for themselves. When you save a file, the server computes the set of modules affected and walks up the importer graph looking for the nearest module that self-accepts. A React or Vue component provided by the framework plugin self-accepts as long as it exports only components. A barrel that re-exports a mix of things does not self-accept, and worse, once a barrel participates in a cycle there is no acyclic path from the edited module to a self-accepting boundary at all. The walk runs out of candidates, reaches the entry, and the only correct fallback is to reload the whole page. Understanding that the bug lives in the shape of the import graph, not in your component code, is what makes the fix obvious.

Barrel cycle breaking the HMR boundary A component imported through a barrel index that re-imports the component forms a cycle with no accept boundary, so the HMR update walks to the entry and forces a full page reload; importing deeply breaks the cycle. Cycle through barrel: full reload Button.tsx edited index.ts barrel re-export Card.tsx imports barrel no accept boundary, walk hits entry Deep import: clean boundary Button.tsx accept boundary Card.tsx deep import patch applied in place
Figure: a barrel cycle leaves no accept boundary, so the update escalates to a full reload; importing the component deeply restores a clean boundary and an in-place patch.

Problem Scope & Prerequisites

This covers React or Vue projects on Vite 5.x or 6.x (Node 20+) where a directory index.ts re-exports modules that, directly or transitively, import that same barrel. The fix is independent of framework plugin version. You will need @vitejs/plugin-react 4.x (or @vitejs/plugin-vue 5.x) so Fast Refresh boundaries exist in the first place — a barrel cycle defeats them, which is what we are diagnosing.

Version scope for this fix The fix applies to Vite 5.x and 6.x on Node 20 or newer with the React or Vue framework plugin that provides Fast Refresh; a barrel cycle defeats the Fast Refresh boundary regardless of plugin version. Applies when all three hold Vite 5.x / 6.x dev server + Fast Refresh Node 20+ native ESM resolution plugin-react 4.x or plugin-vue 5.x The boundary, not the version, is the bug — a cycle breaks Fast Refresh on every plugin release.
Figure: the scenario is version-independent — any barrel cycle removes the Fast Refresh accept boundary.

Reproducible Repro

Create a barrel and a cycle through it. The app imports Card and Button from the barrel; Card also imports Button from the barrel, closing the loop.

Repro import edges App.tsx imports from the components barrel; the barrel re-exports Button and Card; Card imports Button back through the barrel, which is the edge that closes the cycle and removes the accept boundary. App.tsx entry components/index.ts barrel re-export Button.tsx Card.tsx Card → barrel: the edge that closes the cycle
Figure: three plain re-export edges are harmless; the dashed edge from Card back into the barrel is what forms the cycle.
# Vite 6.x, Node 20+
npm create vite@latest barrel-repro -- --template react-ts
cd barrel-repro && npm install
// src/components/index.ts  — the barrel
export { Button } from './Button';
export { Card } from './Card';
// src/components/Button.tsx
export function Button({ label }: { label: string }) {
  return <button>{label}</button>;
}
// src/components/Card.tsx  — imports a sibling THROUGH the barrel, closing the cycle
import { Button } from './index'; // <-- cycle: index.ts -> Card -> index.ts
export function Card() {
  return <div className="card"><Button label="ok" /></div>;
}
// src/App.tsx
import { Button, Card } from './components';
export default function App() {
  return <main><Card /><Button label="save" /></main>;
}

Run npm run dev, open the app, and edit the text in Button.tsx. Instead of a Fast Refresh, the page reloads. The repro is deliberately minimal — three files and one bad edge — because in a real codebase the cyclic edge is usually buried under dozens of legitimate re-exports and is invisible until you look at the graph rather than the code.

How the Accept-Boundary Walk Works

The escalation to a full reload is not a heuristic or a bug; it is the only sound outcome given how Vite propagates updates. When a file changes, the dev server invalidates that module and asks a single question: is there a set of modules, reachable by walking up the importers, that can absorb this change without the change leaking further up the graph? Each edited module is checked first for self-acceptance. The React and Vue plugins inject an import.meta.hot.accept call into every module that exports only components, which is what lets a component swap its own implementation in place. If the edited module self-accepts, the walk stops there and the runtime sends a single update frame over the WebSocket.

If the edited module does not self-accept, Vite looks at each of its importers and repeats the question for them. This is a breadth-first climb, and it terminates in one of two ways. Either it finds a frontier of modules that all self-accept — in which case those modules are re-executed and the patch is bounded — or it reaches a module with no importers (the entry) without finding an accepting boundary, in which case a full reload is the only way to guarantee a consistent module graph. A barrel poisons this climb because a re-exporting index.ts exports bindings, not components, so it never self-accepts, and any module that imports its own barrel creates a cycle. Inside a cycle there is no “up” — every node is both an ancestor and a descendant of the others — so the walk can never find an acyclic frontier and always falls through to the entry.

This is also why the fix is structural rather than a config flag. There is no option that tells Vite to “accept harder”; the accept boundary is a property of the graph, and the graph is defined by your import statements. Break the cycle and the same walk that failed now succeeds on the very first hop. The one deliberate exception is import.meta.hot.accept written by hand, which lets you declare a boundary on a non-component module that would otherwise never qualify — the escape hatch used at the end of the fix section below.

Diagnosis Workflow

Four-step diagnosis sequence Read the reload log, then trace the accept-boundary walk with vite debug hmr, then confirm the cycle statically with madge, then grep for who imports the barrel — each step narrows the cause from symptom to the exact offending edge. 1 · read log page reload line? 2 · --debug hmr boundary walk 3 · madge confirm cycle 4 · grep barrel name the edge Symptom → runtime walk → static proof → exact edge Each step is cheaper to trust than guessing at the next.
Figure: the four diagnosis steps escalate from symptom to the precise import that closes the cycle.
  1. Read the reload log. With the dev server running, edit Button.tsx and watch the terminal:
    [vite] hmr update /src/components/Button.tsx
    [vite] page reload src/components/Button.tsx
    
    The page reload line — not hmr update alone — is the signal that the accept boundary walk failed. The two lines always arrive as a pair when a barrel cycle is the cause: Vite first records that the edited module changed, then discovers during propagation that it cannot bound the update and prints the reload. If you only ever see hmr update with no reload, HMR is working and the problem is elsewhere; if you see the reload line, the walk gave up and you have a boundary problem to trace. Do not skip this step — confirming the exact pair rules out unrelated causes such as a config-file edit or a plugin that reloads on purpose.
  2. Turn on HMR debug tracing. Restart with:
    vite --debug hmr
    You will see the importer walk. A line like [vite:hmr] ... is not self-accepting or a (circular imports) note names the cycle that blocked the boundary. The walk climbs Button → index.ts → Card → index.ts and, finding no self-accepting module, escalates to the entry. This trace is the ground truth because it reflects the runtime module graph after resolution — it accounts for aliases, conditional exports, and plugin transforms that a purely textual search over your source would miss. Read it from the edited file outward; the last module named before the reload is where the boundary should have been and was not.
  3. Confirm the cycle independently. Run a static check so you are not guessing:
    npx madge --circular --extensions ts,tsx src
    Expect output listing components/index.ts > components/Card.tsx > components/index.ts. This is the exact loop Vite cannot break a boundary inside of. Static confirmation matters because it is reproducible in CI and does not depend on which file you happened to edit — a cycle reported by madge will break HMR for every module inside it, not just the one you were touching when you noticed the symptom. Treat the madge output as the authoritative list of edges to cut.
  4. Confirm the barrel is the hub. Grep for who imports the barrel: grep -rn "from './index'" src and grep -rn "from './components'" src. Any in-directory module importing its own barrel is a cycle candidate. The grep is the fastest way to convert the abstract cycle into a concrete list of files to edit: each hit inside the barrel’s own directory is an import that should be rewritten to a deep relative path. Hits from outside the directory are fine and expected — those are exactly the consumers the barrel exists to serve.

The Fix

The root cause is that Fast Refresh can only set a clean accept boundary on a module whose exports are only components. A barrel re-exports a mix and participates in a cycle, so neither the barrel nor the cyclically-imported component qualifies. Break the cycle by importing siblings deeply (never through the barrel), and, for non-component modules that legitimately need to hot-update, add an explicit import.meta.hot.accept.

Accept-boundary decision tree If a module exports only components it already self-accepts; if it imports a sibling through the barrel, switch to a deep import to break the cycle; if it is a non-component module that must hot-update, declare an explicit import.meta.hot.accept boundary. What does the module export? start here only components already self-accepts — nothing to do imports sibling via barrel use a deep import — breaks the cycle non-component + HMR declare import.meta .hot.accept + dispose
Figure: the boundary each module needs depends on what it exports and how it imports its siblings.
// src/components/Card.tsx — FIX: deep import, no barrel, cycle broken
import { Button } from './Button'; // direct path, not './index'
export function Card() {
  return <div className="card"><Button label="ok" /></div>;
}
// src/components/index.ts — barrel stays for EXTERNAL consumers only.
// Nothing INSIDE this directory may import from here.
export { Button } from './Button';
export { Card } from './Card';

For a non-component module (a store, a registry) that must survive hot updates without a reload, declare the boundary yourself:

// src/state/registry.ts — Vite 5.x / 6.x
export const registry = new Map<string, unknown>();

if (import.meta.hot) {
  // Self-accept so an edit here patches in place instead of reloading,
  // and dispose state so the new module does not double-register.
  import.meta.hot.accept((mod) => {
    if (mod) console.info('[hmr] registry updated');
  });
  import.meta.hot.dispose(() => registry.clear());
}

The rule to enforce in review: a directory’s index.ts barrel is for consumers outside the directory. Modules inside the directory import each other by relative path. That single convention removes the entire class of barrel-induced HMR cycles.

Verification

Re-run the dev server with tracing and edit Button.tsx again:

Broken versus fixed HMR signals Before the fix the terminal prints an hmr update followed by a page reload and madge lists the barrel cycle; after the fix a single hmr update appears with no reload and madge reports no circular dependency. Before — full reload [vite] hmr update /Button.tsx [vite] page reload src/… madge: 1 circular dependency After — in-place patch [vite] hmr update /Button.tsx (no page reload line) madge: No circular dependency!
Figure: success is the absence of the reload line and a clean madge run — state survives the edit.
vite --debug hmr

Expected output is a single update with no reload:

[vite] hmr update /src/components/Button.tsx

The browser should now apply a Fast Refresh — component state (an open menu, a typed input) survives the edit. Re-run the static check to prove the cycle is gone:

npx madge --circular --extensions ts,tsx src
# Expected: "No circular dependency found!"

In DevTools → Network → WS → Frames, the save should produce a small update JSON frame rather than a navigation entry in the main Network tab.

Gotchas & Edge Cases

Four edge cases that still force a reload Mixed exports break Fast Refresh without any cycle; wide barrels also inflate cold start and defeat tree-shaking; dynamic import cycles are invisible to madge; and TypeScript path aliases disguise a barrel import as a deep one. Mixed exports a component + a constant in one file marks it non-self-accepting — no cycle needed. Split the module. Wide barrels cost twice larger HMR payloads in dev, dead code in prod. One symbol pulls the whole index.ts. Dynamic cycles hide a cycle through import() never appears in madge — trust the --debug hmr runtime walk. Aliases disguise barrels @/components resolving to the barrel looks deep in source — resolve it with --debug resolve.
Figure: four edge cases — two are barrel-adjacent costs, two disguise the cycle from static tooling.
  • Mixed exports break Fast Refresh even without a cycle. A file exporting both a component and a plain constant or hook can be marked non-self-accepting, because the React plugin’s rule is strict: a module qualifies only if every export is a component. The symptom is identical — an hmr update followed by a reload — but madge finds no cycle, which is the tell that the cause is mixed exports rather than a barrel loop. The fix is to move non-component exports to a sibling module (constants.ts, use-foo.ts) and keep the component file component-only. Confirm by editing the component and checking that the reload line is gone.
  • Barrels also hurt cold start and tree-shaking. Importing one symbol from a wide barrel pulls the whole index.ts into the graph, so the dev server must transform and serve every re-exported module before the page is interactive, and each edit inside the directory produces a larger HMR payload than it needs to. The dev-server cost is slower cold start and fatter update frames; the production cost is dead code that only survives because the barrel’s re-exports look like used references to the bundler. The build-time side is covered in eliminating barrel file side effects in tree-shaking. The deep-import convention that fixes HMR happens to fix both of these at the same time, which is the main argument for enforcing it broadly rather than only where a reload has already bitten you.
  • madge may miss dynamic cycles. A cycle formed through import() will not appear in madge --circular, because the static analyzer follows only static import/export edges and a dynamic import is just a function call to it. The symptom is a reload that madge swears is impossible. Trust the vite --debug hmr walk in that case; it sees the runtime graph, including the edge the dynamic import created, and will name the module where the boundary failed. Confirm the fix the same way — the runtime walk, not the static tool, is the source of truth for dynamic edges.
  • TypeScript path aliases hide the barrel. An alias like @/components resolving to the barrel makes deep imports look identical to barrel imports in source, so a line that reads import { Button } from '@/components' may actually be pulling the whole barrel even though it looks like a scoped, deep import. Resolve the alias mentally — or with vite --debug resolve, which prints the file each specifier resolves to — before concluding an import is “deep”. A useful review rule is to forbid directory-internal modules from importing via the aliased root path at all, so the alias can never disguise a self-import.

Preventing Regressions in CI

A single deep-import fix is worthless if the next pull request reintroduces the cycle, and it will, because the barrel import is the path of least resistance that editors autocomplete to. The durable fix is a gate that fails the build when any directory-internal module imports its own barrel, or when a static cycle appears at all. madge exits non-zero when it finds a cycle, which is all a CI step needs.

// package.json — Vite 5.x / 6.x, madge 8.x
{
  "scripts": {
    "lint:cycles": "madge --circular --extensions ts,tsx src"
  }
}

Wire that script into the same job that runs type-checking so a cyclic import blocks the merge with a readable diff of the exact loop. For finer control you can also forbid barrel self-imports with an ESLint boundary rule — no-restricted-imports patterns that ban ./index and the aliased root inside component directories — but the madge gate is the higher-value first line because it catches the whole class, including cycles that do not pass through a barrel. Run it locally as a pre-push hook too; catching the cycle before it reaches CI is cheaper than a red pipeline and a second commit. The cost of the check is a few hundred milliseconds on a typical src tree, small enough to run on every push without complaint.

When a Barrel Is Still the Right Call

None of this is an argument against barrels wholesale — a package’s public entry point is a legitimate and useful thing, and consumers outside a directory benefit from importing @/components instead of memorizing every file path. The rule is narrower than “no barrels”: a directory’s index.ts is for code that lives outside that directory, and code inside the directory reaches its siblings by relative path. Under that rule the barrel never appears in a cycle, because a cycle requires an edge from inside the directory back into the barrel, and that edge is exactly what the convention forbids.

The rule also has a clean exception for genuinely non-component state. A store, a plugin registry, or a service singleton has no Fast Refresh boundary of its own and legitimately needs to survive edits, so it declares one by hand with import.meta.hot.accept and cleans up with import.meta.hot.dispose, as shown in the fix. That is a deliberate, reviewed boundary rather than an accidental one, and it is the right tool precisely because the module is not a component. Reach for it only when a module must hot-update and cannot be a component; for everything else, breaking the cycle is simpler and leaves no runtime code to maintain.