diff --git a/headless/README.md b/headless/README.md index f6b4614..efacff5 100644 --- a/headless/README.md +++ b/headless/README.md @@ -908,3 +908,17 @@ operation/history-effect admission and final registry/topology validation. This removes a full table copy for every pending change without adding intermediate Roots, history units or network writes. `applyChanges` uses the same conditional change application path and retains its existing strict/default behavior. + +CanvasView coalesces Root presentation notifications with `createFrameSource`. +Local user updates/deletes, undo and redo flush pending presentation work +synchronously so controlled inputs retain their native value/caret. Pure creation +and background invalidations may share a frame. Each subscription owns one +disposable frame queue; all editor commits still +advance scene-change hints synchronously. Snapshot reads always delegate to the +current authoritative Root, so commands, history, persistence and input identity +checks do not wait for a frame. The adapter holds no delayed document snapshot. +Unsubscription cancels pending work, including callbacks delivered late by a +clock; eager subscription failures also dispose their queue. This batches source +notifications, not every React render: another store/prop update may read the +current Root before the pending frame. Session and element subscriptions retain +their existing routing and immediate read semantics. diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 60c9ed6..3eeb204 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -15,6 +15,7 @@ import {nativeRectVisible} from "@/lib/canvas-bindings/visibility"; import {createOverlayIndex} from "@/lib/canvas-bindings/overlays"; import {CanvasOverlays} from "./canvas-overlays"; import {createProjectedPaintIndex} from "@/lib/canvas-bindings/projected-paint"; +import {createFrameSource} from '@/lib/canvas-bindings/frame-queue'; import {createSessionFrameSource} from "@/lib/canvas-bindings/session-frames"; import {routeCanvasKey,canvasKeyContext} from "@/lib/canvas-bindings/input-router"; import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query"; @@ -42,11 +43,17 @@ import styles from "./canvas.module.css"; 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 sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]); - const subscribe = useCallback((notify: () => void) => { + const subscribe = useCallback((notify: (immediate?:boolean) => void) => { let before=engine.getSnapshot().document; - return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document;notify();}); + return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document; + // Controlled inputs must publish their new value before the native event + // finishes. Batch creation/background work may share a presentation frame. + const immediate=commit.origin==='undo'||commit.origin==='redo'||commit.origin==='user'&&commit.changes.some(change=>change.kind==='field'||change.before!==null); + notify(immediate); + }); }, [engine,sceneCache]); - const root = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot); + const rootFrames=useMemo(()=>createFrameSource({subscribe,getSnapshot:engine.getSnapshot},{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine,subscribe]); + const root = useSyncExternalStore(rootFrames.subscribe, rootFrames.getSnapshot, rootFrames.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); diff --git a/src/lib/canvas-bindings/frame-queue.ts b/src/lib/canvas-bindings/frame-queue.ts index ccd0e89..ba0391a 100644 --- a/src/lib/canvas-bindings/frame-queue.ts +++ b/src/lib/canvas-bindings/frame-queue.ts @@ -1,3 +1,4 @@ +import type {ExternalSource} from './selectors.ts'; /** 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){ @@ -9,3 +10,13 @@ export function createFrameQueue(clock:FrameClock,publish:(dirty:ReadonlySet< 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. */ +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;} + return()=>{try{stop();}finally{queue.dispose();}}; + }}); +} diff --git a/src/lib/canvas-bindings/index.ts b/src/lib/canvas-bindings/index.ts index 7b7adcf..358296d 100644 --- a/src/lib/canvas-bindings/index.ts +++ b/src/lib/canvas-bindings/index.ts @@ -12,7 +12,7 @@ export {createSelectorStore} from './selectors.ts'; export type {ExternalSource} from './selectors.ts'; export {routeCanvasKey,canvasKeyContext} from './input-router.ts'; export type {CanvasKeyInput,InputClaim,KeyHandler} from './input-router.ts'; -export {createFrameQueue} from './frame-queue.ts'; +export {createFrameQueue,createFrameSource} from './frame-queue.ts'; export type {FrameClock} from './frame-queue.ts'; export {createSessionFrameSource} from './session-frames.ts'; export {createPaintIndex} from './paint-index.ts'; diff --git a/tests/headless/frame-queue.test.mjs b/tests/headless/frame-queue.test.mjs index 96b7efb..1ceb83c 100644 --- a/tests/headless/frame-queue.test.mjs +++ b/tests/headless/frame-queue.test.mjs @@ -1,7 +1,26 @@ import {test} from 'node:test';import assert from 'node:assert/strict'; -import {createFrameQueue} from '../../src/lib/canvas-bindings/frame-queue.ts'; +import {createFrameQueue,createFrameSource} 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);}); + +test('root presentation batches a burst while commands, history and authoritative reads advance immediately',async()=>{ + const {createDocument,createEditor,createRegistry,builtinComponents}=await import('../../src/lib/canvas-core/index.ts');let id=0; + const editor=createEditor({document:createDocument('p',[{id:'canvas',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'canvas',componentId:'text',dataVersion:1,structure:'node',data:{title:'before',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}]),registry:createRegistry(builtinComponents()),createId:()=>`op-${++id}`}),c=clock(),commits=[],source={getSnapshot:editor.getSnapshot,subscribe:notify=>editor.subscribe((commit,next)=>{commits.push(next.rootRevision);notify();})},framed=createFrameSource(source,c),painted=[],stop=framed.subscribe(()=>painted.push(framed.getSnapshot())); + for(const title of ['first','second','third'])assert.equal(editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title}}).status,'applied'); + assert.equal(commits.length,3);assert.equal(painted.length,0);assert.equal(c.size,1);assert.strictEqual(framed.getSnapshot(),editor.getSnapshot());assert.equal(framed.getSnapshot().document.elements.get('a').data.title,'third');assert.equal(editor.getSnapshot().history.undo.length,3);c.step();assert.equal(painted.length,1);assert.strictEqual(painted[0],editor.getSnapshot());stop();editor.dispose(); +}); +test('frame source subscriptions own their queues and late callbacks cannot notify an unsubscribed view',()=>{ + const callbacks=[],c={request(fn){callbacks.push(fn);return callbacks.length;},cancel(){}},listeners=new Set();let snapshot=0; + const source={getSnapshot:()=>snapshot,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);}},framed=createFrameSource(source,c);let a=0,b=0;const stopA=framed.subscribe(()=>a++);snapshot++;for(const fn of listeners)fn();stopA();const stopB=framed.subscribe(()=>b++);snapshot++;for(const fn of listeners)fn();callbacks[0]();assert.equal(a,0);assert.equal(b,0);callbacks[1]();assert.equal(b,1);assert.equal(framed.getSnapshot(),2);stopB();assert.equal(listeners.size,0); +}); +test('eager failed subscriptions cancel presentation work and reentrant changes wait for the next frame',()=>{ + const c=clock(),broken=createFrameSource({getSnapshot:()=>0,subscribe(notify){notify();throw Error('failed subscribe');}},c);assert.throws(()=>broken.subscribe(()=>assert.fail('failed subscription painted')),/failed subscribe/);assert.equal(c.size,0); + let current=0,listener;const source={getSnapshot:()=>current,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>{seen.push(framed.getSnapshot());if(current===1){current=2;listener();}});current=1;listener();c.step();assert.deepEqual(seen,[1]);assert.equal(c.size,1);c.step();assert.deepEqual(seen,[1,2]);stop(); +}); + +test('an immediate input invalidation publishes the newest Root and consumes any pending background frame',()=>{ + const c=clock();let value=0,listener;const source={getSnapshot:()=>value,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>seen.push(framed.getSnapshot()));value=1;listener();assert.equal(c.size,1);value=2;listener(true);assert.deepEqual(seen,[2]);assert.equal(c.size,0);c.step();assert.deepEqual(seen,[2]);value=3;listener();c.step();assert.deepEqual(seen,[2,3]);stop(); +});