fix(client-modules): compare host releases before parsing the graph

This commit is contained in:
imccyu committed 2026-10-15 01:35:28 +08:00
1 parent f52abc04fa
commit a7a40f93d9
13 files changed
+87 -45

No files matched your search

@@ -18,14 +18,14 @@
en: 1b6efaf08ab3dabc
zh: 96330f89144c5dbd
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/wire:
en: 3e99fce22c1e52f4
zh: 5c1b5b82fed001f8
en: f15c4106f228f5f6
zh: ab80cf4ce62ae84c
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/page-side:
en: ffe1d95f71f1f4da
zh: ba377dac4bd3a517
en: c0610c8f5a0882f3
zh: d309cbc53183ec6e
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/ui:
en: 90bc33052e84f162
zh: 749bad3e829698e2
en: 4a6d9a0bca594b10
zh: 8d0a715123d9dcbb
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/alternatives-considered:
en: abb9d7ab9ef1df15
zh: 1704732daf5f2886
@@ -61,7 +61,7 @@ BootManifest { ...; release?: WebBootRelease }
- [ClientModuleRegistry](../../../../packages/client/modules/src/index.ts) computes the release once at construction and `compose()` writes it into every graph; `release` stays outside `graph.rev`. There is no new config field and the composition is unchanged.
- `__DSH_BOOT__` and every SSE `graph` frame carry the field, so one graph frame carries both the Host release and its complete module graph; the client-hmr Host half is unchanged. A rebuilt frame comes from the same Host process as the graph frame before it on that SSE connection, so it inherits that frame's release.
- `parseBootManifest` accepts an absent `release`, rejects a non-string `version` or `commit`, and drops members outside the identity, so equality compares only those two fields. Older pages that do not know the field ignore it.
- `parseBootManifest` accepts an absent `release`, rejects a non-string `version` or `commit`, and drops members outside the identity, so equality compares only those two fields. `readRelease` reads only the `release` member of a received graph, so a page compares releases before it validates anything else; a release it cannot read counts as absent. Older pages that do not know the field ignore it.
### Page side
@@ -99,7 +99,7 @@ class ClientEntries {
Order of operations:
1. `ClientEntries` creates the gate from the boot manifest's `release`.
2. `sync(graph)` parses the graph and passes its release to `observe`. A graph of the boot release reconciles as before, and the existing revision comparison replaces exactly the changed modules. A graph of another release returns without entering the reconciliation queue; its first appearance logs the Host's previous and current identities through `console.warn` and publishes the change unless that release was the last one dismissed.
2. `sync(graph)` reads the graph's release with `readRelease` and passes it to `observe` before parsing the rest, so a graph of another release is announced even when this page cannot read its format. A graph of the boot release is parsed and reconciles as before, and the existing revision comparison replaces exactly the changed modules. A graph of another release returns without entering the reconciliation queue; its first appearance logs the Host's previous and current identities through `console.warn` and publishes the change unless that release was the last one dismissed.
3. `reload(id, rev)` (a rebuilt frame) returns at once while the Host serves another release.
4. `retry()` keeps reconciling the last applied graph, which always belongs to the boot release.
5. The UI's inject face reloads the page through `location.reload()`; `ClientEntries` does not navigate.
@@ -111,7 +111,7 @@ Order of operations:
- Copy lives in the zh/en locale dictionaries: a title, one sentence stating that live updates are paused until a reload, and two gray-labelled lines:
- **Service**: `previous` (gray) → `host` (solid), joined by a 1px solid green arrow because the Host already switched.
- **Page**: `page` (solid) → `host` (gray), joined by a 1px dashed gray arrow because only a reload completes the switch. Both connectors are a fixed 56px and do not stretch across the dialog.
- Each line shows versions when its two ends differ in version and commits otherwise; a release or field the Host did not publish shows **Not labeled**. Screen readers read the two lines as text; the arrows are decorative.
- Each line shows versions when its two ends differ in version and commits otherwise; a release or field the page cannot read, including one the Host did not publish, shows **Unknown**. Screen readers read the two lines as text; the arrows are decorative.
- The buttons are **Not now** and **Reload to load the new version** (primary, with `data-modal-autofocus`). The close control, a mask click, and Escape act as **Not now** through the shared `Modal` close behavior.
## Alternatives considered
@@ -61,7 +61,7 @@ BootManifest { ...; release?: WebBootRelease }
- [ClientModuleRegistry](../../../../packages/client/modules/src/index.ts) 在构造时计算一次 release,由 `compose()` 写入每个 graph;`release` 不计入 `graph.rev`。不新增配置项,组合配置不变。
- `__DSH_BOOT__` 和每个 SSE `graph` 帧都带这个字段,所以一个 graph 帧同时携带 Host 的 release 和完整模块图;client-hmr 的 Host 半侧不变。rebuilt 帧与同一条 SSE 连接上前一个 graph 帧来自同一个 Host 进程,因此沿用那一帧的 release。
- `parseBootManifest` 允许 `release` 缺省,拒绝非字符串的 `version` 或 `commit`,并丢弃身份以外的成员,因此相等判断只看这两个字段。不认识该字段的旧页面会忽略它。
- `parseBootManifest` 允许 `release` 缺省,拒绝非字符串的 `version` 或 `commit`,并丢弃身份以外的成员,因此相等判断只看这两个字段。`readRelease` 只读取收到的 graph 的 `release` 成员,所以页面先比较 release,再校验其余内容;读不出的 release 视为缺省。不认识该字段的旧页面会忽略它。
### 页面侧
@@ -99,7 +99,7 @@ class ClientEntries {
调用顺序:
1. `ClientEntries` 以启动 manifest 的 `release` 创建 gate。
2. `sync(graph)` 解析 graph 后把它的 release 交给 `observe`。启动 release 的 graph 照常调和,原有的 revision 对比只替换变化了的模块。其他 release 的 graph 直接返回,不进入调和队列;它首次出现时用 `console.warn` 记录 Host 之前和当前的身份并发布变化,除非它就是最近一次撤回的 release。
2. `sync(graph)` 先用 `readRelease` 读出 graph 的 release 交给 `observe`,再解析其余部分,所以即使页面读不懂另一个 release 的格式也会弹框。启动 release 的 graph 经过解析后照常调和,原有的 revision 对比只替换变化了的模块。其他 release 的 graph 直接返回,不进入调和队列;它首次出现时用 `console.warn` 记录 Host 之前和当前的身份并发布变化,除非它就是最近一次撤回的 release。
3. Host 提供其他 release 期间,`reload(id, rev)`(rebuilt 帧)立即返回。
4. `retry()` 继续调和上次应用的 graph,它始终属于启动 release。
5. UI 的 inject 面通过 `location.reload()` 刷新页面;`ClientEntries` 不负责导航。
@@ -111,7 +111,7 @@ class ClientEntries {
- 文案在中英 locale 字典里:一个标题、一句说明热更新已暂停直到刷新,以及两行灰色标签的版本:
- **服务**:`previous`(灰)→ `host`(实色),1px 绿色实线箭头,表示 Host 已经切换。
- **页面**:`page`(实色)→ `host`(灰),1px 灰色虚线箭头,表示刷新后才会切换。两条连接线固定 56px,不撑满对话框。
- 每行两端版本号不同时显示版本号,否则显示 commit;Host 没发布的 release 或字段显示**未标注**。读屏把两行当文字读,箭头只是装饰。
- 每行两端版本号不同时显示版本号,否则显示 commit;页面读不出的 release 或字段(包括 Host 没发布的)显示**未解析**。读屏把两行当文字读,箭头只是装饰。
- 按钮为**暂不刷新**和**刷新以加载新版本**(主按钮,带 `data-modal-autofocus`)。关闭按钮、点击遮罩和 Escape 通过共用的 `Modal` 关闭行为等同**暂不刷新**。
## Alternatives considered
+6 -11
View File
@@ -2,9 +2,10 @@
import type { FiberState } from '@deepseek-ai/cordis'
import type { Entry, Loader } from '@deepseek-ai/cordis-plugin-loader'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import { parseBootManifest } from './manifest.ts'
import { parseBootManifest, readRelease } from './manifest.ts'
import type { BootManifest, ClientModuleLoader } from './manifest.ts'
import { ReleaseChangeGate, type ClientReleaseChange } from './release-change.ts'
import { notifySubscribers } from './notify.ts'
import { removeOwnedStyles, tearDownEntryFiber } from './entry-lifecycle.ts'
/** Page-local failures do not change the Host's bundle enablement. */
@@ -100,13 +101,14 @@ export class ClientEntries {
/**
* Validate and apply the latest full Host graph. Changed targets cancel obsolete mounts; identical targets share pending loads.
* A graph of another release than the page's boot release is not applied; {@link releaseChange} announces it.
* The release is compared before the rest of the graph is validated: a graph of another release, including one whose
* format this page cannot read, is not applied and {@link releaseChange} announces it.
* @param graph - JSON-decoded graph received from the Host.
* @returns after the queued reconciliation, or at once for another release; per-package failures remain available in {@link state}.
*/
sync(graph: unknown): Promise<void> {
if (!this.release.observe(readRelease(graph))) return Promise.resolve()
const manifest = parseBootManifest(graph)
if (!this.release.observe(manifest.release)) return Promise.resolve()
if (entryTargets(manifest) !== entryTargets(this.desired)) this.generation++
this.desired = manifest
const generation = this.generation
@@ -155,14 +157,7 @@ export class ClientEntries {
private publish(snapshot: ClientEntryState): void {
this.snapshot = snapshot
for (const listener of [...this.listeners]) {
try {
listener()
} catch (error) {
// The page controller has no owning plugin Context for a scoped logger.
console.error('client-modules: synchronization subscriber failed', error)
}
}
notifySubscribers(this.listeners, 'synchronization')
}
private enqueue(task: () => Promise<void>, subject = 'graph'): Promise<void> {
+1 -1
View File
@@ -27,7 +27,7 @@ export type {
BootManifest, BootModuleRow, BootPluginRow, ClientBootstrapModule, ClientBundleRegistration,
ClientModuleCreateOptions, ClientModuleLoader, ClientModuleLoaderTarget, ClientModuleRecord,
ClientModuleSystemOptions, DshWindow,
WebBootRelease, WebBootEntry, WebBootGraph,
WebBootEntry, WebBootGraph, WebBootRelease,
} from './manifest.ts'
/**
+22 -6
View File
@@ -234,7 +234,10 @@ export function parseBootManifest(wire: unknown): BootManifest {
if (typeof graph.rev !== 'string') {
throw new Error('client-modules: boot manifest rev must be a string')
}
const release = parseRelease(graph.release)
const release = decodeRelease(graph.release)
if (release === null) {
throw new Error('client-modules: boot manifest release must carry a string version and an optional string commit')
}
if (!Array.isArray(graph.entries)) {
throw new Error('client-modules: boot manifest entries must be an array')
}
@@ -318,17 +321,30 @@ export function parseBootManifest(wire: unknown): BootManifest {
return { rev: graph.rev, ...(release === undefined ? {} : { release }), modules, plugins }
}
/** Validate the optional wire release identity and drop members outside {@link WebBootRelease}. */
function parseRelease(value: unknown): WebBootRelease | undefined {
/**
* Decode a wire release identity, dropping members outside {@link WebBootRelease}.
* @param value - the graph's `release` member.
* @returns the release, undefined when absent, or null when present but malformed.
*/
function decodeRelease(value: unknown): WebBootRelease | undefined | null {
if (value === undefined) return undefined
const release = typeof value === 'object' && value !== null ? value as Record<string, unknown> : undefined
const commit = release?.commit
if (release === undefined || typeof release.version !== 'string' || (commit !== undefined && typeof commit !== 'string')) {
throw new Error('client-modules: boot manifest release must carry a string version and an optional string commit')
}
if (release === undefined || typeof release.version !== 'string' || (commit !== undefined && typeof commit !== 'string')) return null
return { version: release.version, ...(commit === undefined ? {} : { commit }) }
}
/**
* Read a Host graph's release without validating the rest of the graph, so releases stay comparable
* when another release changes the graph format.
* @param wire - JSON-decoded graph received from the Host.
* @returns the release, or undefined when the graph carries none or it cannot be read.
*/
export function readRelease(wire: unknown): WebBootRelease | undefined {
if (typeof wire !== 'object' || wire === null) return undefined
return decodeRelease((wire as Record<string, unknown>).release) ?? undefined
}
/** Module resolver passed into a registered Client bundle factory. */
export interface ClientBundleRequire {
/** Resolve a module-table dependency synchronously. */
@@ -0,0 +1,17 @@
/** Subscriber notification for the page controller's observable sources. */
/**
* Call every subscriber; one throwing subscriber must not skip the rest.
* @param listeners - current subscribers; copied before dispatch.
* @param label - diagnostic owner named in the error log.
*/
export function notifySubscribers(listeners: ReadonlySet<() => void>, label: string): void {
for (const listener of [...listeners]) {
try {
listener()
} catch (error) {
// The page controller has no owning plugin Context for a scoped logger.
console.error(`client-modules: ${label} subscriber failed`, error)
}
}
}
@@ -1,6 +1,7 @@
/** Which Host graphs a page may apply: only graphs of the release the page booted from. */
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { WebBootRelease } from './manifest.ts'
import { notifySubscribers } from './notify.ts'
/** The connected Host serves another release than the one this page booted from; plugin updates wait for a page reload. */
export interface ClientReleaseChange {
@@ -82,13 +83,6 @@ export class ReleaseChangeGate {
private publish(snapshot: ClientReleaseChange | undefined): void {
this.snapshot = snapshot
for (const listener of [...this.listeners]) {
try {
listener()
} catch (error) {
// The page controller has no owning plugin Context for a scoped logger.
console.error('client-modules: release change subscriber failed', error)
}
}
notifySubscribers(this.listeners, 'release change')
}
}
+2 -2
View File
@@ -34,11 +34,11 @@ import type { Context } from '@deepseek-ai/cordis'
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts'
import type { WebBootBatch, WebBootBatchPhase, WebBootRelease, WebBootEntry, WebBootGraph } from './client/manifest.ts'
import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph, WebBootRelease } from './client/manifest.ts'
export { stripClientSuffix } from './client/manifest.ts'
export type {
BootManifest, BootModuleRow, BootPluginRow, WebBootBatch, WebBootBatchPhase, WebBootRelease, WebBootEntry, WebBootGraph,
BootManifest, BootModuleRow, BootPluginRow, WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph, WebBootRelease,
} from './client/manifest.ts'
declare module '@deepseek-ai/cordis' {
@@ -609,6 +609,20 @@ describe('Host release changes', () => {
expect(effects.mounted).toBe(1)
})
it('announces a graph of another release even when its format cannot be read', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
vi.spyOn(console, 'warn').mockImplementation(() => {})
const b = await bench(built(r1, row('pet')), { pet: visible('pet', effects) })
await b.modules.entries.sync({ rev: 'next', release: r3, entries: 'another format' })
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r1, host: r3 })
// A release that cannot be read counts as absent and still differs from the boot release.
await b.modules.entries.sync({ release: { version: 3 } })
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r3, host: undefined })
expect(effects.mounted).toBe(1)
// A graph of the boot release is still fully validated.
expect(() => b.modules.entries.sync({ rev: 'next', release: r1, entries: 'another format' })).toThrow('entries must be an array')
})
it('treats an absent release as one identity', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const b = await bench(graph(), {})
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { removeOwnedStyles } from '../src/client/entry-lifecycle.ts'
import { readRelease } from '../src/client/manifest.ts'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
@@ -704,7 +705,12 @@ describe('boot manifest wire', () => {
for (const release of ['1.0.0', null, {}, { version: 1 }, { version: '1.0.0', commit: 7 }]) {
expect(() => parseBootManifest({ ...wire, release }))
.toThrow('client-modules: boot manifest release must carry a string version and an optional string commit')
// Release comparison reads no further than the release and reports an unreadable one as absent.
expect(readRelease({ release })).toBeUndefined()
}
expect(readRelease({ release: { version: '1.0.0', commit: 'abc1234', label: 'x' }, entries: 'another format' }))
.toEqual({ version: '1.0.0', commit: 'abc1234' })
expect(readRelease(null)).toBeUndefined()
})
it('rejects malformed batch phases', () => {
@@ -40,7 +40,7 @@ export const zh = {
'hostRelease.description': '服务版本已变化,本页面已暂停热更新,刷新后加载新版本。',
'hostRelease.service': '服务',
'hostRelease.page': '页面',
'hostRelease.unknown': '未标注',
'hostRelease.unknown': '未解析',
'hostRelease.reload': '刷新以加载新版本',
'hostRelease.later': '暂不刷新',
} satisfies Record<string, string>
@@ -88,7 +88,7 @@ export const en = {
'hostRelease.description': 'The service version changed. This page paused live updates; reload to load the new version.',
'hostRelease.service': 'Service',
'hostRelease.page': 'Page',
'hostRelease.unknown': 'Not labeled',
'hostRelease.unknown': 'Unknown',
'hostRelease.reload': 'Reload to load the new version',
'hostRelease.later': 'Not now',
} satisfies Record<SettingsKey, string>
@@ -299,12 +299,12 @@ describe('Host release change confirmation', () => {
cleanup()
prompt({ page: { version: '0.2.1' }, previous: { version: '0.2.1' }, host: { version: '0.2.1', commit: 'bbbbbbb' } }, zh)
expect(screen.getByRole('dialog', { name: '服务版本变化' }).textContent)
.toContain('服务未标注bbbbbbb页面未标注bbbbbbb')
.toContain('服务未解析bbbbbbb页面未解析bbbbbbb')
cleanup()
prompt({ page: undefined, previous: undefined, host: { version: '0.2.1' } })
expect(screen.getByRole('dialog').textContent).toContain('ServiceNot labeled0.2.1PageNot labeled0.2.1')
expect(screen.getByRole('dialog').textContent).toContain('ServiceUnknown0.2.1PageUnknown0.2.1')
cleanup()
prompt({ page: { version: '0.2.1' }, previous: { version: '0.2.1' }, host: undefined })
expect(screen.getByRole('dialog').textContent).toContain('Service0.2.1Not labeledPage0.2.1Not labeled')
expect(screen.getByRole('dialog').textContent).toContain('Service0.2.1UnknownPage0.2.1Unknown')
})
})