Short answer
No. Vite's HMR API documents per-module semantics for accept, dispose, data, prune and invalidate, but it publishes no cross-module callback ordering guarantee. Inside a circular dependency, treat the order in which accept and dispose callbacks run as an implementation detail of the installed Vite version, not a contract you can build state transfer on.
What is reasonably stable, and what is not
- Within a single update, dispose callbacks for replaced modules generally run before the new module bodies evaluate. That is the part most people observe.
- The relative order among several modules in the same cycle is not fixed, and it need not match the original import or evaluation order.
- Propagation walks the importer graph from the changed file looking for a self-accepting boundary. A visited set breaks cycles, so a cycle with no self-accepting member usually escalates to a full page reload rather than a partial update.
The second and third points are version-sensitive: HMR propagation internals have changed across Vite majors. Confirm the version you actually run before relying on any of it.
Why the state transfer fails
ESM cycles can leave a binding uninitialized (temporal dead zone) or only partially populated at first evaluation. During a hot update the same hazard reappears: your accept callback may read a partner module that has not been replaced yet and see the old value, or read a binding that was replaced but not re-initialized. import.meta.hot.data does not fix this. It is per-module and only carries that module's own state across its own replacement; it does not synchronize state owned by other modules in the cycle.
The fix: take shared state out of the cycle
Move the mutable state into a leaf module that imports nothing from the cycle, and let the cyclic modules read from it.
// shared-store.js — no imports, cannot join the cycle
export const store = { count: 0 };
if (import.meta.hot) {
// carry this module's own state across its own replacement
const previous = import.meta.hot.data.store;
if (previous) Object.assign(store, previous);
import.meta.hot.dispose((data) => {
data.store = store;
});
import.meta.hot.accept();
}
The cyclic modules then only re-read or re-render from store in their own accept callbacks; they never hand state to each other. If a cyclic module cannot safely self-accept, call import.meta.hot.invalidate() and accept that a full reload is the consistent outcome. Guard every HMR block, because import.meta.hot is undefined in production builds and module-scope state is lost on a full reload.
Verify in your own project
- Check the version:
npx vite --version.
- Run the dev server with HMR logging:
DEBUG=vite:hmr npx vite.
- Edit one module in the cycle and watch whether the socket sends accepted paths or a full-reload message.
- In the browser network panel, look for re-fetched module requests carrying
?t= timestamps to see which files were actually replaced.
- Repeat the same edit several times. If the callback order varies between runs, it was never a guarantee.
One detail that changes the recommendation
Does the update end in a full page reload or a partial hot update? A full reload means no ordering guarantee is being exercised at all, and the cycle itself is the thing to fix. A partial update that keeps stale values means the cycle has a self-accepting member, and the leaf-store pattern above is the right move.