From 04a9b0c2b2ff4e1eeaaada397f76f9d51627d5a2 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 17:41:02 +0800 Subject: [PATCH] Coalesce session presentation invalidations through a disposable frame queue --- headless/README.md | 13 +++++++++++++ src/components/canvas/headless-graph-canvas.tsx | 4 +++- src/lib/canvas-bindings/frame-queue.ts | 11 +++++++++++ src/lib/canvas-bindings/index.ts | 3 +++ src/lib/canvas-bindings/session-frames.ts | 16 ++++++++++++++++ tests/headless/frame-queue.test.mjs | 7 +++++++ 6 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/lib/canvas-bindings/frame-queue.ts create mode 100644 src/lib/canvas-bindings/session-frames.ts create mode 100644 tests/headless/frame-queue.test.mjs diff --git a/headless/README.md b/headless/README.md index 3901379..15f8918 100644 --- a/headless/README.md +++ b/headless/README.md @@ -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. diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 237df4b..07a460e 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -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); diff --git a/src/lib/canvas-bindings/frame-queue.ts b/src/lib/canvas-bindings/frame-queue.ts new file mode 100644 index 0000000..ccd0e89 --- /dev/null +++ b/src/lib/canvas-bindings/frame-queue.ts @@ -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(clock:FrameClock,publish:(dirty:ReadonlySet)=>void){ + let dirty=new Set(),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){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();}, + }); +} diff --git a/src/lib/canvas-bindings/index.ts b/src/lib/canvas-bindings/index.ts index 2feb637..96af7ce 100644 --- a/src/lib/canvas-bindings/index.ts +++ b/src/lib/canvas-bindings/index.ts @@ -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'; diff --git a/src/lib/canvas-bindings/session-frames.ts b/src/lib/canvas-bindings/session-frames.ts new file mode 100644 index 0000000..8c4c2cc --- /dev/null +++ b/src/lib/canvas-bindings/session-frames.ts @@ -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,clock:FrameClock):ExternalSource{ + return Object.freeze({getSnapshot:source.getSnapshot,subscribe(notify:()=>void){ + let previous=source.getSnapshot();const queue=createFrameQueue(clock,()=>notify()); + const stop=source.subscribe(()=>{const next=source.getSnapshot(),dirty=new Set(); + 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();}; + }}); +} diff --git a/tests/headless/frame-queue.test.mjs b/tests/headless/frame-queue.test.mjs new file mode 100644 index 0000000..96b7efb --- /dev/null +++ b/tests/headless/frame-queue.test.mjs @@ -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);});