فهرست منبع

fix(web): reconcile pending plugin revisions and preserve bootstrap

Yichen Jiang 3 هفته پیش
والد
کامیت
2c9375e509

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: 924459599a63070fc4a3cc89d32ea0e18b936dc2
-2026-07-23-client-plugin-loading-model.zh.md: ab8a19ee92601e6e5038ed764802084cd8082b6f
+2026-07-23-client-plugin-loading-model.md: b32b2ab2b9dd0a37eeba7a63b997d13702e0f443
+2026-07-23-client-plugin-loading-model.zh.md: 9501ade9feead91401ead7e70d9ef3c57a9008f4

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md

@@ -70,7 +70,7 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a
 
 ### Live graph reconciliation
 
-The modules controller owns exactly the Loader entries created from the boot manifest. Full Host snapshots update its module descriptors and reconcile those entries; other Loader contributors retain ownership. New modules arrive through single-resource URLs because replaying a startup batch could register an existing factory twice. Removal uses Loader deletion, then drains the captured fiber before removing unused modules and styles. Declared and observed transitive requests keep shared modules alive.
+The modules controller owns exactly the Loader entries created from the boot manifest. Full Host snapshots update its module descriptors and reconcile those entries; other Loader contributors retain ownership. New modules arrive through single-resource URLs because replaying a startup batch could register an existing factory twice. Removal uses Loader deletion, then drains the captured fiber before removing unused modules and styles. Declared and observed transitive requests keep shared modules alive. Factory revision tracking is independent of entry activation because a download or materialization can finish without creating an entry. Graph updates invalidate stale factories and failed arrival targets without managed entries, clearing their styles before any consumer imports its dependencies; rebuilt frames also reconcile entries missing after import failures.
 
 The Host SSE adapter waits for Loader imports, activation and captured removed-fiber cleanup, then sends the complete current graph, including on reconnect. Graph reconciliation and rebuilt code share one page-owned queue. Local generations prevent stale downloads from mounting; opaque revisions are compared only for equality. The settings inventory reports page-local errors and can retry the same graph without changing Host enablement. This preserves unrelated page state and avoids both an application restart and a second plugin executor. Electron's independent installation flow remains outside this mechanism.
 
@@ -90,7 +90,7 @@ On the browser side, the transport delegates code replacement to the same module
 6. Materialize the new exports through the module system, then call `entry.refresh()` to mount them through Loader. CSS re-injects after the old disposers drain; explicit materialization exposes the import error before Loader can reduce it to a console log.
 7. `fiber.await()` — rethrows loud.
 
-Every plugin shares this one semantics; an `immediately` row reloads exactly like a lazy one. Dependency cascade costs zero client code: a fiber's activation epoch strings its service providers' uids, so replacing a foundational provider such as connection re-loads every dependent through cordis itself — correct, if heavy.
+Bootstrap replacement is rejected before invalidation or teardown: the module system retains its initial exports, so remounting its old code would reset consumers without applying the requested revision. The page reports the required reload and preserves the bootstrap fiber. Every non-bootstrap plugin shares the replacement semantics; an `immediately` row reloads exactly like a lazy one. Dependency cascade costs zero client code: a fiber's activation epoch strings its service providers' uids, so replacing a foundational provider such as connection re-loads every dependent through cordis itself — correct, if heavy.
 
 Reload creates a fresh fiber and component state; it does not preserve React state inside the replaced plugin. Static assembly libraries and the shell require a rebuilt page. Failed imports or activation remain diagnosable and retryable without rolling back unrelated plugins. Self-reload closes the old SSE channel and opens a new one; its full snapshot repairs missed graph changes. Boot, graph updates and rebuilt frames share the same queue, so a code replacement cannot overlap initial module arrival.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -70,7 +70,7 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 
 ### 动态图对账
 
-modules 控制器只持有从启动清单创建的 Loader 条目。Host 的完整快照更新模块描述并对账这些条目;其他 Loader 贡献方保留自身所有权。新增模块通过单资源 URL 到达,因为重放启动 batch 可能重复注册现有 factory。移除使用 Loader 删除语义,随后等待已捕获 fiber 清理完毕,再移除未使用的模块与样式。已声明及已观察到的传递依赖使共享模块保持存活。
+modules 控制器只持有从启动清单创建的 Loader 条目。Host 的完整快照更新模块描述并对账这些条目;其他 Loader 贡献方保留自身所有权。新增模块通过单资源 URL 到达,因为重放启动 batch 可能重复注册现有 factory。移除使用 Loader 删除语义,随后等待已捕获 fiber 清理完毕,再移除未使用的模块与样式。已声明及已观察到的传递依赖使共享模块保持存活。Factory revision 的跟踪独立于条目激活,因为下载或物化完成后仍可能没有创建条目。图更新会在任何消费者导入依赖前,使未归属受管条目的陈旧 factory 和失败的到达目标失效,并清除其样式;重建帧也会对账因导入失败而缺失的条目。
 
 Host SSE 适配器等待 Loader 导入、激活及已捕获的被移除 fiber 清理完成后发送当前完整图,重连也使用同一路径。图对账与代码重建共用一个页面队列。本地代际阻止过期下载挂载;不透明 revision 只比较相等。失败页面报告本地错误,并可重试同一张图而不改变 Host 启用状态。这保留了无关页面状态,也无需重启应用或引入第二套插件执行器。Electron 的独立安装流程不属于此机制。
 
@@ -90,7 +90,7 @@ Host SSE 适配器等待 Loader 导入、激活及已捕获的被移除 fiber 
 6. 通过模块系统物化新导出,再调用 `entry.refresh()` 由 Loader 挂载。CSS 在旧 disposer 清理完成后重新注入;显式物化使导入错误能够被捕获,而不只留下 Loader 的控制台日志。
 7. `fiber.await()`——让失败大声重抛。
 
-每个插件都共享同一套语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber 时,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高。
+Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始导出,重新挂载旧代码会重置消费者,却无法应用请求的 revision。页面会报告需要刷新,并保留 bootstrap fiber。所有非 bootstrap 插件都共享同一套替换语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber 时,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高。
 
 重载会创建新的 fiber 和组件状态,不保留被替换插件内部的 React 状态。静态组装库与应用壳需要重建后的页面。导入或激活失败仍可诊断和重试,不会回滚无关插件。自重载关闭旧 SSE 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠。
 

+ 76 - 1
apps/web/tests/client-plugin-live.e2e.ts

@@ -1,8 +1,10 @@
 /** Real profile, Remote, bundle scripts and Cordis slots: page-local client lifecycle without navigation. */
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { chromium, type Page } from 'playwright'
-import { expect, it, onTestFailed } from 'vitest'
+import { expect, it, onTestFailed, onTestFinished } from 'vitest'
 import { launchWebScaffold, watchConsole, captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts'
 import { saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
 
@@ -129,3 +131,76 @@ it('keeps a failed client download local and retries without changing Host enabl
     await scaffold.close()
   }
 }, 90_000)
+
+it('recovers an uncreated client entry with rebuilt factory code without navigation', async () => {
+  const fixture = await mkdtemp(join(tmpdir(), 'dsh-client-rebuild-'))
+  // Finished hooks unwind in reverse order, so the Host closes before its fixture is removed.
+  onTestFinished(() => rm(fixture, { recursive: true, force: true }))
+  await cp(FIXTURE, fixture, { recursive: true })
+  const file = join(fixture, 'client.js')
+  const source = await readFile(file, 'utf8')
+  const broken = source.replace("const React = require('react')", "throw new Error('fixture r0 factory failed')")
+  expect(broken).not.toBe(source)
+  await writeFile(file, broken)
+  const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(fixture, 'package.json')] })
+  onTestFinished(() => scaffold.close())
+  const host = scaffold.ctx.loader.ctx.fiber.uid
+  const browser = await chromium.launch()
+  try {
+    const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-client-factory-rebuild'))
+    const inventory = await openInventory(page, scaffold.authenticatedUrl)
+    const draft = inventory.getByRole('searchbox', { name: '搜索插件' })
+    await draft.fill('unfinished-filter')
+    let navigations = 0
+    page.on('framenavigated', () => { navigations++ })
+    const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
+    const failure = page.locator('[data-client-sync-failure]')
+    await failure.getByText(/fixture r0 factory failed/).waitFor()
+    const rebuilt = source.replace('动态插件已启用', '动态插件 r1 已启用').replace('Live plugin enabled', 'Live plugin r1 enabled')
+    await writeFile(file, rebuilt)
+    scaffold.ctx.clientModules.rebuilt('@fixture/live-client')
+    await page.getByText('动态插件 r1 已启用', { exact: true }).waitFor()
+    await expect.poll(() => failure.count()).toBe(0)
+    await compareOrRefreshGolden(join(EXPECTED, 'recovered.expected.md'), await captureStableAria(page, '[data-live-client]', scaffold.workspaceCwd), webSnapshotMode())
+    expect(await draft.inputValue()).toBe('unfinished-filter')
+    expect(await page.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(1)
+    expect(await page.evaluate(() => document.documentElement.dataset.liveMounts)).toBe('1')
+    expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
+    expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
+    expect(navigations).toBe(0)
+  } finally {
+    await browser.close()
+  }
+})
+
+it('reports bootstrap rebuilds without remounting the settings page or navigating', async () => {
+  const scaffold = await launchWebScaffold()
+  onTestFinished(() => scaffold.close())
+  const host = scaffold.ctx.loader.ctx.fiber.uid
+  const browser = await chromium.launch()
+  try {
+    const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
+    const console = watchConsole(page)
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-client-bootstrap-rebuild'))
+    const inventory = await openInventory(page, scaffold.authenticatedUrl)
+    const draft = inventory.getByRole('searchbox', { name: '搜索插件' })
+    await draft.fill('unfinished-filter')
+    const originalInput = await draft.elementHandle()
+    let navigations = 0
+    page.on('framenavigated', () => { navigations++ })
+    scaffold.ctx.clientModules.rebuilt('@deepseek-ai/dsh-client-modules')
+    const failure = page.locator('[data-client-sync-failure]')
+    await failure.getByText(/replacing bootstrap module .* requires a page reload/).waitFor()
+    await failure.getByRole('button', { name: '重试本页面同步' }).click()
+    await failure.getByText(/replacing bootstrap module .* requires a page reload/).waitFor()
+    await compareOrRefreshGolden(join(EXPECTED, 'bootstrap-rebuild.expected.md'), await captureStableAria(page, '[data-client-sync-failure]', scaffold.workspaceCwd), webSnapshotMode())
+    expect(await originalInput!.evaluate(input => input.isConnected)).toBe(true)
+    expect(await draft.inputValue()).toBe('unfinished-filter')
+    expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
+    expect(navigations).toBe(0)
+    expect(console.pageErrors).toEqual([])
+  } finally {
+    await browser.close()
+  }
+})

+ 4 - 0
apps/web/tests/expected/client-plugin-live/bootstrap-rebuild.expected.md

@@ -0,0 +1,4 @@
+- alert: 本页面的插件未能完成同步;服务端的启用状态保持不变。
+- list:
+  - listitem: "@deepseek-ai/dsh-client-modules: Error: client-modules: replacing bootstrap module @deepseek-ai/dsh-client-modules requires a page reload"
+- button "重试本页面同步"

+ 1 - 0
apps/web/tests/expected/client-plugin-live/recovered.expected.md

@@ -0,0 +1 @@
+- text: 动态插件 r1 已启用

+ 2 - 2
packages/client/modules/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/modules/README.md
-README.md: 8470935beabf8007850e26c8274c792ddf2adc1a
-README.zh.md: 06b1b0d7bc19465d34ed0d814162fcd7ed1dc2dc
+README.md: 6e5bb593401f63704b1ae9fd1015d4c1b073a624
+README.zh.md: 3dd17a5fe9b2c70150c55760634b968391db8af5

+ 2 - 2
packages/client/modules/README.md

@@ -81,7 +81,7 @@ The host contributes structured index rows that inject, into `<head>`: the `wind
 
 ### Entry ownership
 
-`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available.
+`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Factories retain their artifact revision before an entry exists; graph updates discard stale unowned factories, their styles and failed arrival targets before importing consumers. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available.
 
 ### Source map
 
@@ -128,7 +128,7 @@ None; this package neither assembles nor sends a provider request.
 These limits define what the module system does not do. They are current package constraints, not a task backlog.
 
 - **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change.
-- **Bootstrap and code replacement limits** — the page retains its modules bootstrap and static platform identities. Removing the bootstrap requires a page reload; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
+- **Bootstrap and code replacement limits** — the page retains its modules bootstrap and static platform identities. Removing or replacing the bootstrap requires a page reload; live replacement requests report a page-local error while retaining its fiber and exports; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
 - **Snapshot delivery retains artifact bytes** — the Host holds each bundle, optional source map, generated one-resource response, and current startup combo responses in memory; HMR additionally retains one prior startup generation. Memory scales as several copies of the composed client artifacts in exchange for immutable responses and one-generation race tolerance.
 
 <a id="dev-note"></a>

+ 2 - 2
packages/client/modules/README.zh.md

@@ -81,7 +81,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
 
 ### 条目所有权
 
-`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。
+`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。Factory 在条目创建前就保留产物 revision;图更新会在导入消费者前丢弃未归属条目的陈旧 factory、其样式和失败的到达目标。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。
 
 ### 源码索引
 
@@ -128,7 +128,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
 这些限制说明模块系统不做什么。它们是当前包约束,不是任务积压。
 
 - **有意采用扁平模块图**——每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
-- **Bootstrap 与代码替换限制**——页面保留 modules bootstrap 和静态平台模块的身份。移除 bootstrap 需要刷新页面;替换包代码及其所有现有消费者不属于普通启停同步。
+- **Bootstrap 与代码替换限制**——页面保留 modules bootstrap 和静态平台模块的身份。移除或替换 bootstrap 需要刷新页面;动态替换请求会报告页面本地错误,并保留其 fiber 与导出;替换包代码及其所有现有消费者不属于普通启停同步。
 - **快照式提供会保留产物字节**——Host 在内存中保留每个 bundle、可选 source map、生成的单资源响应和当前启动 combo 响应;HMR 还会保留上一代启动响应。内存会随已组合客户端产物增长为数份副本,以换取不可变响应和一代竞态容忍。
 
 <a id="dev-note"></a>

+ 16 - 7
packages/client/modules/src/client/entries.ts

@@ -14,9 +14,10 @@ export interface ClientEntryState {
   readonly failures: readonly { readonly id: string; readonly message: string }[]
 }
 
-/** Internal capabilities owned by the module table, called only after serialized entry operations. */
+/** Module-table capabilities used within serialized entry operations. */
 interface ModuleIndex {
-  update(manifest: BootManifest): void
+  update(manifest: BootManifest, managed: Iterable<string>): void
+  invalidateForReplacement(id: string, rev: string): void
   prune(roots: Iterable<string>): void
 }
 
@@ -107,9 +108,10 @@ export class ClientEntries {
 
   /**
    * Replace one entry's code in the same queue as graph updates; duplicate revisions are ignored.
+   * Entries missing after a failed import are reconciled; bootstrap replacement fails before teardown.
    * @param id - Package id from a rebuilt frame.
    * @param rev - Opaque revision selecting the rebuilt artifact.
-   * @returns after replacement settles; failure rejects and is exposed in page diagnostics.
+   * @returns after queued work; replacement errors reject, while per-package reconciliation errors remain in {@link state}.
    */
   reload(id: string, rev: string): Promise<void> {
     this.desired = {
@@ -117,9 +119,16 @@ export class ClientEntries {
       modules: this.desired.modules.map(row => row.id === id ? { ...row, rev } : row),
     }
     return this.enqueue(async () => {
-      if (this.stopped || !this.desired.modules.some(row => row.id === id)) return
+      const desired = this.desired.modules.find(row => row.id === id)
+      if (this.stopped || desired === undefined) return
       const entry = this.managed.get(id)
-      if (entry === undefined || this.revisions.get(id) === rev) return
+      if (entry === undefined) {
+        this.modules.invalidate(id, desired.rev)
+        removeOwnedStyles(id)
+        await this.reconcile(this.generation)
+        return
+      }
+      if (this.revisions.get(id) === rev) return
       this.publish({ syncing: true, failures: this.snapshot.failures.filter(failure => failure.id !== id) })
       await this.replace(entry, id, rev, this.generation)
       this.publish({ syncing: false, failures: this.snapshot.failures })
@@ -166,7 +175,7 @@ export class ClientEntries {
   }
 
   private async replace(entry: Entry, id: string, rev: string, generation: number): Promise<void> {
-    this.modules.invalidate(id, rev)
+    this.index.invalidateForReplacement(id, rev)
     await this.modules.prefetch(id)
     if (!this.current(generation)) return
     await tearDownEntryFiber(entry)
@@ -187,7 +196,7 @@ export class ClientEntries {
     const manifest = this.desired
     this.publish({ syncing: true, failures: [] })
     const failures: { id: string; message: string }[] = []
-    this.index.update(manifest)
+    this.index.update(manifest, this.managed.keys())
     const wanted = new Set(manifest.plugins.map(row => row.id))
     for (const [id, entry] of this.managed) {
       if (wanted.has(id)) continue

+ 31 - 14
packages/client/modules/src/client/system.ts

@@ -67,12 +67,12 @@ export class ClientModuleSystem implements ClientModuleLoader {
   readonly loadCache = new Map<string, ClientModuleRecord>()
 
   private readonly seed: Map<string, unknown>
-  private readonly factories = new Map<string, ClientBundleRegistration['factory']>()
+  private readonly factories = new Map<string, { factory: ClientBundleRegistration['factory']; rev: string | undefined }>()
   private readonly bootstrapIds = new Set<string>()
   /** In-flight script transport per URL; every row in one batch shares it. */
   private readonly pendingArrival = new Map<string, Promise<void>>()
   /** Single-resource combo URL selected by HMR after invalidating one row. */
-  private readonly reloadUrls = new Map<string, string>()
+  private readonly reloadTargets = new Map<string, { url: string; rev: string }>()
   /** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
   private readonly materializing = new Set<string>()
   private readonly graphRows = new Map<string, BootModuleRow>()
@@ -85,7 +85,11 @@ export class ClientModuleSystem implements ClientModuleLoader {
   constructor(options: ClientModuleSystemOptions) {
     this.manifest = options.manifest
     this.entries = new ClientEntries(this, {
-      update: (manifest) => { this.updateManifest(manifest) },
+      update: (manifest, managed) => { this.updateManifest(manifest, managed) },
+      invalidateForReplacement: (id, rev) => {
+        if (this.bootstrapIds.has(id)) throw new Error(`client-modules: replacing bootstrap module ${id} requires a page reload`)
+        this.invalidate(id, rev)
+      },
       prune: (roots) => { this.prune(roots) },
     })
     this.seed = new Map(Object.entries(options.staticModules))
@@ -122,15 +126,18 @@ export class ClientModuleSystem implements ClientModuleLoader {
     if (this.bootstrapIds.has(id) || this.factories.has(id)) {
       throw new Error(`client-modules: duplicate factory registration for "${registration.id}" (bundle executed twice without invalidate?)`)
     }
-    this.factories.set(id, registration.factory)
+    this.factories.set(id, {
+      factory: registration.factory,
+      rev: this.reloadTargets.get(id)?.rev ?? this.graphRows.get(id)?.rev,
+    })
   }
 
   /** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
   private arrive(row: BootModuleRow): Promise<void> {
     const { id } = row
     if (this.loadCache.has(id) || this.factories.has(id)) return Promise.resolve()
-    const reloadUrl = this.reloadUrls.get(id)
-    const url = reloadUrl ?? row.initialUrl
+    const reload = this.reloadTargets.get(id)
+    const url = reload?.url ?? row.initialUrl
     let transport = this.pendingArrival.get(url)
     if (transport === undefined) {
       transport = this.loadBundle(url).finally(() => { this.pendingArrival.delete(url) })
@@ -140,8 +147,8 @@ export class ClientModuleSystem implements ClientModuleLoader {
       if (!this.factories.has(id)) {
         throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
       }
-      if (reloadUrl !== undefined && this.reloadUrls.get(id) === reloadUrl) {
-        this.reloadUrls.delete(id)
+      if (reload !== undefined && this.reloadTargets.get(id) === reload) {
+        this.reloadTargets.delete(id)
       }
     })
   }
@@ -188,7 +195,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
     this.materializing.add(id)
     try {
       const edges = new Set<string>()
-      const exports = registered(this.makeRequire(edges))
+      const exports = registered.factory(this.makeRequire(edges))
       const record: ClientModuleRecord = { id, exports, styles: claimStyles(id), edges }
       this.loadCache.set(id, record)
       return record
@@ -246,14 +253,22 @@ export class ClientModuleSystem implements ClientModuleLoader {
     await this.arriveGraphRow(row)
   }
 
-  /** Replace descriptors without invalidating live factories; subsequent arrivals use individual resources. */
-  private updateManifest(manifest: BootManifest): void {
+  /** Refresh descriptors and unowned factory revisions before any entry imports its dependencies. */
+  private updateManifest(manifest: BootManifest, managed: Iterable<string>): void {
     for (const id of this.bootstrapIds) {
       if (this.manifest.modules.some(row => row.id === id) && !manifest.modules.some(row => row.id === id)) {
         throw new Error(`client-modules: removing bootstrap module ${id} requires a page reload`)
       }
     }
-    for (const row of manifest.modules) this.graphRows.set(row.id, { ...row, initialUrl: row.url })
+    const owned = new Set(managed)
+    for (const row of manifest.modules) {
+      this.graphRows.set(row.id, { ...row, initialUrl: row.url })
+      const cachedRevision = this.factories.get(row.id)?.rev ?? this.reloadTargets.get(row.id)?.rev
+      if (!owned.has(row.id) && cachedRevision !== undefined && cachedRevision !== row.rev) {
+        this.invalidate(row.id, row.rev)
+        removeOwnedStyles(row.id)
+      }
+    }
     this.manifest = manifest
   }
 
@@ -283,8 +298,10 @@ export class ClientModuleSystem implements ClientModuleLoader {
     const normalized = stripClientSuffix(id)
     if (this.bootstrapIds.has(normalized)) return
     const row = this.graphRows.get(normalized)
-    if (row !== undefined) this.reloadUrls.set(normalized, atRevision(row.url, rev ?? row.rev))
-    else this.reloadUrls.delete(normalized)
+    if (row !== undefined) {
+      const revision = rev ?? row.rev
+      this.reloadTargets.set(normalized, { url: atRevision(row.url, revision), rev: revision })
+    } else this.reloadTargets.delete(normalized)
     this.factories.delete(normalized)
     this.loadCache.delete(normalized)
   }

+ 112 - 4
packages/client/modules/tests/entries.client.spec.ts

@@ -2,7 +2,7 @@
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { createClientModuleSystem } from '../src/client/index.ts'
+import { apply as provideModules, createClientModuleSystem } from '../src/client/index.ts'
 import type { ClientBundleRegistration, ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
 
 const contexts: Context[] = []
@@ -35,16 +35,17 @@ async function bench(initial: WebBootGraph, factories: Record<string, ClientBund
   const fetched: string[] = []
   const target: ClientModuleLoaderTarget = {
     mode: 'queue', pendingQueue: [], load: () => {},
-    create: options => createClientModuleSystem(target, { id: 'bootstrap', exports: { apply() {} } }, options),
+    create: options => createClientModuleSystem(target, { id: 'bootstrap', exports: { inject: ['loader'], apply: provideModules } }, options),
   }
   let arrival: (url: string) => Promise<void> = async () => {}
   const modules = target.create({
     boot: initial, staticModules: {},
     loadBundle: async (url) => {
       fetched.push(url)
+      const ids = url === '/batch' ? initial.entries.map(row => row.id).filter(id => id !== 'bootstrap') : [url.split('??')[1]!.split('/client.js')[0]!]
+      const registrations = ids.map(id => ({ id, factory: factories[id]! }))
       await arrival(url)
-      const ids = url === '/batch' ? initial.entries.map(row => row.id) : [url.split('??')[1]!.split('/client.js')[0]!]
-      for (const id of ids) target.load({ id, factory: factories[id]! })
+      for (const registration of registrations) target.load(registration)
     },
   })
   await ctx.plugin(Loader)
@@ -406,3 +407,110 @@ it('coalesces an overlapping graph snapshot with the same rebuilt artifact', asy
   expect(b.fetched).toEqual(['/batch', row('a', 'r1').url])
   expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
 })
+
+it.each(['graph', 'rebuilt'])('replaces a failed factory before entry creation on a new %s revision', async (source) => {
+  const effects = { mounted: 0, disposed: 0, hits: 0 }
+  const factories: Record<string, ClientBundleRegistration['factory']> = { a: () => { throw new Error('broken r0 factory') } }
+  const b = await bench(graph(), factories)
+  await b.modules.entries.sync(graph(row('a')))
+  expect([...b.ctx.loader.entries()]).toHaveLength(0)
+  expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('broken r0 factory')
+  factories.a = () => ({ ...visible('a', effects)(), revision: 'r1' })
+  if (source === 'graph') await b.modules.entries.sync(graph(row('a', 'r1')))
+  else await b.modules.entries.reload('a', 'r1')
+  await b.modules.entries.retry()
+  expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url])
+  expect(await b.modules.import('a')).toHaveProperty('revision', 'r1')
+  expect(document.querySelectorAll('[data-live=a]')).toHaveLength(1)
+  expect(effects.mounted).toBe(1)
+  expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
+})
+
+it('replaces a superseded arrival and its cached dependency before mounting the latest code', async () => {
+  const dependency = row('dependency')
+  const consumer = row('consumer', 'r0', { external: ['dependency/client'] })
+  const factories: Record<string, ClientBundleRegistration['factory']> = {
+    dependency: () => ({ apply() {}, revision: 'r0' }),
+    consumer: require => ({ apply() {}, dependency: require('dependency/client'), revision: 'r0' }),
+  }
+  const b = await bench(graph(), factories)
+  const started = deferred()
+  const download = deferred()
+  b.arrival(async (url) => { if (url === consumer.url) { started.resolve(); await download.promise } })
+  const old = b.modules.entries.sync(graph(consumer, dependency))
+  try {
+    await started.promise
+    factories.dependency = () => ({ apply() {}, revision: 'r1' })
+    factories.consumer = require => ({ apply() {}, dependency: require('dependency/client'), revision: 'r1' })
+    const latest = b.modules.entries.sync(graph(row('consumer', 'r1', { external: ['dependency/client'] }), row('dependency', 'r1')))
+    download.resolve()
+    await Promise.all([old, latest])
+    expect(await b.modules.import('consumer')).toMatchObject({ revision: 'r1', dependency: { revision: 'r1' } })
+    expect(b.fetched).toEqual([dependency.url, consumer.url, row('dependency', 'r1').url, row('consumer', 'r1').url])
+    expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
+  } finally {
+    download.resolve()
+    await old
+  }
+})
+
+it.each(['graph', 'rebuilt'])('preserves bootstrap and dependent fibers when a %s requests new bootstrap code', async (source) => {
+  const effects = { mounted: 0, disposed: 0, hits: 0 }
+  const b = await bench(graph(row('bootstrap'), row('consumer')), {
+    consumer: () => ({ ...visible('consumer', effects)(), inject: ['modules'] }),
+  })
+  const fibers = [...b.ctx.loader.entries()].map(entry => entry.fiber)
+  const exports = await b.modules.import('bootstrap')
+  if (source === 'graph') await b.modules.entries.sync(graph(row('bootstrap', 'r1'), row('consumer')))
+  else await expect(b.modules.entries.reload('bootstrap', 'r1')).rejects.toThrow('requires a page reload')
+  for (let retry = 0; retry < 2; retry++) {
+    await b.modules.entries.retry()
+    expect(b.modules.entries.state.getSnapshot().failures).toEqual([
+      { id: 'bootstrap', message: 'Error: client-modules: replacing bootstrap module bootstrap requires a page reload' },
+    ])
+  }
+  expect([...b.ctx.loader.entries()].map(entry => entry.fiber)).toEqual(fibers)
+  expect(await b.modules.import('bootstrap')).toBe(exports)
+  expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
+  expect(b.fetched).toEqual(['/batch'])
+})
+
+it('discards a failed arrival target when an uncreated entry receives a newer graph', async () => {
+  const b = await bench(graph(), { a: () => { throw new Error('r0 factory') } })
+  await b.modules.entries.sync(graph(row('a')))
+  b.arrival(async () => { throw new Error('offline r1') })
+  await b.modules.entries.reload('a', 'r1')
+  expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('offline r1')
+  b.arrival(async () => {})
+  await b.modules.entries.sync(graph(row('a', 'r2')))
+  expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url, row('a', 'r2').url])
+})
+
+it('uses the latest desired revision when a rebuild queues before entry creation', async () => {
+  const b = await bench(graph(), { a: () => ({ apply() {} }) })
+  const adding = b.modules.entries.sync(graph(row('a')))
+  const rebuilding = b.modules.entries.reload('a', 'r1')
+  const latest = b.modules.entries.sync(graph(row('a', 'r2')))
+  await Promise.all([adding, rebuilding, latest])
+  expect(b.fetched).toEqual([row('a', 'r2').url])
+  expect([...b.ctx.loader.entries()]).toHaveLength(1)
+})
+
+it('cleans styles from a materialized factory superseded before its entry is created', async () => {
+  const effects = { mounted: 0, disposed: 0, hits: 0 }
+  let latest: Promise<void> | undefined
+  const factories: Record<string, ClientBundleRegistration['factory']> = { a: () => {
+    const old = visible('a', effects)()
+    queueMicrotask(() => {
+      factories.a = visible('a', effects)
+      latest = b.modules.entries.sync(graph(row('a', 'r1')))
+    })
+    return old
+  } }
+  const b = await bench(graph(), factories)
+  await b.modules.entries.sync(graph(row('a')))
+  await latest
+  expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url])
+  expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
+  expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(1)
+})