Coalesce session presentation invalidations through a disposable frame queue
This commit is contained in:
@@ -296,3 +296,16 @@ its `ReactRendererDefinition`. The assembly exposes `previewNodeTypes` and
|
|||||||
app never constructs a fake editor merely to satisfy the hooks. Preview functions
|
app never constructs a fake editor merely to satisfy the hooks. Preview functions
|
||||||
must render the supplied records without requiring CanvasProvider. Built-in preview
|
must render the supplied records without requiring CanvasProvider. Built-in preview
|
||||||
renderers are registered explicitly. Framework definitions never enter core data.
|
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 type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types";
|
||||||
import { equal } from "@/lib/canvas-core/values";
|
import { equal } from "@/lib/canvas-core/values";
|
||||||
import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache";
|
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 {routeCanvasKey} from "@/lib/canvas-bindings/input-router";
|
||||||
import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query";
|
import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query";
|
||||||
import {compatConnector,measuredConnector} from "@/lib/canvas-bindings/connectors";
|
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 }) {
|
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 subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
|
||||||
const root = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot);
|
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 liveView = sessions.views.get(viewId), lastView = useRef(liveView);
|
||||||
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
|
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
|
||||||
|
|
||||||
|
|||||||
@@ -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();},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,3 +12,6 @@ export {createSelectorStore} from './selectors.ts';
|
|||||||
export type {ExternalSource} from './selectors.ts';
|
export type {ExternalSource} from './selectors.ts';
|
||||||
export {routeCanvasKey} from './input-router.ts';
|
export {routeCanvasKey} from './input-router.ts';
|
||||||
export type {CanvasKeyInput,InputClaim,KeyHandler} 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';
|
||||||
|
|||||||
@@ -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();};
|
||||||
|
}});
|
||||||
|
}
|
||||||
@@ -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);});
|
||||||
Reference in New Issue
Block a user