Unloading and Hot Reload
dsh has two HMR chains:
- Server side (
vendor/hmr): uses chokidar to watch files, walks the dependency graph via Node's internal ModuleLoader loadCache, categorizes files as accepted / declined, clears both ESM + CJS caches, then re-imports and callsregistry.delete→ reload. - Browser side (
packages/client/hmr): stat-polls bundle file mtimes, pushesrebuiltframes via the/plugins/eventsSSE; the client half invalidates → prefetches → does a registry-first teardown →entry.refresh()hot-swaps the fiber.
Server-side HMR
The Hmr Service
class Hmr extends Service at vendor/hmr/src/index.ts:86-125:
static inject = ['loader', 'timer']- Holds
internal: ModuleLoader, three categorization setsexternals/accepted/declined, andstashedpending files
Startup: Service.init
The [Service.init] async generator at vendor/hmr/src/index.ts:199-295:
- Starts the chokidar watcher.
loadDependencies(mainJob)computes externals (the CLI entry's dependency tree; a change triggersloader.exit()— full restart).- Binds
add/change/unlinkroutes.
Externals = CLI entry's dependency tree: any change in an external dependency triggers
loader.exit()— a full-process restart. Framework code does not participate in partial reload.
Reverse Dependency Graph Propagation
analyzeChanges() at vendor/hmr/src/index.ts:345-398: starting from stashed files, propagates backward along getLinked —:
- Any dependent accepted → accepted
- All descendants declined → declined
partialReload
partialReload() at vendor/hmr/src/index.ts:400-449: re-resolves module specifiers for every loader entry; if its dependency tree contains an accepted file, it is included in reloads.
Cache Cleanup and Rebuild
The core flow at vendor/hmr/src/index.ts:461-549:
- Cache cleanup (ESM loadCache + CJS
require.cache) - Re-import
registry.delete(plugin)- For every old fiber,
registry.plugin(plugin, oldFiber._config)rebuilds the fiber and setsentry.fiber = fiber - Failure rolls back the backup
Dual-cache cleanup is the Node 24 compatibility key:
deleteon loadCache only nullifies the slot;Map.prototype.delete.callis required to fully clear it; CJSrequire.cachemust be cleared in lockstep, otherwise CJS modules stay stale.
Single-file Config Watch
registerConfig(filename, refresh) at vendor/hmr/src/index.ts:134-187: single-file watch; findWatchRoot handles non-existent parent directories; refreshConfig serially coalesces + retries in a loop; the disposer closes the watcher and drains.
Browser-side HMR
Host half: stat-poll + SSE
The host-half apply(ctx, config) at packages/client/hmr/src/index.ts:57-191:
statSyncpolls bundle mtime + sizeclientModules.rebuilt(id)re-hashes/plugins/eventsSSE pushes a{type:'rebuilt', id, rev}frame
PluginsEventFrame
The PluginsEventFrame union at packages/client/hmr/src/events.ts:10-16:
{type:'graph'} // full snapshot on connect
{type:'rebuilt', id, rev} // incremental
EVENTS_ENDPOINT = '/plugins/events'Browser half: EventSource
The browser-half apply(ctx) at packages/client/hmr/src/client/index.ts:98-164:
new EventSource(EVENTS_ENDPOINT)handle(frame)→ serial queuereload(id)- invalidate → prefetch → registry.delete →
entry.refresh()
registry-first teardown: Why Order Matters
The module comment at packages/client/hmr/src/client/index.ts:38-63 explains why you cannot directly call entry.fiber.dispose():
Entry.fiberis not cleared on disposerefresh()would no-op withif (this.fiber) return- Loader self-dispose marks the entry
disabled: true— permanently disabled
You must registry.delete(callback) first, then refresh.
The full sequence of reload(id) at packages/client/hmr/src/client/index.ts:104-140:
invalidate(id)
→ prefetch(id)
→ grab entry.fiber
→ registry.delete(runtime.callback) // ← registry-first
→ drain oldFiber.inertia
→ delete entry.fiber
→ removeOwnedStyles(id)
→ entry.refresh()
→ entry.fiber?.await()The "registry-first teardown" comment on the client half is the ready-made narrative for the HMR page:
registry.delete(runtime.callback)must happen before the fiber dispose, otherwise the Loader'sinternal/pluginself-dispose branch marks the entrydisabled: truepermanently, and subsequent refreshes can never bring it back up.
CSS Cleanup
removeOwnedStyles(id) at packages/client/hmr/src/client/index.ts:86-92: document.querySelectorAll('style[data-plugin]') removes styles by strict attribute comparison on id — CSS injection uses a stable tag id, and the idempotency guard kicks in when re-materializing.
Failure Rollback
rollback() restores the ESM + CJS caches, then re-registers the old plugin — the no-rollback policy only applies to runtime failures that cannot be rolled back.
Official Tutorial: HMR debounce
The tutorial at docs/cordis-tutorial/06-composition-and-hmr.md:23-59: HMR debounces via inject: ['timer']; editing hello.ts triggers reload plugin at hello.ts; editing cordis.yml diffs by id for partial re-mounting.
What to read next
- Plugin Registry — what
registry.deleteacts on - Lifecycle and Middleware —
internal/updatewaterfall is the HMR mount point