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.

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.

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.

# 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.

Diagnosis Workflow

  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.
  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.
  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.
  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 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.

// 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:

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

  • 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. Move non-component exports to a separate module; do not co-locate them with the component you want to hot-update.
  • Barrels also hurt cold start and tree-shaking. Importing one symbol from a wide barrel pulls the whole index.ts into the graph. The dev-server cost shows up as larger HMR payloads; the production cost is dead code. The build-time side is covered in eliminating barrel file side effects in tree-shaking.
  • madge may miss dynamic cycles. A cycle formed through import() will not appear in madge --circular. Trust the vite --debug hmr walk in that case; it sees the runtime graph.
  • TypeScript path aliases hide the barrel. An alias like @/components resolving to the barrel makes deep imports look identical to barrel imports in source. Resolve the alias mentally — or with vite --debug resolve — before concluding an import is “deep”.