Coalesce session presentation invalidations through a disposable frame queue

This commit is contained in:
2026-11-09 17:41:02 +08:00
parent 075bb79443
commit 04a9b0c2b2
6 changed files with 53 additions and 1 deletions
+13
View File
@@ -296,3 +296,16 @@ its `ReactRendererDefinition`. The assembly exposes `previewNodeTypes` and
app never constructs a fake editor merely to satisfy the hooks. Preview functions
must render the supplied records without requiring CanvasProvider. Built-in preview
renderers are registered explicitly. Framework definitions never enter core data.
Presentation scheduling:
`createFrameQueue(clock, publish)` accumulates dirty keys in a Set and requests
one frame. `flush()` publishes now; reentrant invalidations wait for the next
frame. `dispose()` cancels pending work, including late canceled callbacks.
This queue never holds commands, undo units or requests. `createSessionFrameSource`
wraps a session source with a per-subscription queue; reads still return the
current session immediately, while preview notifications coalesce per frame.
The application uses it for CanvasView sessions. Pointerup reads its final event
and commits through the engine synchronously; persistence and Root notifications
are not frame-throttled. This does not implement paint extent indexing or selective
per-element drawing: dirty keys currently schedule a view notification.
@@ -10,6 +10,7 @@ import { defaultCanvasRenderers } from "./canvas-renderers";
import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types";
import { equal } from "@/lib/canvas-core/values";
import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache";
import {createSessionFrameSource} from "@/lib/canvas-bindings/session-frames";
import {routeCanvasKey} from "@/lib/canvas-bindings/input-router";
import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query";
import {compatConnector,measuredConnector} from "@/lib/canvas-bindings/connectors";
@@ -37,7 +38,8 @@ const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
const root = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot);
const sessions = useSyncExternalStore(engine.sessions.subscribe, engine.sessions.getSnapshot, engine.sessions.getSnapshot);
const sessionFrames=useMemo(()=>createSessionFrameSource(engine.sessions,{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine]);
const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot);
const liveView = sessions.views.get(viewId), lastView = useRef(liveView);
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
+11
View File
@@ -0,0 +1,11 @@
/** Presentation invalidations only. Commands, history and network work never wait here. */
export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void};
export function createFrameQueue<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){
let dirty=new Set<K>(),scheduled:{handle?:unknown}|undefined,disposed=false;
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({
invalidate(keys:Iterable<K>){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,
dispose(){if(disposed)return;disposed=true;if(scheduled)clock.cancel(scheduled.handle);scheduled=undefined;dirty.clear();},
});
}
+3
View File
@@ -12,3 +12,6 @@ export {createSelectorStore} from './selectors.ts';
export type {ExternalSource} from './selectors.ts';
export {routeCanvasKey} from './input-router.ts';
export type {CanvasKeyInput,InputClaim,KeyHandler} from './input-router.ts';
export {createFrameQueue} from './frame-queue.ts';
export type {FrameClock} from './frame-queue.ts';
export {createSessionFrameSource} from './session-frames.ts';
+16
View File
@@ -0,0 +1,16 @@
import type {SessionSnapshot} from '../canvas-core/gestures.ts';
import type {ExternalSource} from './selectors.ts';
import {createFrameQueue,type FrameClock} from './frame-queue.ts';
/** One subscription owns one disposable frame queue. Reads always see current sessions. */
export function createSessionFrameSource(source:ExternalSource<SessionSnapshot>,clock:FrameClock):ExternalSource<SessionSnapshot>{
return Object.freeze({getSnapshot:source.getSnapshot,subscribe(notify:()=>void){
let previous=source.getSnapshot();const queue=createFrameQueue<string>(clock,()=>notify());
const stop=source.subscribe(()=>{const next=source.getSnapshot(),dirty=new Set<string>();
for(const [id,view]of previous.views)if(next.views.get(id)!==view)dirty.add(`view:${id}`);
for(const [id,view]of next.views)if(previous.views.get(id)!==view)dirty.add(`view:${id}`);
if(previous.gesture!==next.gesture)for(const state of [previous.gesture,next.gesture])for(const target of state?.targets??[])dirty.add(`element:${target.id}`);
if(previous.connectionGesture!==next.connectionGesture)for(const state of [previous.connectionGesture,next.connectionGesture])if(state)dirty.add(`element:${state.target.id}`);
previous=next;queue.invalidate(dirty);
});return()=>{stop();queue.dispose();};
}});
}
+7
View File
@@ -0,0 +1,7 @@
import {test} from 'node:test';import assert from 'node:assert/strict';
import {createFrameQueue} from '../../src/lib/canvas-bindings/frame-queue.ts';
import {createSessionFrameSource} from '../../src/lib/canvas-bindings/session-frames.ts';
function clock(){let id=0;const callbacks=new Map();return{request(fn){callbacks.set(++id,fn);return id;},cancel(id){callbacks.delete(id);},step(){const batch=[...callbacks.values()];callbacks.clear();for(const fn of batch)fn();},get size(){return callbacks.size;}};}
test('frame dirty Set deduplicates bursts, flushes last invalidation and preserves reentrant work for the next frame',()=>{const c=clock(),batches=[];let q;q=createFrameQueue(c,dirty=>{batches.push([...dirty]);if(dirty.has('a'))q.invalidate(['next']);});q.invalidate(['a','a']);q.invalidate(['b']);assert.equal(c.size,1);q.flush();assert.deepEqual(batches,[['a','b']]);assert.equal(c.size,1);c.step();assert.deepEqual(batches,[['a','b'],['next']]);q.invalidate(['discard']);q.dispose();c.step();q.invalidate(['closed']);assert.equal(batches.length,2);assert.equal(c.size,0);});
test('canceled late callbacks cannot publish a replaced or disposed frame',()=>{const callbacks=[],c={request(fn){callbacks.push(fn);return callbacks.length;},cancel(){}},batches=[];const q=createFrameQueue(c,dirty=>batches.push([...dirty]));q.invalidate(['first']);q.flush();q.invalidate(['second']);callbacks[0]();assert.deepEqual(batches,[['first']]);q.dispose();callbacks[1]();assert.equal(batches.length,1);});
test('session presentation coalesces preview bursts but live reads and final cancel remain immediate; unsubscribe drops pending frame',()=>{const c=clock(),listeners=new Set();let snapshot={views:new Map(),gesture:null,connectionGesture:null};const source={getSnapshot:()=>snapshot,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);}},framed=createSessionFrameSource(source,c);let paints=0;const stop=framed.subscribe(()=>paints++);const emit=next=>{snapshot=next;for(const fn of listeners)fn();};for(let i=0;i<20;i++)emit({...snapshot,gesture:{targets:[{id:'a',incarnation:1}],preview:new Map([['a',{x:i}]])}});assert.equal(c.size,1);assert.equal(paints,0);assert.equal(framed.getSnapshot().gesture.preview.get('a').x,19);c.step();assert.equal(paints,1);emit({...snapshot,gesture:null});assert.equal(framed.getSnapshot().gesture,null);c.step();assert.equal(paints,2);emit({...snapshot,views:new Map([['v',{}]])});stop();c.step();assert.equal(paints,2);assert.equal(listeners.size,0);});