From 04c4566679cd67d4a9836e493d01adbfec481370 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 15:28:00 +0800 Subject: [PATCH] docs(bindings): explain derived sources and renderer lease contracts --- src/lib/canvas-bindings/element-sources.ts | 16 +++++++++++----- src/lib/canvas-bindings/overlays.ts | 15 ++++++++++----- src/lib/canvas-bindings/renderer-context.ts | 3 ++- src/lib/canvas-bindings/renderers.ts | 6 ++++-- src/lib/canvas-bindings/resource-view.ts | 17 ++++++++++++----- src/lib/canvas-bindings/runtime-sources.ts | 19 +++++++++++++------ 6 files changed, 52 insertions(+), 24 deletions(-) diff --git a/src/lib/canvas-bindings/element-sources.ts b/src/lib/canvas-bindings/element-sources.ts index 208bc6e..e067f3d 100644 --- a/src/lib/canvas-bindings/element-sources.ts +++ b/src/lib/canvas-bindings/element-sources.ts @@ -16,7 +16,9 @@ export type ElementSourcePorts={ }; /** One upstream subscription, ID buckets and cached lookup values, never another document. * Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */ -/** 创建惰性订阅中心;每个 forElement 源缓存自身读取值,同 ID 的监听共享上游桶。 */ +/** buckets 按 elementId 共享上游查询结果/观察器,每个 forElement 另有即时读取缓存。 + * 首次 subscribe 才接上游,全部桶退订才释放 previous 根引用;创建源或直接读不持有上游租约。 + * 依赖不可变快照与完整 Commit.changes 合同,不从队列/网络结果猜测元素是否已改变。 */ export function createElementSources(ports:ElementSourcePorts){ // 失败标记使下次成功查询即使值等价也通知恢复;不把异常缓存成一条虚假元素记录。 type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean}; @@ -28,11 +30,13 @@ export function createElementSources(ports:ElementSourcePorts){ const before=previous!;previous=next; const ids=new Set(); if(before.document!==next.document){ - // 显式元素/字段改动走 ID 路由;无 changes 的文档替换无法精确定位,复查全部已订阅元素。 + // 显式变化只路由 field.target.id / element.id,不按 canvas 变更自动扩散到全体子元素。 + // 文档替换且无 changes 时复查全部已订阅桶;路由准确性依赖发布者提供完整变更及 lookup 的依赖合同。 if(commit.changes.length){for(const change of commit.changes)if(change.kind==='field')ids.add(change.target.id);else if(change.kind==='element')ids.add(change.id);} else for(const id of buckets.keys())ids.add(id); } - // loading 的已知范围改变会把 absent/unloaded 变成可查询;原先非 present 的桶必须复查。 + // loading 对象或其读取基线变动可改变 absent/unloaded 判定,因此复查非 present 桶。 + // 不将任意 runtime 更新都当元素变化;failed 桶也只有进入本次候选 IDs 才会在此重新查询。 const coverageChanged=before.runtime.loading!==next.runtime.loading||!!next.runtime.loading&&(before.runtime.loadingRead?.document??before.runtime.acknowledged)!==(next.runtime.loadingRead?.document??next.runtime.acknowledged); if(coverageChanged)for(const[id,bucket]of buckets)if(bucket.value.status!=='present')ids.add(id); const notifications=new Set<()=>void>(); @@ -50,7 +54,8 @@ export function createElementSources(ports:ElementSourcePorts){ return Object.freeze({forElement(id:Id):ExternalSource{ let value:ElementLookup|undefined; return Object.freeze({ - /** 每次从当前根查询并按值保留稳定引用,不能只依赖通知时机猜测最新数据。 */ + /** 每次查询当前根,用 equal 保持等价 ElementLookup 的局部引用;读取不改共享桶通知基线。 + * 不深复制查询值,lookup 必须返回适合不可变使用的结果;异常直接抛给调用方读取边界。 */ getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;}, /** 每个订阅包装独立 listener,即使调用方传同一个函数也能分别释放。 */ subscribe(notify:()=>void){ @@ -59,7 +64,8 @@ export function createElementSources(ports:ElementSourcePorts){ const listener=()=>notify();bucket.listeners.add(listener); // 首次连上游抛错时撤销这次 listener/空桶,避免半注册泄漏。 if(!stop){previous=ports.getSnapshot();try{stop=ports.subscribe(changed);}catch(error){bucket.listeners.delete(listener);if(!bucket.listeners.size)buckets.delete(id);if(!buckets.size)previous=undefined;throw error;}} - // 最后一个桶清空才退订上游,释放 previous 根引用;其它元素仍有监听时继续共享。 + // 最后一个桶清空才 stop 上游并释放 previous;同回调的多次订阅因 listener 包装仍分别退订。 + // stop 异常不被捕获,后续状态重置不保证执行;宿主的取消订阅端口应不抛错。 let active=true;return()=>{if(!active)return;active=false;bucket!.listeners.delete(listener);if(!bucket!.listeners.size)buckets.delete(id);if(!buckets.size){stop?.();stop=undefined;previous=undefined;}}; }, }); diff --git a/src/lib/canvas-bindings/overlays.ts b/src/lib/canvas-bindings/overlays.ts index 23f09e2..db267ed 100644 --- a/src/lib/canvas-bindings/overlays.ts +++ b/src/lib/canvas-bindings/overlays.ts @@ -9,19 +9,24 @@ import {fault} from '../canvas-core/values.ts'; /** bounds 使用屏幕空间;target 仍是真实元素 ID+代次,viewId 限定所属交互视图。 */ export type OverlayEntry={readonly id:string;readonly viewId:string;readonly placement:string;readonly target:Target;readonly bounds:Rect;readonly focused:boolean;readonly composing:boolean}; /** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */ -/** 为一个有效 viewId 创建租约表、冻结快照和可重建 paint 网格。 */ +/** entries 保存 overlayId→{token,entry},snapshot 沿用 Map 插入顺序(替换同 ID 不移位),index 只按屏幕 bounds 查询。 + * 不订阅文档/视口,不自行核 target 存活;调用者更新位置,并在 visible 时提供当前目标有效性判断。 */ export function createOverlayIndex(viewId:string){ if(typeof viewId!=='string'||!viewId)throw fault('view_session_required');let serial=0,snapshot:readonly OverlayEntry[]=Object.freeze([]);const entries=new Map(),listeners=new Set<()=>void>(),index=createPaintIndex(); - /** 从当前条目重建快照和空间索引后通知;这里只同步位置事实,不决定浮层 DOM 的挂载。 */ + /** 先更新 snapshot/index,再同步通知监听集合副本;只发布位置事实,不挂载 DOM。 + * 监听器异常直接传播且会中断后续通知,不回滚已发布条目;不能据 register/update 抛错推断它未生效。 */ function publish(){snapshot=Object.freeze([...entries.values()].map(row=>row.entry));index.sync(snapshot.map(entry=>({id:entry.id,bounds:entry.bounds})));for(const listener of [...listeners])listener();} - /** 复制并冻结目标/矩形,验证身份和位置;复用 paint 索引校验矩形,拒绝不合法的外部边界。 */ + /** 重建并冻结 entry 外壳/target/bounds,校验 ID/placement/目标代次和合法矩形。 + * focused/composing 的布尔值由类型合同约束,此处不另验;扩展字段浅保留,不是任意对象深克隆。 */ function capture(id:string,value:Omit):OverlayEntry{if(typeof id!=='string'||!id||typeof value.placement!=='string'||!value.placement||typeof value.target.id!=='string'||!value.target.id||!Number.isSafeInteger(value.target.incarnation)||value.target.incarnation<1)throw fault('invalid_overlay');const entry=Object.freeze({...value,id,viewId,target:Object.freeze({...value.target}),bounds:Object.freeze({...value.bounds})});const check=createPaintIndex();check.sync([{id,bounds:entry.bounds}]);return entry;} // 订阅只关注本视图位置变化,调用方需用返回函数解除监听;getSnapshot 返回稳定的最近发布值。 return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};}, /** 注册或替换一个浮层,返回只能操作本 token 的 update/release。 - * update 先完整校验,新旧序列化值相同时不发布;过期租约的更新/释放均无效果。 */ + * 注册时 ++serial 分配 token,覆盖注册使旧租约失效;update 先检查 token,再捕获并按 JSON 文本比较。 + * 等值 update 不发布,register 替换始终发布;release/clear 只移除瞬态浮层,不删除元素。 */ register(id:string,value:Omit){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit){if(entries.get(id)?.token!==token)return;const entry=capture(id,value),old=entries.get(id)!.entry;if(JSON.stringify(old)===JSON.stringify(entry))return;entries.set(id,{token,entry});publish();},release(){if(entries.get(id)?.token!==token)return;entries.delete(id);publish();}});}, - /** 正常浮层按视口交集筛选;已聚焦或输入法组合中的浮层保留,但目标必须仍有效。 */ + /** 先验证查询矩形并查网格,再按 snapshot 顺序筛选:current(target) 必须真,之后才考虑焦点/组合状态或交集。 + * 焦点/输入法保留不会绕过目标有效性,也不自动移除过期 entries;返回新数组,不发布位置更新。 */ visible(bounds:Rect,current:(target:Target)=>boolean){const ids=index.query(bounds);return snapshot.filter(entry=>current(entry.target)&&(entry.focused||entry.composing||ids.has(entry.id)));}, /** 清空所有当前浮层并发布空快照;不关闭监听服务,后续仍可注册新租约。 */ clear(){entries.clear();publish();}, diff --git a/src/lib/canvas-bindings/renderer-context.ts b/src/lib/canvas-bindings/renderer-context.ts index 43f18f1..54552d2 100644 --- a/src/lib/canvas-bindings/renderer-context.ts +++ b/src/lib/canvas-bindings/renderer-context.ts @@ -13,5 +13,6 @@ export type CanvasRendererCommands={dispatch(command:Command,options?:RendererCo /** Provider 由上层 renderer 装配;默认 null 表示组件尚未处于合法的编辑上下文。 */ export const CanvasRendererCommandsContext=createContext(null); /** Trusted installed renderers submit normal validated commands, never write a second model. */ -/** 已安装的受信 renderer 通过此 hook 获取命令门面,所有业务修改仍经 editor 发布边界。 */ +/** 读取最近 Provider 的命令对象,缺失就同步抛错;hook 不缓存命令、不建立订阅或另建 editor。 + * Provider 须注入 editor 的受检门面;Context 本身不验证任意自定义函数是否真的经过发布边界。 */ export function useCanvasRendererCommands(){const commands=useContext(CanvasRendererCommandsContext);if(!commands)throw Error('Canvas renderer commands are unavailable');return commands;} diff --git a/src/lib/canvas-bindings/renderers.ts b/src/lib/canvas-bindings/renderers.ts index ee1269d..41026e0 100644 --- a/src/lib/canvas-bindings/renderers.ts +++ b/src/lib/canvas-bindings/renderers.ts @@ -11,7 +11,8 @@ export type RendererCatalog={readonly definitions:readonly RendererDescriptor[]; /** JSON 元组键避免拼接分隔符歧义,节点与连线即使 componentId 相同也独立匹配。 */ const key=(value:Pick)=>JSON.stringify([value.componentId,value.dataVersion,value.structure]); /** 校验并复制描述项,拒绝重复定义、危险/保留 renderer 名和未知投影协议。 - * 工厂不加载代码、不自动迁移 dataVersion,也不把高版本当成兼容旧版本。 */ + * 工厂按声明顺序重建标准标量字段/默认 projection,未知扩展字段不复制,Map 键是精确三元组。 + * 不加载 React/插件代码、不执行 schema 校验、不自动迁移版本;resolve 不授予编辑或网络权限。 */ export function createRendererCatalog(definitions:readonly RendererDescriptor[]):RendererCatalog{ const entries=new Map(); for(const value of definitions){ @@ -31,5 +32,6 @@ export const builtinRendererCatalog=createRendererCatalog(builtinRendererDefinit /** Installed renderers without a reference declaration wait for full context. */ /** 渐进加载仅开放 registry 中声明 copy 引用规则的组件 renderer;缺声明先走降级等待完整上下文。 - * 保留 definitions 供装配检查,但 resolve 才决定当前是否允许显示实现。 */ + * 保留原 catalog.definitions 供装配检查,resolve 每次先查 registry 中 component 是否有 copy 声明。 + * 这仅是局部加载门禁,不验证声明覆盖所有引用、schema 或源记录版本,也不证明依赖已加载;精确 renderer 匹配仍由 catalog 执行。 */ export function createLoadingRendererCatalog(registry:Registry,catalog:RendererCatalog):RendererCatalog{return Object.freeze({definitions:catalog.definitions,resolve:(element:Parameters[0])=>registry.components.get(element.componentId)?.copy?catalog.resolve(element):undefined});} diff --git a/src/lib/canvas-bindings/resource-view.ts b/src/lib/canvas-bindings/resource-view.ts index 3721044..8b3a085 100644 --- a/src/lib/canvas-bindings/resource-view.ts +++ b/src/lib/canvas-bindings/resource-view.ts @@ -14,7 +14,9 @@ export type ResourceViewSource = { readonly resourceId: Id; readonly url: string * unchanged nodes, publish each ready source independently, and fence late work. */ /** resolve 由 engine/display service 注入,onChange 收到新的已就绪集合和当前失败数。 - * sync 输入是完整目标集合,retry 只重建失败项,dispose 幂等释放所有在途/已完成句柄。 */ + * sync 输入是完整目标集合,retry 只重建失败项,dispose 请求中止在途并归还已有句柄。 + * 工厂不立即 resolve;每个元素一次独立尝试,同资源的多个元素不在此合并(底层缓存可共享读取)。 + * resolve 须返回合法 DisplayHandle;本模块不校验 URL/下载权限或图片内容,回调错误仅报告。 */ export function createResourceView(options: { resolve(resource: Id, signal: AbortSignal): Promise; onChange(sources: Map, failures: number): void; @@ -24,13 +26,16 @@ export function createResourceView(options: { const records = new Map(); let targets = new Map(), disposed = false; /** 错误展示抛错不影响租约释放或其它图片的加载。 */ const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Observer errors do not own leases. */ } }; - /** 只公布已拿到 handle 的元素,失败数另报;先复制 Map,不把内部记录交给界面修改。 */ + /** 每次重建 ready 元素→{resourceId,url} 的新 Map,另计 failed;pending 不计失败也不带空 URL。 + * Map/条目未运行时冻结,但不暴露内部 record/handle;onChange 抛错被 report 隔离,不撤销已采纳句柄。 */ const publish = () => { try { options.onChange(new Map([...records].flatMap(([id, record]) => record.handle ? [[id, {resourceId:record.resource,url:record.handle.url}] as [Id, ResourceViewSource]] : [])), [...records.values()].filter(record => record.failed).length); } catch (error) { report(error); } }; /** 释放器自身抛错只报告,不让一张图片阻止其它图片清理。 */ const releaseHandle = (handle: DisplayHandle) => { try { handle.release(); } catch (error) { report(error); } }; /** 先中止在途解析,再归还已完成句柄;未完成解析的迟到成功也由下方结果边界归还。 */ const release = (record: Record) => { record.controller.abort(); if(record.handle)releaseHandle(record.handle); }; - /** 完整验证元素/资源 ID 与重复项后再更新;删除或换源立即释放旧记录,未变化项继续复用。 */ + /** 先验证并捕获完整目标映射(元素 ID 唯一,资源 ID 可重复),再删除/换源/补新尝试。 + * 未变化项包括 failed 都保留,因此同集合 sync 不隐式重试;显式 retry 才重建失败项。 + * 变化时先发布当前 ready 集合,各 resolve 独立完成再各自发布,不等全组 Promise.all。 */ function sync(next: readonly (readonly [Id, Id])[]) { if (disposed) throw fault('resource_view_disposed'); const desired = new Map(); @@ -60,9 +65,11 @@ export function createResourceView(options: { } return Object.freeze({ sync, - /** 移除失败记录后按 targets 重新 sync;成功项不重新取 URL。 */ + /** 先撤掉失败尝试并释放,再用最近 targets 重新 sync;ready/pending 项继续复用。 + * 没有失败项也不额外发布,不重新授权所有成功资源或重建业务元素。 */ retry() { if (disposed) throw fault('resource_view_disposed'); for (const [id, record] of records) if (record.failed) { records.delete(id); release(record); } sync([...targets]); }, - /** 中止并归还全部显示租约,发布空集合,后续 sync/retry 明确拒绝。 */ + /** 同步标 disposed、请求 abort 并 release 已有句柄,清目标/记录后发布空集合。 + * 不等待 resolve Promise 退出;迟到成功由记录身份/ disposed 检查归还,后续 sync/retry 明确拒绝。 */ dispose() { if (disposed) return; disposed = true; for (const record of records.values()) release(record); records.clear(); targets.clear(); publish(); }, }); } diff --git a/src/lib/canvas-bindings/runtime-sources.ts b/src/lib/canvas-bindings/runtime-sources.ts index 0b7ffa7..1a0d8e2 100644 --- a/src/lib/canvas-bindings/runtime-sources.ts +++ b/src/lib/canvas-bindings/runtime-sources.ts @@ -20,7 +20,9 @@ export type SyncCounts={readonly pending:number;readonly blocked:number;readonly type Dependencies=readonly(object|boolean|undefined)[]; /** Read-only table projections: one upstream listener, no per-hook Root/table snapshots. */ -/** 装配 operation/asset/counts 三类通道,首次订阅连上游,最后一个通道离开后断开。 */ +/** 装配 operation/asset/counts 三组:组内按 key 共享桶,所有活动组共用一个上游订阅。 + * 只按所声明表对象身份/布尔值检测依赖,要求上游不可变;原地改 Map 成员不会自动生成新依赖戳。 + * factories/getSnapshot 不启动网络或修改业务状态,选中记录仍借用 Root 中的不可变事实。 */ export function createRuntimeSources(source:ExternalSource){ // 每个 group 负责自己的桶更新,上游先推进所有组,再运行去重后的外部通知。 type Group={update(root:EngineSnapshot,notifications:Set<()=>void>):void}; @@ -33,7 +35,8 @@ export function createRuntimeSources(source:ExternalSource){ for(const group of active)group.update(root,notifications); let failed=false,error:unknown;for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error; } - /** 为共享依赖的一类按键查询创建桶。依赖不变跳过计算,失败桶即使签名未变仍可重新尝试。 */ + /** 一组依赖对应多个 key 桶;observedSignature 决定路由,failed 键可在戳未变时再试。 + * cachedSignature 只说明读取缓存对应哪个依赖戳,不能替代 observed 的通知基线。 */ function group(dependencies:(root:EngineSnapshot)=>Dependencies,read:(root:EngineSnapshot,key:K)=>V){ // Direct reads may precede notification, so read caches cannot replace observed baselines. // cached 服务即时读取,observed 是上次通知基线;两者不能合并,否则渲染提前读取会漏通知。 @@ -44,7 +47,8 @@ export function createRuntimeSources(source:ExternalSource){ if(bucket.cachedSignature!==stamp){const value=read(root,key);if(!equal(bucket.cached,value))bucket.cached=value;bucket.cachedSignature=stamp;} return bucket.cached; } - // 只更新有订阅的键;失败时仍通知使用方,让其读取/错误边界重新解释,不造一个成功值。 + // 戳变化扫描全部已订阅 key,否则只扫描 failed;readCached 失败不会推进该桶缓存戳。 + // 失败/失败后恢复都通知,cached/observed 不造一个假成功值;其它正常等价桶不通知。 const owner:Group={update(root,notifications){ const stamp=signature(dependencies(root)),all=stamp!==observedSignature;if(!all&&!failed.size)return;observedSignature=stamp; for(const key of all?buckets.keys():[...failed]){ @@ -67,7 +71,8 @@ export function createRuntimeSources(source:ExternalSource){ const listener=()=>notify();bucket.listeners.add(listener); /** 撤销本订阅;空桶和空组立即删除,失败键也同时清理。 */ const remove=()=>{bucket!.listeners.delete(listener);if(!bucket!.listeners.size){buckets.delete(key);failed.delete(key);}if(!buckets.size){active.delete(owner);observedSignature=undefined;}}; - // 首次注册上游失败撤回桶;正常退订只有最后一个活动组离开才停止共享监听。 + // 首次注册上游失败撤回本次 listener/空桶;各订阅包装回调身份,传同一个 notify 也可独立退订。 + // 最后活动组离开才 stop 上游;stop 抛错不兜底重置,宿主取消端口须可靠完成。 if(!stop)try{stop=source.subscribe(changed);}catch(error){remove();throw error;} let live=true;return()=>{if(!live)return;live=false;remove();if(!active.size){stop?.();stop=undefined;}}; }}); @@ -80,7 +85,8 @@ export function createRuntimeSources(source:ExternalSource){ let countStamp:string|undefined,projectCounts:SyncCounts,emptyCounts:SyncCounts,canvasCounts=new Map(); /** 同步计数只取操作表、请求表和是否 loading,不因文档几何或历史变化全表重数。 */ const countDependencies=(root:EngineSnapshot):Dependencies=>[root.runtime.ledger.operations,root.runtime.ledger.requests,!!root.runtime.loading]; - // 同一依赖戳一次扫描得出项目及各画布计数,多个 canvas hook 共享结果。 + // 同一依赖戳只扫描一次得到项目/各 canvas 计数,空 canvas 共享零计数;loading 是项目级布尔值。 + // canvas 归属沿用 intent.canvasIds,不在这里从当前 owner 推导;上传等空参与者任务只进入项目计数。 const counts=group(countDependencies,(root,canvasId)=>{ const stamp=signature(countDependencies(root)); if(stamp!==countStamp){ @@ -92,7 +98,8 @@ export function createRuntimeSources(source:ExternalSource){ const ids=operation.intent.canvasIds.length>1?new Set(operation.intent.canvasIds):operation.intent.canvasIds,blocked=operation.phase==='blocked'&&operation.blockers.some(reason=>reason!=='waiting_digest');project.pending++;if(blocked)project.blocked++; for(const id of ids){const row=scope(id);row.pending++;if(blocked)row.blocked++;} } - // 请求只统计仍关联未决操作的项。无 retryAt 的请求错误计 blocked,已发出但结果未知计 unknown。 + // 请求仅在仍关联未决操作时计数,canvas 集合取这些操作参与者的并集,不直接用 request.canvasIds。 + // 请求 blocked 可与操作 blocked 叠加,unknown 也可同时非零;它们不是互斥任务/节点数量。 for(const request of root.runtime.ledger.requests.values())if(request.phase!=='resolved'){ let matched=false;const ids=new Set();for(const id of request.operationIds){const operation=root.runtime.ledger.operations.get(id);if(operation&&operation.phase!=='resolved'){matched=true;for(const canvas of operation.intent.canvasIds)ids.add(canvas);}} if(!matched)continue;const blocked=!!request.error&&request.retryAt===undefined,unknown=request.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(request.phase);