# How Cordis Handles Hot Module Replacement (HMR): Dependency Analysis and Cache Management

> Learn how Cordis handles Hot Module Replacement (HMR) with advanced dependency analysis and efficient cache management. Understand its plugin-HMR implementation for seamless updates.

- Repository: [Cordiverse/cordis](https://github.com/cordiverse/cordis)
- Tags: internals
- Published: 2026-08-23

---

**Cordis implements HMR through the `@cordisjs/plugin-hmr` package by recursively analyzing the module dependency graph, classifying changed files as accepted or declined, and atomically clearing both ESM and CJS caches before re-importing updated plugins, with automatic rollback on failure.**

Cordis is an extensible framework designed for building scalable, plugin-based applications. Understanding **Cordis HMR dependency analysis and cache** internals is essential for developers who need reliable hot reloading without state loss. The HMR plugin watches files using `chokidar` and orchestrates a sophisticated dependency traversal to determine exactly which modules must be invalidated and reloaded.

## Building the Dependency Tree with `loadDependencies`

The core of Cordis HMR analysis relies on the `loadDependencies` helper located in [`packages/hmr/src/index.ts`](https://github.com/cordiverse/cordis/blob/main/packages/hmr/src/index.ts#L31-L41). This function recursively traverses a `ModuleJob`—the internal representation of a loaded module—to collect every reachable URL.

The traversal deliberately excludes Node.js built-ins (URLs starting with `node:`) and third-party packages (paths containing `/node_modules/`), ensuring the system only tracks userland code. During initialization, the plugin captures **externals** by running `loadDependencies` on the CLI entry point (L15-L24). This set represents framework-level files; any change to these triggers a full process restart rather than a hot swap.

```ts
// packages/hmr/src/index.ts L31-L41
async function loadDependencies(job: ModuleJob, ignored = new Set<string>()) {
  const dependencies = new Set<string>()
  async function traverse(job: ModuleJob) {
    if (ignored.has(job.url) || dependencies.has(job.url)) return
    if (job.url.startsWith('node:') || job.url.includes('/node_modules/')) return
    dependencies.add(job.url)
    const children = await job.linked
    await Promise.all(Array.prototype.map.call(children, traverse))
  }
  await traverse(job)
  return dependencies
}

```

## Classifying Changes as Accepted or Declined

When `chokidar` detects a file change, the URL is added to a `stashed` set. The `analyzeChanges` method (L74-L122) then performs a fixed-point iteration to classify every affected file:

- **Accepted** files are those directly changed or that have a dependent already marked as accepted.
- **Declined** files belong to the `externals` set or have dependents that are all declined.

The algorithm maintains a `pending` array and iterates until no further updates occur, ensuring the classification is stable before proceeding to cache clearing.

```ts
// packages/hmr/src/index.ts L74-L122 (excerpt)
private async analyzeChanges() {
  const pending: string[] = []
  this.accepted = new Set(this.stashed)
  this.declined = new Set(this.externals)
  const isExcluded = (url: string) => url.startsWith('node:') || url.includes('/node_modules/')

  // Populate pending with children of stashed files
  await Promise.all([...this.stashed].map(async (url) => {
    const children = await this.getLinked(url)
    for (const child of children) {
      if (this.accepted.has(child) || this.declined.has(child) || isExcluded(child)) continue
      pending.push(child)
    }
  }))

  // Resolve until fixed point
  while (pending.length) {
    let index = 0, hasUpdate = false
    while (index < pending.length) {
      const url = pending[index]
      const children = await this.getLinked(url)
      let isDeclined = true, isAccepted = false
      for (const child of children) {
        if (this.declined.has(child) || isExcluded(child)) continue
        if (this.accepted.has(child)) {
          isAccepted = true; break
        } else {
          isDeclined = false
          if (!pending.includes(child)) { hasUpdate = true; pending.push(child) }
        }
      }
      if (isAccepted || isDeclined) {
        hasUpdate = true
        pending.splice(index, 1)
        isAccepted ? this.accepted.add(url) : this.declined.add(url)
      } else index++
    }
    if (!hasUpdate) break
  }
  for (const url of pending) this.declined.add(url)
}

```

## Determining Which Plugins to Reload

After classification, the `partialReload` logic (L42-L71) determines which plugin entry files require reloading. It constructs a `nameMap` of plugin entries and loads each plugin’s dependencies using `loadDependencies` with the `declined` set as an ignore list. A plugin is scheduled for reload only if its dependency set intersects with the `accepted` set, minimizing unnecessary re-instantiations.

```ts
// packages/hmr/src/index.ts L42-L71 (excerpt)
for (const [job, plugin] of pending) {
  const dependencies = [...await loadDependencies(job, this.declined)]
  if (!dependencies.some(dep => this.accepted.has(dep))) continue
  dependencies.forEach(dep => this.accepted.add(dep))
  reloads.set(plugin, { filename: job.url, runtime: this.ctx.registry.get(plugin) })
}

```

## Clearing ESM and CJS Module Caches

Cordis clears caches for every file in the `accepted` set. To handle differences in Node.js versions 22-24, the code uses native `Map.prototype` methods to interact with the internal ESM `loadCache`. It also purges the CJS `require.cache` using `createRequire`. Backups are created for both ESM (`esmBackup`) and CJS (`cjsBackup`) entries to enable rollback if the reload fails.

```ts
// packages/hmr/src/index.ts L90-L108
const esmBackup: Dict = {}
const cjsBackup: Dict = {}
const require = createRequire(import.meta.url)

for (const filename of this.accepted) {
  // 1. ESM cache
  const job = Map.prototype.get.call(this.internal.loadCache, filename)
  esmBackup[filename] = job
  Map.prototype.delete.call(this.internal.loadCache, filename)

  // 2. CJS cache
  try {
    const filepath = fileURLToPath(filename)
    if (require.cache[filepath]) {
      cjsBackup[filepath] = require.cache[filepath]
      delete require.cache[filepath]
    }
  } catch {}
}

```

## Re-importing and Safe Rollback

The plugin imports each new entry file using `ctx.loader.import`, unwraps exports, and replaces the old registration in the `ctx.registry`. Successful reloads emit the `hmr/reload` event. If any import throws an error, the `rollback` function restores the ESM and CJS backups from their respective backup objects, guaranteeing the application returns to its pre-reload state. If the changed file belongs to the `externals` set, Cordis calls `loader.exit()` to trigger a full process restart (L32-34), ensuring framework consistency.

```ts
// packages/hmr/src/index.ts L120-L128 (excerpt)
try {
  for (const [, { filename }] of reloads) {
    attempts[filename] = this.ctx.loader.unwrapExports(
      await this.ctx.loader.import(filename, this.getOuterStack))
  }
} catch (e) { handleError(this.ctx, e); return rollback() }

this.ctx.emit('hmr/reload', reloads)

```

## Configuration and Usage Example

To enable HMR, register the `@cordisjs/plugin-hmr` plugin after the loader. Configure the `root` directories to watch, set a `debounce` time to batch rapid changes, and optionally ignore specific patterns.

```ts
// src/app.ts
import { Context } from 'cordis'
import loader from '@cordisjs/plugin-loader'
import hmr from '@cordisjs/plugin-hmr'

const ctx = new Context({
  loader: { entries: [{ name: 'my-plugin', path: './plugins/my-plugin.ts' }] },
  hmr: { root: ['src'], debounce: 100, ignored: ['**/node_modules'] },
})

await ctx.start()
ctx.plugin(loader)
ctx.plugin(hmr)

```

Listen to the `hmr/reload` event to react to successful hot swaps:

```ts
ctx.on('hmr/reload', (reloads) => {
  for (const [plugin, { filename }] of reloads) {
    console.log(`Reloaded ${plugin.name} from ${filename}`)
  }
})

```

## Summary

- **`loadDependencies`** recursively builds the module graph while excluding `node_modules` and Node built-ins.
- **`analyzeChanges`** uses fixed-point iteration to classify files as accepted or declined based on their dependents.
- **Dual cache clearing** targets both ESM (`this.internal.loadCache`) and CJS (`require.cache`) systems using version-agnostic `Map.prototype` methods.
- **Atomic rollback** restores cache backups if re-import fails, preventing application corruption.
- **Full restart fallback** occurs immediately if framework core files (externals) are modified.

## Frequently Asked Questions

### How does Cordis decide between a partial reload and a full restart?

Cordis maintains an `externals` set containing all dependencies reachable from the CLI entry point. If a changed file’s URL exists in this set, the plugin invokes `loader.exit()` to terminate and restart the entire process. Otherwise, it proceeds with `analyzeChanges` to attempt a partial hot reload of only affected plugins.

### Why does Cordis use `Map.prototype.delete` instead of direct map access for the ESM cache?

Node.js versions 22 through 24 implement the internal `loadCache` differently. Using `Map.prototype.get.call(this.internal.loadCache, filename)` and `Map.prototype.delete.call(...)` guarantees consistent cache manipulation across these versions, bypassing potential variations in direct property accessibility.

### What happens if a hot-reloaded plugin fails to import?

The system executes a `rollback` function that restores the previous state of both `esmBackup` and `cjsBackup` into their respective caches. This ensures that the failed module is not left in a partially loaded state and the application continues running with the last known good version of the plugin.

### Does Cordis HMR track dependencies inside `node_modules`?

No. The `loadDependencies` function explicitly filters out URLs containing `/node_modules/` or starting with `node:`. This design choice ensures that updates to third-party libraries do not trigger reloads, focusing the hot-swap mechanism exclusively on user-developed code.