From d6245fc25495f0e8c09039ea93e5297bb2f674af Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:13:58 +0800 Subject: [PATCH] fix(inspector): update Cordis DOM incrementally --- ...4-cordis-runtime-tree-inspection.i18n.yaml | 4 +- ...26-08-24-cordis-runtime-tree-inspection.md | 10 +- ...08-24-cordis-runtime-tree-inspection.zh.md | 10 +- .../experimental/inspector/README.i18n.yaml | 4 +- packages/experimental/inspector/README.md | 2 + packages/experimental/inspector/README.zh.md | 2 + .../src/worker/cdp/domains/dom/index.ts | 2 +- .../src/worker/cdp/domains/dom/model.ts | 122 ++++++++++++++--- .../src/worker/cdp/domains/dom/session.ts | 99 +++++++++++++- .../inspector/tests/cordis-tree.host.spec.ts | 127 +++++++++++++++++- .../tests/fixtures/client-source.client.ts | 27 +++- .../tests/fixtures/client-source.host.ts | 15 +++ 12 files changed, 383 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.i18n.yaml index baaec4c1f1..c7ce5ddb76 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.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-08-24-cordis-runtime-tree-inspection.md -2026-08-24-cordis-runtime-tree-inspection.md: 49a0bb4de34cf2a3c9f2943b432a62051ae85a9d -2026-08-24-cordis-runtime-tree-inspection.zh.md: b08b8fcb8d2bbed624be4dd5273406c856393e62 +2026-08-24-cordis-runtime-tree-inspection.md: 9784018a050bdb27e791a44e1cd342a31cec42c0 +2026-08-24-cordis-runtime-tree-inspection.zh.md: 05b9b0d4387447b7c8df05b7c7e771e96c767bd9 diff --git a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md index 49a0bb4de3..9784018a05 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md +++ b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md @@ -39,7 +39,7 @@ The identities are intentionally distinct: - Fiber `uid` comes from Cordis. Context currently has no Cordis-owned id and the Inspector does not expose a generated substitute. - `InspectorObjectReference` is an opaque realm-local handle resolving a tree node to its live Context or Fiber. Snapshots carry the handle for routing, never as a semantic id or DOM attribute. - `BackendNodeId` is assigned by the Worker to one retained `(source id, source generation, object reference)` and is shared by DevTools connections while that generation's snapshot is retained. -- `NodeId` is assigned per DevTools connection when a node enters that frontend's document. It is discarded on `DOM.documentUpdated` or connection close. +- `NodeId` is assigned per DevTools connection when a node enters that frontend's document. It remains stable while the corresponding backend node is retained and is discarded when that node leaves the tree, on the rare full-document fallback, or when the connection closes. - `RemoteObjectId` is assigned by the selected Runtime session when `DOM.resolveNode` exposes the live object. It remains scoped to that DevTools connection and object group. `sourceId` identifies one Client runtime instance and remains stable across its automatic transport reconnects; `generation` identifies one WebSocket admission. Disconnect removes the synthetic context from the Console with `Runtime.executionContextDestroyed`. Reconnection announces a fresh CDP execution-context id because the destroyed id and its RemoteObjects cannot be reused, but this does not imply that the browser's underlying JavaScript realm was recreated. @@ -62,7 +62,7 @@ Sources publish the Cordis tree as retained state rather than an event history. Closing a source changes its stored tree from connected to disconnected instead of deleting the last snapshot. Object lookup excludes disconnected trees, so the snapshot remains inspectable as data without retaining or reviving a live Context, Fiber, or Runtime object. A replacement from the same source id and a new transport generation atomically restores the connected state. The configurable disconnected-tree limit evicts the oldest retained snapshots. -Accepted tree replacements emit `DOM.documentUpdated` and require the frontend to pull a fresh document. A disconnect that does not evict another tree invalidates object routes without changing the DOM document, preserving the loaded tree, expansion, and selection. Connection state remains in the inspection model until its Elements presentation is designed. Retention eviction falls back to `DOM.documentUpdated`. Further incremental tree diffs can be added behind `CordisDomSession` without changing collectors, snapshots, or model consumers. +Accepted source snapshots rebuild the connection-neutral document and are diffed by stable backend node identity. A revision-only replacement emits no DOM event. Child insertion and removal use `DOM.childNodeInserted` and `DOM.childNodeRemoved`; attribute changes use their corresponding DOM events; sibling reorder falls back to `DOM.setChildNodes` for that parent only. Reusing one backend identity for a different node kind is the sole `DOM.documentUpdated` fallback. A disconnect invalidates object routes without changing the retained DOM tree, preserving expansion and selection; retention eviction removes only the evicted `` node. ## CDP projection @@ -97,7 +97,7 @@ The read-only adapter implements document retrieval, child requests, node descri - `DOM.resolveNode` and `DOM.requestNode` round-trip Context and Fiber identities without sharing object ids across DevTools connections or source generations. - A Context or Fiber returned by Runtime evaluation is node-branded and can be revealed in Elements. - Disconnect destroys the Client execution context and its RemoteObjects while retaining the last Elements tree unchanged; a new transport generation replaces it after a complete snapshot arrives. -- Reconnect and resnapshot replay the latest tree state; malformed or oversized replacements do not replace the last valid snapshot. +- Reconnect and resnapshot replay the latest tree state; unchanged snapshots emit no DOM mutation, while structural changes update only their affected parent or node. Malformed or oversized replacements do not replace the last valid snapshot. - The stored snapshot and query API contain no CDP types and can support a future model-facing adapter unchanged. ## Consequences @@ -106,8 +106,8 @@ Cordis exposes no complete global Context registry. The collector can recover co Object recognition adds a Runtime round trip for each Host object that requires semantic identification. An annotation failure leaves an ordinary RemoteObject rather than breaking Runtime or Debugger delivery. Client Console observation preserves the original method result and schedules serialization afterward; each enabled DevTools session receives independently retained handles, so recognition never blocks the page call or shares objects between connections. -Full-tree replacement is simpler than incremental source mutations but can become expensive in very large runtimes. Node and byte limits preserve a valid prefix and report truncation; a later delta protocol can replace the transport without changing the snapshot model. +Sources continue to publish complete snapshots, keeping one shared Host/Client collector and allowing recovery after dropped observations. The Worker pays the snapshot comparison cost, then emits incremental CDP DOM mutations so unchanged revisions do not reset the Elements document. Node and byte limits preserve a valid prefix and report truncation; a later source delta protocol can replace the transport without changing the snapshot model or CDP projection. The object table intentionally keeps every object in the current visible tree strongly reachable until the next replacement or observer disposal. This is bounded by the retained snapshot and must not become a general-purpose object registry. -The Worker retains only serialized metadata for a disconnected snapshot; any still-running source owns its realm-local object registry independently and disposal releases that registry. `maxDisconnectedCordisTrees` bounds Worker snapshot memory and may force a full Elements document refresh when an older disconnected tree is evicted. +The Worker retains only serialized metadata for a disconnected snapshot; any still-running source owns its realm-local object registry independently and disposal releases that registry. `maxDisconnectedCordisTrees` bounds Worker snapshot memory, and eviction removes the corresponding retained Client subtree. diff --git a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md index b08b8fcb8d..05b9b0d438 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md @@ -39,7 +39,7 @@ collector 从 root、注册表中的每个 live Fiber,以及每个 event hook - Fiber `uid` 来自 Cordis。Context 当前没有 Cordis 自有 id,Inspector 不会暴露一个生成值来替代。 - `InspectorObjectReference` 是 realm 本地的不透明 handle,用于把树节点解析成实时 Context 或 Fiber。snapshot 携带该 handle 只为完成路由,不把它当成语义 id 或 DOM attribute。 - `BackendNodeId` 由 Worker 为一条保留的 `(source id, source generation, object reference)` 分配,并在该 generation 的 snapshot 被保留期间由所有 DevTools 连接共享。 -- `NodeId` 在节点进入某个 frontend document 时按 DevTools 连接分配;`DOM.documentUpdated` 或连接关闭时丢弃。 +- `NodeId` 在节点进入某个 frontend document 时按 DevTools 连接分配;对应 backend node 被保留期间保持稳定,并在节点离开树、少见的整 document fallback 或连接关闭时丢弃。 - `RemoteObjectId` 在 `DOM.resolveNode` 暴露实时对象时由选定的 Runtime session 分配;它只属于该 DevTools 连接和 object group。 `sourceId` 标识一个 Client runtime instance,并在自动重连 transport 时保持稳定;`generation` 标识一次 WebSocket 接纳。断联通过 `Runtime.executionContextDestroyed` 从 Console 移除 synthetic context。重连会发布新的 CDP execution-context id,因为已销毁的 id 及其 RemoteObject 不能复用;这并不表示浏览器底层 JavaScript realm 被重新创建。 @@ -62,7 +62,7 @@ source 把 Cordis 树作为保留状态发布,而不是事件历史。Host Mes source 关闭时,存储的树从 connected 变为 disconnected,而不是删除最后一份 snapshot。对象查询会排除 disconnected 树,因此 snapshot 仍可作为数据检查,但不会保留或复活实时 Context、Fiber 或 Runtime object。同一 source id 的新 transport generation 提交 replacement 后,会原子恢复 connected 状态。可配置的 disconnected tree 数量上限会淘汰最早保留的 snapshot。 -接受 tree replacement 后发送 `DOM.documentUpdated`,要求 frontend 重新拉取 document。如果断联没有淘汰另一棵树,则只失效对象路由,不改变 DOM document,从而保留已加载的树、展开状态与选择。连接状态留在 inspection model 中,等待其 Elements 展示方式被明确设计。保留上限触发淘汰时回退到 `DOM.documentUpdated`。以后可以在 `CordisDomSession` 内增加更多增量 DOM diff,而无需修改 collector、snapshot 或模型消费方。 +每个被接受的 source snapshot 都会重建 connection-neutral document,并按稳定的 backend node identity 比较差异。只改变 revision 的 replacement 不发送 DOM event;子节点增删使用 `DOM.childNodeInserted` 与 `DOM.childNodeRemoved`,attribute 变化使用对应 DOM event,兄弟节点重排只对该 parent 使用 `DOM.setChildNodes`。只有同一 backend identity 被复用为不同 node kind 时才回退到 `DOM.documentUpdated`。断联只会使 object route 失效,不改变保留的 DOM tree,因此保留展开与选择;达到保留上限时只移除被淘汰的 `` 节点。 ## CDP projection @@ -97,7 +97,7 @@ synthetic document 包含一个 `` container 和一个 `` contain - `DOM.resolveNode` 与 `DOM.requestNode` 能往返映射 Context/Fiber 身份,且不会跨 DevTools 连接或 source generation 共享 object id。 - Runtime evaluation 返回的 Context 或 Fiber 会被标记为 node,并能在 Elements 中定位。 - 断联会销毁 Client execution context 与 RemoteObject,同时原样保留最后一棵 Elements 树;新的 transport generation 在完整 snapshot 到达后替换它。 -- 重连和 resnapshot 会重放最新树状态;畸形或超限 replacement 不会替换最后一个有效快照。 +- 重连和 resnapshot 会重放最新树状态;无变化的 snapshot 不发送 DOM mutation,结构变化只更新受影响的 parent 或 node。畸形或超限 replacement 不会替换最后一个有效快照。 - 存储的 snapshot 与查询 API 不包含 CDP 类型,可以不加修改地支持未来的模型适配器。 ## Consequences @@ -106,8 +106,8 @@ Cordis 不提供完整的全局 Context registry。collector 能恢复从 live f 需要语义识别的每个 Host object 都会增加一次 Runtime round trip。annotation 失败时保留普通 RemoteObject,不破坏 Runtime 或 Debugger 投递。Client Console observation 保留原始 method result,并在之后调度序列化;每个已启用 DevTools session 独立保留 handle,因此识别既不阻塞页面调用,也不在连接间共享对象。 -完整树 replacement 比增量 source mutation 更简单,但在超大运行时中可能昂贵。节点数与字节数限制会保留有效前缀并报告截断;以后可以替换为 delta 协议,而不修改 snapshot model。 +source 仍发布完整 snapshot,从而复用同一套 Host/Client collector,并能在 observation 丢失后恢复。Worker 承担 snapshot 比较成本,再发送增量 CDP DOM mutation,使无变化的 revision 不会重置 Elements document。节点数与字节数限制会保留有效前缀并报告截断;以后可以替换 source delta 协议,而不修改 snapshot model 或 CDP projection。 对象表会有意强引用当前可见树中的每个对象,直到下一次 replacement 或 observer dispose。该集合受保留快照限制,不能扩展成通用对象注册表。 -Worker 对断联 snapshot 只保留序列化 metadata;仍在运行的 source 独立拥有其 realm-local object registry,dispose 会释放该 registry。`maxDisconnectedCordisTrees` 约束 Worker snapshot 内存;淘汰较早的断联树时,Elements document 可能需要完整刷新。 +Worker 对断联 snapshot 只保留序列化 metadata;仍在运行的 source 独立拥有其 realm-local object registry,dispose 会释放该 registry。`maxDisconnectedCordisTrees` 约束 Worker snapshot 内存;淘汰时会移除对应的已保留 Client subtree。 diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index 69720dbcc8..822f4178c0 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/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/experimental/inspector/README.md -README.md: 09f8b2901e8a8d5c6e8264d2e1be680d3b968178 -README.zh.md: 510ca42362a3c7e779a672b721ec9bc43ede07b5 +README.md: 9e955ae2ff14c7d6331c4081e373183cc768c212 +README.zh.md: dc2e8228bedad4262793109204fd4743b55be744 diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index 09f8b2901e..9e955ae2ff 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -102,6 +102,8 @@ The Elements document has fixed `` and `` containers. `` co Host and Client publish the same nested `CordisTreeSnapshot` type. Context and Fiber nodes carry opaque object handles for realm-local object lookup; Fiber nodes additionally carry Cordis `uid`. The Worker composes those realm snapshots into one `{ host, clients }` inspection tree. It assigns `BackendNodeId` values per source generation; each DevTools connection assigns its own `NodeId` values; `DOM.resolveNode` asks the owning Host or Client Runtime for a connection-local `RemoteObjectId`. `DOM.requestNode` maps that object id back to the same Elements node. `ctx.inspector.cordis.getTree()` and `DSHInspector.getCordisTree` read the detached consumer-neutral tree without routing handles or CDP ids. +Sources publish complete snapshots, while the Worker compares stable backend node identities before notifying DevTools. Unchanged snapshots emit no DOM event; additions, removals, and attribute changes use node-level CDP events, and sibling reordering replaces only that parent's children. Existing `NodeId` values and unaffected Elements expansion remain stable. + When a Client disconnects, its Console execution context and live object ids are destroyed immediately. With disconnected-tree retention enabled, Elements keeps the last tree unchanged while connection state remains in the inspection model rather than becoming an unreviewed DOM attribute. Reconnection keeps the logical source id, creates a new synthetic CDP context id for the new transport generation, and replaces the stale tree after its complete snapshot arrives. The Worker retains at most `maxDisconnectedCordisTrees` such snapshots; zero removes them immediately. diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md index 510ca42362..dc2e8228be 100644 --- a/packages/experimental/inspector/README.zh.md +++ b/packages/experimental/inspector/README.zh.md @@ -102,6 +102,8 @@ Elements document 包含固定的 `` 与 `` 容器。`` 包 Host 与 Client 发布同一种嵌套 `CordisTreeSnapshot` 类型。Context 与 Fiber 节点携带用于 realm-local 对象查询的不透明 object handle;Fiber 还携带 Cordis `uid`。Worker 把这些 realm snapshot 组合成一棵 `{ host, clients }` inspection tree。Worker 按 source generation 分配 `BackendNodeId`;每条 DevTools 连接分配自己的 `NodeId`;`DOM.resolveNode` 请求所属 Host 或 Client Runtime 生成连接本地 `RemoteObjectId`。`DOM.requestNode` 把该 object id 映射回同一个 Elements 节点。`ctx.inspector.cordis.getTree()` 与 `DSHInspector.getCordisTree` 读取不含 routing handle 或 CDP id 的 detached consumer-neutral tree。 +source 仍发布完整 snapshot,Worker 在通知 DevTools 前按稳定的 backend node identity 比较差异。无变化的 snapshot 不发送 DOM event;新增、移除和 attribute 变化使用节点级 CDP event,兄弟节点重排只替换对应 parent 的 children。现有 `NodeId` 与未受影响的 Elements 展开状态保持稳定。 + Client 断联时,其 Console execution context 与 live object id 会立即销毁。启用断联树保留后,Elements 会原样保留最后一棵树;连接状态留在 inspection model 中,不会未经设计就成为 DOM attribute。重连会沿用逻辑 source id,为新的 transport generation 创建新的 synthetic CDP context id,并在完整 snapshot 到达后替换旧树。Worker 最多保留 `maxDisconnectedCordisTrees` 棵此类 snapshot;设为零会立即移除。 diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts index d7288f784a..c153b4f3cc 100644 --- a/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts @@ -1,4 +1,4 @@ /** Cordis semantic DOM domain exports. */ -export { CordisDomBackend, type CordisDomChange } from './model.ts' +export { CordisDomBackend, type CordisDomChange, type CordisDomMutation } from './model.ts' export { CordisDomSession } from './session.ts' diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts index ad70430dab..2401392a98 100644 --- a/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts @@ -9,7 +9,6 @@ import type { CordisTreeObjectRoute, CordisTreeSourceSnapshot, CordisTreeStore, - CordisTreeStoreEvent, } from '../../../inspection/cordis-store.ts' /** One Worker-global backend node independent of any DevTools connection. */ @@ -31,9 +30,40 @@ export interface CordisDomDocument { readonly parentByBackendId: ReadonlyMap } -/** A full tree replacement or an in-place source availability change. */ -export type CordisDomChange = +/** One structural or attribute mutation between two projected documents. */ +export type CordisDomMutation = | { readonly type: 'document-updated' } + | { + readonly type: 'child-inserted' + readonly parentBackendNodeId: CdpBackendNodeId + readonly previousBackendNodeId: CdpBackendNodeId | 0 + readonly node: CordisDomNode + } + | { + readonly type: 'child-removed' + readonly parentBackendNodeId: CdpBackendNodeId + readonly node: CordisDomNode + } + | { + readonly type: 'children-replaced' + readonly parentBackendNodeId: CdpBackendNodeId + readonly children: readonly CordisDomNode[] + } + | { + readonly type: 'attribute-modified' + readonly backendNodeId: CdpBackendNodeId + readonly name: string + readonly value: string + } + | { + readonly type: 'attribute-removed' + readonly backendNodeId: CdpBackendNodeId + readonly name: string + } + +/** A visible incremental mutation or an in-place source availability change. */ +export type CordisDomChange = + | { readonly type: 'tree-mutated'; readonly mutations: readonly CordisDomMutation[] } | { readonly type: 'source-disconnected'; readonly source: InspectorSourceDescriptor } /** Assigns durable backend ids and projects the latest source snapshots. */ @@ -51,14 +81,9 @@ export class CordisDomBackend { this.unsubscribe = trees.subscribe((event) => { const previous = this.documentValue this.documentValue = this.build() - const change = this.change(event, previous) - for (const listener of [...this.listeners]) { - try { - listener(change) - } catch { - // One closed CDP connection cannot prevent sibling sessions from receiving the new document. - } - } + if (event.type === 'source-disconnected') this.emit({ type: 'source-disconnected', source: event.source }) + const mutations = diffDocument(previous, this.documentValue) + if (mutations.length > 0) this.emit({ type: 'tree-mutated', mutations }) }) } @@ -183,11 +208,14 @@ export class CordisDomBackend { return { backendNodeId, key, name, attributes, description, ...(object === undefined ? {} : { object }), children: [] } } - private change(event: CordisTreeStoreEvent, previous: CordisDomDocument): CordisDomChange { - if (event.type === 'source-disconnected' && sameNodeSet(previous, this.documentValue)) { - return { type: 'source-disconnected', source: event.source } + private emit(change: CordisDomChange): void { + for (const listener of [...this.listeners]) { + try { + listener(change) + } catch { + // One closed CDP connection cannot prevent sibling sessions from receiving the document mutation. + } } - return { type: 'document-updated' } } } @@ -204,10 +232,66 @@ function objectKey(source: InspectorSourceDescriptor, reference: InspectorObject return `${source.sourceId}\0${source.generation}\0${reference.registryId}\0${reference.handle}` } -function sameNodeSet(left: CordisDomDocument, right: CordisDomDocument): boolean { - if (left.byBackendId.size !== right.byBackendId.size) return false - for (const backendNodeId of left.byBackendId.keys()) { - if (!right.byBackendId.has(backendNodeId)) return false +function diffDocument(previous: CordisDomDocument, current: CordisDomDocument): CordisDomMutation[] { + const mutations: CordisDomMutation[] = [] + return diffNode(previous.root, current.root, mutations) + ? mutations + : [{ type: 'document-updated' }] +} + +function diffNode(previous: CordisDomNode, current: CordisDomNode, mutations: CordisDomMutation[]): boolean { + if (previous.backendNodeId !== current.backendNodeId || previous.name !== current.name) { + return false + } + const previousAttributes = new Map(previous.attributes) + const currentAttributes = new Map(current.attributes) + for (const [name, value] of currentAttributes) { + if (previousAttributes.get(name) === value) continue + mutations.push({ type: 'attribute-modified', backendNodeId: current.backendNodeId, name, value }) + } + for (const [name] of previousAttributes) { + if (!currentAttributes.has(name)) { + mutations.push({ type: 'attribute-removed', backendNodeId: current.backendNodeId, name }) + } + } + + const previousIds = previous.children.map(child => child.backendNodeId) + const currentIds = current.children.map(child => child.backendNodeId) + const previousSet = new Set(previousIds) + const currentSet = new Set(currentIds) + const retainedBefore = previousIds.filter(id => currentSet.has(id)) + const retainedAfter = currentIds.filter(id => previousSet.has(id)) + if (!sameIds(retainedBefore, retainedAfter)) { + mutations.push({ + type: 'children-replaced', + parentBackendNodeId: current.backendNodeId, + children: current.children, + }) + return true + } + for (const child of previous.children) { + if (!currentSet.has(child.backendNodeId)) { + mutations.push({ type: 'child-removed', parentBackendNodeId: current.backendNodeId, node: child }) + } + } + for (let index = 0; index < current.children.length; index++) { + const child = current.children[index] as CordisDomNode + if (previousSet.has(child.backendNodeId)) continue + mutations.push({ + type: 'child-inserted', + parentBackendNodeId: current.backendNodeId, + previousBackendNodeId: index === 0 ? 0 : (current.children[index - 1] as CordisDomNode).backendNodeId, + node: child, + }) + } + const previousById = new Map(previous.children.map(child => [child.backendNodeId, child])) + for (const child of current.children) { + const prior = previousById.get(child.backendNodeId) + if (prior !== undefined && !diffNode(prior, child, mutations)) return false } return true } + +function sameIds(left: readonly CdpBackendNodeId[], right: readonly CdpBackendNodeId[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts index 4f2e53181c..4b4aa97295 100644 --- a/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts @@ -7,7 +7,7 @@ import { respondToCdpRequest, type CdpRequest, type CdpTransport } from '../../p import type { InspectorRealmDescriptor } from '../../../inspection/realm.ts' import type { RuntimeDomainSession } from '../runtime/index.ts' import type { RuntimeObjectPresentation } from '../runtime/object-table.ts' -import type { CordisDomBackend, CordisDomChange, CordisDomNode } from './model.ts' +import type { CordisDomBackend, CordisDomChange, CordisDomMutation, CordisDomNode } from './model.ts' import { cdpNumericId, cdpStringId, @@ -292,8 +292,97 @@ export class CordisDomSession { this.releaseSourceObjects(event.source) return } - this.resetDocument() - if (this.enabled) this.transport.send({ method: 'DOM.documentUpdated', params: {} }) + if (this.enabled) for (const mutation of event.mutations) this.sendMutation(mutation) + this.pruneDocumentState() + } + + private sendMutation(mutation: CordisDomMutation): void { + switch (mutation.type) { + case 'document-updated': + this.resetDocument() + this.transport.send({ method: 'DOM.documentUpdated', params: {} }) + return + case 'child-inserted': { + const parentNodeId = this.nodeIdByBackend.get(mutation.parentBackendNodeId) + if (parentNodeId === undefined) return + const previousNodeId = mutation.previousBackendNodeId === 0 + ? 0 + : this.nodeIdByBackend.get(mutation.previousBackendNodeId) + if (previousNodeId === undefined) return + this.transport.send({ + method: 'DOM.childNodeInserted', + params: { + parentNodeId, + previousNodeId, + node: this.serialize(mutation.node, parentNodeId, true), + }, + }) + return + } + case 'child-removed': { + const parentNodeId = this.nodeIdByBackend.get(mutation.parentBackendNodeId) + const nodeId = this.nodeIdByBackend.get(mutation.node.backendNodeId) + if (parentNodeId === undefined || nodeId === undefined) return + this.transport.send({ method: 'DOM.childNodeRemoved', params: { parentNodeId, nodeId } }) + return + } + case 'children-replaced': { + const parentNodeId = this.nodeIdByBackend.get(mutation.parentBackendNodeId) + if (parentNodeId === undefined) return + this.transport.send({ + method: 'DOM.setChildNodes', + params: { + parentId: parentNodeId, + nodes: mutation.children.map(child => this.serialize(child, parentNodeId, true)), + }, + }) + return + } + case 'attribute-modified': { + const nodeId = this.nodeIdByBackend.get(mutation.backendNodeId) + if (nodeId !== undefined) { + this.transport.send({ + method: 'DOM.attributeModified', + params: { nodeId, name: mutation.name, value: mutation.value }, + }) + } + return + } + case 'attribute-removed': { + const nodeId = this.nodeIdByBackend.get(mutation.backendNodeId) + if (nodeId !== undefined) { + this.transport.send({ method: 'DOM.attributeRemoved', params: { nodeId, name: mutation.name } }) + } + return + } + default: + return assertNever(mutation) + } + } + + private pruneDocumentState(): void { + const document = this.backend.document() + for (const [backendNodeId, nodeId] of this.nodeIdByBackend) { + if (document.byBackendId.has(backendNodeId)) continue + this.nodeIdByBackend.delete(backendNodeId) + this.backendByNodeId.delete(nodeId) + } + for (const [objectId, binding] of this.backendByObjectId) { + const node = document.byBackendId.get(binding.backendNodeId) + const source = node?.object?.source + if (source?.sourceId === binding.sourceId && source.generation === binding.generation) continue + this.backendByObjectId.delete(objectId) + for (const [group, objectIds] of this.objectIdsByGroup) { + objectIds.delete(objectId) + if (objectIds.size === 0) this.objectIdsByGroup.delete(group) + } + } + for (const [searchId, nodeIds] of this.searches) { + this.searches.set(searchId, nodeIds.filter((nodeId) => { + const backendNodeId = this.backendByNodeId.get(nodeId) + return backendNodeId !== undefined && document.byBackendId.has(backendNodeId) + })) + } } private releaseSourceObjects(source: InspectorSourceDescriptor): void { @@ -359,3 +448,7 @@ function presentation(node: CordisDomNode): RuntimeObjectPresentation { description: node.description, } } + +function assertNever(value: never): never { + throw new Error(`Unexpected Cordis DOM mutation: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts index 22ceac488b..90741211f0 100644 --- a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts +++ b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts @@ -13,6 +13,7 @@ import type { InspectorJsonValue } from '../src/shared/json.ts' import { jsonByteLength } from '../src/shared/json.ts' import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts' import { CordisTreeStore } from '../src/worker/inspection/cordis-store.ts' +import { CordisDomBackend, type CordisDomChange } from '../src/worker/cdp/domains/dom/model.ts' import { InspectorClientFixture } from './fixtures/client-source.host.ts' interface CdpMessage { @@ -277,6 +278,72 @@ describe('Cordis tree inspection', () => { collector.close() }) + it('diffs snapshots into local DOM mutations and suppresses revision-only updates', () => { + const store = new CordisTreeStore({ maxNodes: 100, maxDisconnectedTrees: 1 }) + const backend = new CordisDomBackend(store) + const changes: CordisDomChange[] = [] + backend.subscribe((event) => { changes.push(event) }) + const host = { ...source('host', 'generation-1'), kind: 'host' as const } + const context = (objectHandle: string, children: unknown[] = []): Record => ({ + kind: 'context', + objectHandle, + children, + }) + const fiber = (uid: number, objectHandle: string): Record => ({ + kind: 'fiber', + uid, + objectHandle, + children: [context(`${objectHandle}-context`)], + }) + const snapshot = (revision: number, children: unknown[]): InspectorJsonValue => ({ + schemaVersion: 0, + revision, + objectRegistryId: 'registry', + root: context('root', children), + truncated: false, + }) as InspectorJsonValue + const replace = (revision: number, children: unknown[]): void => { + store.append(host, [{ sequence: revision, monotonicMs: revision, topic: 'cordis/tree', payload: snapshot(revision, children) }]) + } + + replace(1, [fiber(1, 'fiber-1')]) + expect(changes.at(-1)).toMatchObject({ type: 'tree-mutated', mutations: [{ type: 'child-inserted' }] }) + changes.length = 0 + replace(2, [fiber(1, 'fiber-1')]) + expect(changes).toEqual([]) + + replace(3, [fiber(2, 'fiber-1')]) + expect(changes).toEqual([ + expect.objectContaining({ type: 'tree-mutated', mutations: [expect.objectContaining({ type: 'attribute-modified', name: 'uid', value: '2' })] }), + ]) + changes.length = 0 + + replace(4, [fiber(2, 'fiber-1'), context('context-2')]) + expect(changes).toEqual([ + expect.objectContaining({ type: 'tree-mutated', mutations: [expect.objectContaining({ type: 'child-inserted' })] }), + ]) + changes.length = 0 + replace(5, [fiber(2, 'fiber-1')]) + expect(changes).toEqual([ + expect.objectContaining({ type: 'tree-mutated', mutations: [expect.objectContaining({ type: 'child-removed' })] }), + ]) + + changes.length = 0 + replace(6, [context('context-a'), context('context-b')]) + changes.length = 0 + replace(7, [context('context-b'), context('context-a')]) + expect(changes).toEqual([ + expect.objectContaining({ type: 'tree-mutated', mutations: [expect.objectContaining({ type: 'children-replaced' })] }), + ]) + + changes.length = 0 + replace(8, [{ kind: 'fiber', uid: 3, objectHandle: 'context-a', children: [context('changed-kind')] }]) + expect(changes).toEqual([ + expect.objectContaining({ type: 'tree-mutated', mutations: [{ type: 'document-updated' }] }), + ]) + backend.close() + }) + it('projects Host and Client trees and resolves both node kinds to RemoteObjects', async () => { inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) const host = new Context() @@ -455,6 +522,59 @@ describe('Cordis tree inspection', () => { expect(disconnectedTree.clients[0]?.connection.state).toBe('disconnected') }) + it('emits only node-level DOM changes for Client snapshots', async () => { + inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) + cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + const initialDocument = (await cdp.call('DOM.getDocument')).result?.root as CdpNode + const clientsNode = initialDocument.children?.find(node => node.localName === 'clients') + if (clientsNode === undefined) throw new Error('DOM document has no clients container') + + let offset = cdp.events.length + clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Incremental Client' }) + await vi.waitFor(() => { + const events = cdp!.events.slice(offset) + const inserted = events.find(event => event.method === 'DOM.childNodeInserted') + expect(inserted?.params?.parentNodeId).toBe(clientsNode.nodeId) + expect(inserted?.params?.node).toMatchObject({ localName: 'client' }) + expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false) + }) + + const firstTree = (await cdp.call('DSHInspector.getCordisTree')).result?.tree as { + clients: Array<{ revision: number }> + } + const firstRevision = firstTree.clients[0]?.revision + offset = cdp.events.length + await clientSource.refreshTree() + await vi.waitFor(async () => { + const tree = (await cdp!.call('DSHInspector.getCordisTree')).result?.tree as { + clients: Array<{ revision: number }> + } + expect(tree.clients[0]?.revision).toBeGreaterThan(firstRevision ?? 0) + }) + expect(cdp.events.slice(offset).some(event => event.method?.startsWith('DOM.'))).toBe(false) + + offset = cdp.events.length + const uid = await clientSource.addFiber() + let insertedNodeId: number | undefined + await vi.waitFor(() => { + const inserted = cdp!.events.slice(offset).find(event => event.method === 'DOM.childNodeInserted' + && (event.params?.node as CdpNode | undefined)?.localName === 'fiber' + && (event.params?.node as CdpNode | undefined)?.attributes?.includes(String(uid))) + insertedNodeId = (inserted?.params?.node as CdpNode | undefined)?.nodeId + expect(insertedNodeId).toBeTypeOf('number') + expect(cdp!.events.slice(offset).some(event => event.method === 'DOM.documentUpdated')).toBe(false) + }) + + offset = cdp.events.length + await clientSource.removeFiber() + await vi.waitFor(() => { + const events = cdp!.events.slice(offset) + const removed = events.find(event => event.method === 'DOM.childNodeRemoved') + expect(removed?.params?.nodeId).toBe(insertedNodeId) + expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false) + }) + }) + it('restores a disconnected Client tree from a new transport generation', async () => { inspector = await startInspector({ port: 0, @@ -493,11 +613,14 @@ describe('Cordis tree inspection', () => { const context = event.params?.context as { id?: number } | undefined return typeof context?.id === 'number' && context.id !== contextId }) - const refreshed = events.findIndex(event => event.method === 'DOM.documentUpdated') + const removed = events.findIndex(event => event.method === 'DOM.childNodeRemoved') + const inserted = events.findIndex(event => event.method === 'DOM.childNodeInserted') expect(destroyed).toBeGreaterThanOrEqual(0) expect(created).toBeGreaterThan(destroyed) - expect(refreshed).toBeGreaterThan(created) + expect(removed).toBeGreaterThan(created) + expect(inserted).toBeGreaterThan(removed) expect(events.slice(0, created).some(event => event.method?.startsWith('DOM.'))).toBe(false) + expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false) }) await vi.waitFor(async () => { diff --git a/packages/experimental/inspector/tests/fixtures/client-source.client.ts b/packages/experimental/inspector/tests/fixtures/client-source.client.ts index a6616facda..367b7ebb61 100644 --- a/packages/experimental/inspector/tests/fixtures/client-source.client.ts +++ b/packages/experimental/inspector/tests/fixtures/client-source.client.ts @@ -1,7 +1,7 @@ /** Client-face process fixture used by Host-side protocol integration tests. */ import { parentPort, workerData } from 'node:worker_threads' -import { Context } from '@deepseek-ai/cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import WebSocket from 'ws' import { ClientInspectorSource } from '../../src/client/bridge/transport.ts' import { ClientSourceCatalog } from '../../src/client/cdp/sources.ts' @@ -24,7 +24,17 @@ interface ClientFixtureInput { interface ClientFixtureRequest { readonly id: number - readonly op: 'close' | 'disconnect' | 'get-tree' | 'log-cordis' | 'log-value' | 'publish' | 'set-global' + readonly op: + | 'add-fiber' + | 'close' + | 'disconnect' + | 'get-tree' + | 'log-cordis' + | 'log-value' + | 'publish' + | 'refresh-tree' + | 'remove-fiber' + | 'set-global' readonly name?: string readonly value?: InspectorJsonValue readonly marker?: string @@ -60,6 +70,7 @@ const disposeCordis = publishCordisTree(context, source, { maxBytes: input.bootstrap.maxFrameBytes - 4_096, }) const service = createInspectorService(source) +let addedFiber: Fiber | undefined port.on('message', (message: ClientFixtureRequest) => { void dispatch(message).then( @@ -100,7 +111,19 @@ async function dispatch(message: ClientFixtureRequest): Promise { socket?.terminate() return undefined } + case 'refresh-tree': + context.emit('internal/status', childFiber.ctx.fiber, childFiber.ctx.fiber.state) + return undefined + case 'add-fiber': + addedFiber = context.plugin({ name: 'dynamic-client-child', apply() {} }).ctx.fiber + await addedFiber.await() + return addedFiber.uid + case 'remove-fiber': + await addedFiber?.dispose() + addedFiber = undefined + return undefined case 'close': + await addedFiber?.dispose() disposeCordis() source.close() await context.fiber.dispose() diff --git a/packages/experimental/inspector/tests/fixtures/client-source.host.ts b/packages/experimental/inspector/tests/fixtures/client-source.host.ts index 8b817c49c4..aaf69d6ce6 100644 --- a/packages/experimental/inspector/tests/fixtures/client-source.host.ts +++ b/packages/experimental/inspector/tests/fixtures/client-source.host.ts @@ -103,6 +103,21 @@ export class InspectorClientFixture { await this.request({ op: 'disconnect' }) } + /** Trigger a Cordis observation without changing the runtime tree. */ + async refreshTree(): Promise { + await this.request({ op: 'refresh-tree' }) + } + + /** Add one Fiber to the inspected Client runtime. */ + async addFiber(): Promise { + return await this.request({ op: 'add-fiber' }) as number + } + + /** Remove the Fiber most recently added by {@link addFiber}. */ + async removeFiber(): Promise { + await this.request({ op: 'remove-fiber' }) + } + /** Dispose the Client source and its Cordis context. */ async close(): Promise { if (this.closed) return