/** * 显示层的帧合并队列。把多次“需要重画”合并成去重 key 集合,不延迟命令提交、历史或网络任务。 * 队列只拥有调度句柄和失效索引;真实数据随时从上游快照读取,dispose 时丢弃未发布通知。 */ import type {ExternalSource} from './selectors.ts'; /** Presentation invalidations only. Commands, history and network work never wait here. */ /** 宿主注入帧请求/取消能力,可接 requestAnimationFrame 或测试时钟;handle 不保存业务身份。 */ export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void}; /** 创建一条可手动 flush 的通知队列;Set 按键身份去重,并保留首次插入顺序。 * clock/publish 由宿主实现,不读取 DOM/模型;调度器异常原样抛出,不另设重试或全局错误处理。 */ export function createFrameQueue(clock:FrameClock,publish:(dirty:ReadonlySet)=>void){ let dirty=new Set(),scheduled:{handle?:unknown}|undefined,disposed=false; /** 同步排空:先撤当前调度并换新 Set,再 publish,回调中的 invalidate 留给下一批。 * batch 以 ReadonlySet 类型借出但未运行时冻结,与新 dirty 已分离;publish 抛错不把本批自动放回队列。 */ function flush(){if(disposed)return;const pending=scheduled;scheduled=undefined;if(pending)clock.cancel(pending.handle);if(!dirty.size)return;const batch=dirty;dirty=new Set();publish(batch);} return Object.freeze({ /** 逐项加入 dirty,至多安排一个帧回调;pending 对象身份防旧回调误刷后来一批。 * request 抛错会清 scheduled,但保留 dirty 供后续 invalidate/flush 重试;不自动再 request。 */ invalidate(keys:Iterable){if(disposed)return;for(const key of keys)dirty.add(key);if(!dirty.size||scheduled)return;const pending: {handle?:unknown}={};scheduled=pending;try{pending.handle=clock.request(()=>{if(scheduled!==pending)return;scheduled=undefined;flush();});}catch(error){scheduled=undefined;throw error;}}, flush, /** 标记 disposed 后尽力取消帧并清 dirty;后续 invalidate/flush 无效,不替上游关闭 editor。 * 这里不捕获 clock.cancel 异常,宿主取消端口须可靠清理;重复 dispose 不会重试一次失败取消。 */ dispose(){if(disposed)return;disposed=true;if(scheduled)clock.cancel(scheduled.handle);scheduled=undefined;dirty.clear();}, }); } /** Coalesce presentation notifications; immediate invalidations flush pending work synchronously. */ /** 给普通外部数据源加通知节流:读取始终透传当前快照,每个订阅独有可释放的帧队列。 * 上游标 immediate 时先 invalidate 再同步 flush,合并的旧失效也一起通知;读取从不等待帧。 * 同一工厂的不同订阅不共享队列;退订函数每次都会调用上游 stop,幂等性依赖该 stop 合同。 */ export function createFrameSource(source:{getSnapshot():S;subscribe(notify:(immediate?:boolean)=>void):()=>void},clock:FrameClock):ExternalSource{ return Object.freeze({getSnapshot:()=>source.getSnapshot(),subscribe(notify:()=>void){ const queue=createFrameQueue(clock,()=>notify()); let stop:()=>void; // 注册抛错也要撤销可能已经安排的帧,避免一个未建立成功的订阅仍在稍后通知。 try{stop=source.subscribe(immediate=>{queue.invalidate([0]);if(immediate)queue.flush();});}catch(error){queue.dispose();throw error;} // 先退订上游,finally 保证上游清理抛错时仍释放本订阅的显示队列。 return()=>{try{stop();}finally{queue.dispose();}}; }}); }