From a7a40f93d9e78ebf577dbcd0f79a7e475366d4ef Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:35:28 +0800 Subject: [PATCH] fix(client-modules): compare host releases before parsing the graph --- ...st-release-change-refresh-prompt.i18n.yaml | 12 ++++---- ...0-14-host-release-change-refresh-prompt.md | 6 ++-- ...4-host-release-change-refresh-prompt.zh.md | 6 ++-- packages/client/modules/src/client/entries.ts | 17 ++++------- packages/client/modules/src/client/index.ts | 2 +- .../client/modules/src/client/manifest.ts | 28 +++++++++++++++---- packages/client/modules/src/client/notify.ts | 17 +++++++++++ .../modules/src/client/release-change.ts | 10 ++----- packages/client/modules/src/index.ts | 4 +-- .../modules/tests/entries.client.spec.ts | 14 ++++++++++ .../modules/tests/loader.client.spec.ts | 6 ++++ .../ui-settings-general/src/client/locales.ts | 4 +-- .../tests/components.client.spec.tsx | 6 ++-- 13 files changed, 87 insertions(+), 45 deletions(-) create mode 100644 packages/client/modules/src/client/notify.ts diff --git a/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.i18n.yaml b/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.i18n.yaml index 8bf4aca027..596a862075 100644 --- a/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.md b/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.md index 658aeb52d0..8700d365fc 100644 --- a/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.md +++ b/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.zh.md b/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.zh.md index 6a6bd6b55d..58029194d9 100644 --- a/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.zh.md +++ b/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.zh.md @@ -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 diff --git a/packages/client/modules/src/client/entries.ts b/packages/client/modules/src/client/entries.ts index 41c8c00f9b..587d3ffdd0 100644 --- a/packages/client/modules/src/client/entries.ts +++ b/packages/client/modules/src/client/entries.ts @@ -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 { + 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, subject = 'graph'): Promise { diff --git a/packages/client/modules/src/client/index.ts b/packages/client/modules/src/client/index.ts index 8e360041a4..5be8baa095 100644 --- a/packages/client/modules/src/client/index.ts +++ b/packages/client/modules/src/client/index.ts @@ -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' /** diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts index 047d3e5219..1926d812a1 100644 --- a/packages/client/modules/src/client/manifest.ts +++ b/packages/client/modules/src/client/manifest.ts @@ -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 : 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).release) ?? undefined +} + /** Module resolver passed into a registered Client bundle factory. */ export interface ClientBundleRequire { /** Resolve a module-table dependency synchronously. */ diff --git a/packages/client/modules/src/client/notify.ts b/packages/client/modules/src/client/notify.ts new file mode 100644 index 0000000000..1e658872b6 --- /dev/null +++ b/packages/client/modules/src/client/notify.ts @@ -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) + } + } +} diff --git a/packages/client/modules/src/client/release-change.ts b/packages/client/modules/src/client/release-change.ts index da82dab877..acf6ba4432 100644 --- a/packages/client/modules/src/client/release-change.ts +++ b/packages/client/modules/src/client/release-change.ts @@ -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') } } diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index 45fc73bff3..8bd8e6ed4c 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -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' { diff --git a/packages/client/modules/tests/entries.client.spec.ts b/packages/client/modules/tests/entries.client.spec.ts index 63ad92d8fa..dc908bea33 100644 --- a/packages/client/modules/tests/entries.client.spec.ts +++ b/packages/client/modules/tests/entries.client.spec.ts @@ -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(), {}) diff --git a/packages/client/modules/tests/loader.client.spec.ts b/packages/client/modules/tests/loader.client.spec.ts index 0a2e80c209..ffa3fe10e9 100644 --- a/packages/client/modules/tests/loader.client.spec.ts +++ b/packages/client/modules/tests/loader.client.spec.ts @@ -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', () => { diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index c3ba5732b7..f12d407a74 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -40,7 +40,7 @@ export const zh = { 'hostRelease.description': '服务版本已变化,本页面已暂停热更新,刷新后加载新版本。', 'hostRelease.service': '服务', 'hostRelease.page': '页面', - 'hostRelease.unknown': '未标注', + 'hostRelease.unknown': '未解析', 'hostRelease.reload': '刷新以加载新版本', 'hostRelease.later': '暂不刷新', } satisfies Record @@ -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 diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 5e1b76fad6..eae05f85ef 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -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') }) })