Przeglądaj źródła

fix(web): share pending loads across matching graph notifications

Yichen Jiang 1 tydzień temu
rodzic
commit
b49f57ca12

+ 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: 2b5aa0e2e3a0a7d591fc573f3d4d70404e759b2b
-README.zh.md: d2a46283b50baab3a5ee9d399df87a0cfa4910e0
+README.md: 31dcffaefaffff4c40880873d95f0ce2fcb59db6
+README.zh.md: c7748a00e7a9ac5d80265d34158f3fb412771df4

+ 1 - 1
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 a newer graph arrives. 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. 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
 

+ 1 - 1
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。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。
 
 ### 源码索引
 

+ 10 - 3
packages/client/modules/src/client/entries.ts

@@ -24,6 +24,11 @@ interface ModuleIndex {
 const ACTIVE = 2 as FiberState.ACTIVE
 const FAILED = 3 as FiberState.FAILED
 
+/** Revisions and requests identify desired code; URLs only select its immutable delivery resource. */
+function entryTargets(manifest: BootManifest): string {
+  return JSON.stringify(manifest.modules.map(row => [row.id, row.rev, row.inject, row.external]))
+}
+
 /** Manages only entries created from the Host manifest; other Loader contributors retain ownership. */
 export class ClientEntries {
   /** Stable observable consumed by page diagnostics through the renderer's injected hook. */
@@ -79,13 +84,15 @@ export class ClientEntries {
   }
 
   /**
-   * Validate and apply the latest full Host graph. A later snapshot prevents an older download from mounting.
+   * Validate and apply the latest full Host graph. Changed targets cancel obsolete mounts; identical targets share pending loads.
    * @param graph - JSON-decoded graph received from the Host.
    * @returns after the queued reconciliation; per-package failures remain available in {@link state}.
    */
   sync(graph: unknown): Promise<void> {
-    this.desired = parseBootManifest(graph)
-    const generation = ++this.generation
+    const manifest = parseBootManifest(graph)
+    if (entryTargets(manifest) !== entryTargets(this.desired)) this.generation++
+    this.desired = manifest
+    const generation = this.generation
     return this.enqueue(() => this.reconcile(generation))
   }
 

+ 15 - 0
packages/client/modules/tests/entries.client.spec.ts

@@ -391,3 +391,18 @@ it.each([false, true])('does not mount a materialized factory after a newer remo
   expect(mounted).toBe(rebuild ? 1 : 0)
   expect([...b.ctx.loader.entries()]).toHaveLength(0)
 })
+
+it('coalesces an overlapping graph snapshot with the same rebuilt artifact', async () => {
+  const effects = { mounted: 0, disposed: 0, hits: 0 }
+  const b = await bench(graph(row('a')), { a: visible('a', effects) })
+  const download = deferred()
+  const started = deferred()
+  b.arrival(async () => { started.resolve(); await download.promise })
+  const rebuilding = b.modules.entries.reload('a', 'r1')
+  await started.promise
+  const syncing = b.modules.entries.sync(graph(row('a', 'r1')))
+  download.resolve()
+  await Promise.all([rebuilding, syncing])
+  expect(b.fetched).toEqual(['/batch', row('a', 'r1').url])
+  expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
+})