Skip to content
源码版本47f9438 (dsh@0.1)

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 calls registry.delete → reload.
  • Browser side (packages/client/hmr): stat-polls bundle file mtimes, pushes rebuilt frames via the /plugins/events SSE; 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 sets externals / accepted / declined, and stashed pending files

Startup: Service.init

The [Service.init] async generator at vendor/hmr/src/index.ts:199-295:

  1. Starts the chokidar watcher.
  2. loadDependencies(mainJob) computes externals (the CLI entry's dependency tree; a change triggers loader.exit() — full restart).
  3. Binds add / change / unlink routes.

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:

  1. Cache cleanup (ESM loadCache + CJS require.cache)
  2. Re-import
  3. registry.delete(plugin)
  4. For every old fiber, registry.plugin(plugin, oldFiber._config) rebuilds the fiber and sets entry.fiber = fiber
  5. Failure rolls back the backup

Dual-cache cleanup is the Node 24 compatibility key: delete on loadCache only nullifies the slot; Map.prototype.delete.call is required to fully clear it; CJS require.cache must 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:

  1. statSync polls bundle mtime + size
  2. clientModules.rebuilt(id) re-hashes
  3. /plugins/events SSE pushes a {type:'rebuilt', id, rev} frame

PluginsEventFrame

The PluginsEventFrame union at packages/client/hmr/src/events.ts:10-16:

ts
{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:

  1. new EventSource(EVENTS_ENDPOINT)
  2. handle(frame) → serial queue reload(id)
  3. 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.fiber is not cleared on dispose
  • refresh() would no-op with if (this.fiber) return
  • Loader self-dispose marks the entry disabled: truepermanently 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's internal/plugin self-dispose branch marks the entry disabled: true permanently, 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.

Unofficial community learning site. Content based on the MIT-licensed deepseek-ai/deepseek-harness source. · Privacy · Terms · About