docs(bindings): explain derived sources and renderer lease contracts

This commit is contained in:
2026-11-12 15:28:00 +08:00
parent 30978320c3
commit 04c4566679
6 changed files with 52 additions and 24 deletions
+11 -5
View File
@@ -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<Id>();
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<ElementLookup>{
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;}};
},
});
+10 -5
View File
@@ -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<string,{token:number;entry:OverlayEntry}>(),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,'id'|'viewId'>):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<OverlayEntry,'id'|'viewId'>){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit<OverlayEntry,'id'|'viewId'>){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();},
+2 -1
View File
@@ -13,5 +13,6 @@ export type CanvasRendererCommands={dispatch(command:Command,options?:RendererCo
/** Provider 由上层 renderer 装配;默认 null 表示组件尚未处于合法的编辑上下文。 */
export const CanvasRendererCommandsContext=createContext<CanvasRendererCommands|null>(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;}
+4 -2
View File
@@ -11,7 +11,8 @@ export type RendererCatalog={readonly definitions:readonly RendererDescriptor[];
/** JSON 元组键避免拼接分隔符歧义,节点与连线即使 componentId 相同也独立匹配。 */
const key=(value:Pick<RendererDescriptor,'componentId'|'dataVersion'|'structure'>)=>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<string,RendererDescriptor>();
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<RendererCatalog['resolve']>[0])=>registry.components.get(element.componentId)?.copy?catalog.resolve(element):undefined});}
+12 -5
View File
@@ -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<DisplayHandle>;
onChange(sources: Map<Id, ResourceViewSource>, failures: number): void;
@@ -24,13 +26,16 @@ export function createResourceView(options: {
const records = new Map<Id, Record>(); let targets = new Map<Id, Id>(), 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<Id, Id>();
@@ -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(); },
});
}
+13 -6
View File
@@ -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<EngineSnapshot>){
// 每个 group 负责自己的桶更新,上游先推进所有组,再运行去重后的外部通知。
type Group={update(root:EngineSnapshot,notifications:Set<()=>void>):void};
@@ -33,7 +35,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
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<K,V>(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<EngineSnapshot>){
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<EngineSnapshot>){
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<EngineSnapshot>){
let countStamp:string|undefined,projectCounts:SyncCounts,emptyCounts:SyncCounts,canvasCounts=new Map<Id,SyncCounts>();
/** 同步计数只取操作表、请求表和是否 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<Id|undefined,SyncCounts>(countDependencies,(root,canvasId)=>{
const stamp=signature(countDependencies(root));
if(stamp!==countStamp){
@@ -92,7 +98,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
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<Id>();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);