From b833f0a2fc9050651671f0285c3b1bf7cbb80c07 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 20:58:57 +0800 Subject: [PATCH] Scope mode and gesture subscriptions and isolate controller leases --- headless/README.md | 25 ++++++++-- src/lib/canvas-bindings/react.ts | 23 +++++---- src/lib/canvas-bindings/session-sources.ts | 54 ++++++++++++++++++++++ tests/headless/package.integration.mjs | 8 ++-- tests/headless/react-hooks-fixture.tsx | 6 ++- tests/headless/session-sources.test.mjs | 31 +++++++++++++ 6 files changed, 130 insertions(+), 17 deletions(-) create mode 100644 src/lib/canvas-bindings/session-sources.ts create mode 100644 tests/headless/session-sources.test.mjs diff --git a/headless/README.md b/headless/README.md index 7a47bcd..8052b3b 100644 --- a/headless/README.md +++ b/headless/README.md @@ -269,11 +269,14 @@ Import from `/react` for React only, or the re-export in `/react-flow`. - `useActions(target)` exposes stable `rename`, `setData`, `resize`, `remove`, and `moveTo` facades over normal editor commands. Optional command settings retain label/grouping/history semantics. Call actions from events, not during render. -- `useMode(target)` returns the current view's mode and `setMode`; it never writes - document/history state. Missing view context is an explicit error. +- `useMode(target)` returns the current view's mode and `setMode`; its session + subscription is keyed by view and target. It never writes document/history + state. Missing view context is an explicit error. - `useGestureBindings(target)` returns effective geometry, active ownership and `begin/update/commit/cancel`. A token protects the lease; cleanup cancels only - its own lease. Host input bindings still handle pointer capture and events. + its own lease. Controller replacements keep independent token slots. Preview + geometry remains shared across views; active ownership and controls belong to + the owning view. Host bindings still handle pointer capture and events. - `useOperation(id)` observes both the logical record and its frozen request. - `useAsset(id)` resolves a releasable display handle, returns metadata plus loading/ready/error/url/retry, and releases on ref/owner change or unmount. @@ -523,3 +526,19 @@ const title = useElement(target, view => A change to another field on this target evaluates the title selector but does not rerender its consumer. Unrelated target edits skip the selector entirely. This is ID routing and value selection, not a static field dependency index. + + +Mode and gesture hooks pair keyed element lookups with small session selections, +instead of retaining a full Root/session pair per hook. All session channels in +an engine share one upstream listener. Mode buckets use view→target Maps; +gesture buckets use target IDs. Preview updates compare the active preview map, +then notify only changed targets. A WeakMap indexes each immutable target array +once for incarnation checks. Viewport/selection-only updates and connection +previews do not reevaluate these node hook selectors. + +The pairing caches lookup/selection tuples; values stay fresh on direct reads, +and equal effective geometry/mode values suppress intermediate renders. The last +subscription removes its bucket and releases the upstream listener. A controller +identity change (engine/view/target/incarnation) creates a private lease slot, so +old cleanup cannot consume the replacement controller's token. This does not +change the one shared gesture owner, its history unit or durable task lifecycle. diff --git a/src/lib/canvas-bindings/react.ts b/src/lib/canvas-bindings/react.ts index d88d13a..e4c19a1 100644 --- a/src/lib/canvas-bindings/react.ts +++ b/src/lib/canvas-bindings/react.ts @@ -2,10 +2,10 @@ import {createContext,createElement,useCallback,useContext,useEffect,useMemo,useRef,useState,useSyncExternalStore,type ReactNode} from 'react'; import {createSelectorStore,type ExternalSource} from './selectors.ts'; import {createElementSources} from './element-sources.ts'; +import {createSessionSources,type GestureSelection} from './session-sources.ts'; import type {ProjectEngine} from '../canvas-runtime/workspace.ts'; import type {EngineSnapshot} from '../canvas-runtime/contracts.ts'; import type {Target,Id,Data,Geometry,ElementRecord,Availability,Result} from '../canvas-core/types.ts'; -import type {SessionSnapshot} from '../canvas-core/gestures.ts'; import type {ElementLookup} from '../canvas-core/queries.ts'; import {equal,fault} from '../canvas-core/values.ts'; import type {OperationRecord,FrozenRequest} from '../canvas-runtime/ledger.ts'; @@ -42,22 +42,29 @@ export function useActions(target:Target){const {engine}=useCanvasBinding(),{id, remove:(options?:ActionOptions)=>engine.editor.dispatch({type:'element.remove',target:ref},options), moveTo:(canvasId:Id,geometry?:Geometry,options?:ActionOptions)=>engine.editor.dispatch({type:'element.move',target:ref,canvasId,...(geometry?{geometry}:{})},options), });},[engine,id,incarnation]);} -type ViewSnapshot={root:EngineSnapshot;sessions:SessionSnapshot}; -function useViewSource():ExternalSource{const {engine}=useCanvasBinding();return useMemo(()=>{let previous:ViewSnapshot|undefined;return{getSnapshot(){const root=engine.getSnapshot(),sessions=engine.sessions.getSnapshot();if(!previous||previous.root!==root||previous.sessions!==sessions)previous={root,sessions};return previous;},subscribe(notify:()=>void){if(engine.getDurability().closed)return()=>{};const a=engine.editor.subscribe(notify),b=engine.sessions.subscribe(notify);return()=>{a();b();};}};},[engine]);} +const sessionSources=new WeakMap>(); +function sessionSourcesFor(engine:ProjectEngine){let sources=sessionSources.get(engine);if(!sources){sources=createSessionSources({getSnapshot:engine.sessions.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.sessions.subscribe(notify)});sessionSources.set(engine,sources);}return sources;} +function pairedSource(left:ExternalSource,right:ExternalSource):ExternalSource{ + let previous:readonly[A,B]|undefined; + return{getSnapshot(){const a=left.getSnapshot(),b=right.getSnapshot();if(!previous||!Object.is(previous[0],a)||!Object.is(previous[1],b))previous=Object.freeze([a,b] as const);return previous;},subscribe(notify){const a=left.subscribe(notify);try{const b=right.subscribe(notify);return()=>{a();b();};}catch(error){a();throw error;}}}; +} function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};} function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};} export function useMode(target:Target){ const {engine,viewId}=useView(),{id,incarnation}=target; - const mode=useSelected(useViewSource(),useCallback(({root,sessions}:ViewSnapshot)=>root.document.elements.get(id)?.incarnation===incarnation?sessions.views.get(viewId)?.modes.get(id)??null:null,[id,incarnation,viewId])); + const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).mode(viewId,id)),[engine,viewId,id]); + const mode=useSelected(source,useCallback(([found,mode]:readonly[ElementLookup,string|null])=>found.status==='present'&&found.element.incarnation===incarnation?mode:null,[incarnation])); const setMode=useCallback((mode:string|null):Availability=>{try{if(engine.getSnapshot().document.elements.get(id)?.incarnation!==incarnation)throw fault('stale_incarnation',[id]);engine.sessions.mode(viewId,id,mode);return{enabled:true};}catch(error){return available(error);}},[engine,viewId,id,incarnation]); return useMemo(()=>Object.freeze({mode,setMode}),[mode,setMode]); } export function useGestureBindings(target:Target){ - const {engine,viewId}=useView(),{id,incarnation}=target,lease=useRef(null); - const state=useSelected(useViewSource(),useCallback(({root,sessions}:ViewSnapshot)=>{const element=root.document.elements.get(id),gesture=sessions.gesture;return{geometry:element?.incarnation===incarnation&&element.structure==='node'?sessions.gesture?.preview.get(id)??element.geometry:undefined,active:!!gesture&&gesture.viewId===viewId&&gesture.targets.some(t=>t.id===id&&t.incarnation===incarnation)};},[id,incarnation,viewId]),equal); + const {engine,viewId}=useView(),{id,incarnation}=target; + const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).gesture(viewId,{id,incarnation})),[engine,viewId,id,incarnation]); + const state=useSelected(source,useCallback(([found,transient]:readonly[ElementLookup,GestureSelection])=>({geometry:found.status==='present'&&found.element.incarnation===incarnation&&found.element.structure==='node'?transient.preview??found.element.geometry:undefined,active:transient.active}),[incarnation]),equal); const controls=useMemo(()=>{ - const owned=()=>lease.current!==null&&engine.sessions.getSnapshot().gesture?.token===lease.current&&engine.sessions.getSnapshot().gesture?.viewId===viewId&&engine.sessions.getSnapshot().gesture?.phase==='active'; - return Object.freeze({begin():Availability{const result=engine.sessions.begin(viewId,[{id,incarnation}]);if(result.enabled)lease.current=engine.sessions.getSnapshot().gesture!.token;return result;},update(patch:Partial):Availability{return owned()?engine.sessions.update(viewId,{id,incarnation},patch):{enabled:false,code:'gesture_not_owned',targets:[id]};},commit():Result{if(!owned())return{status:'rejected',code:'gesture_not_owned',targets:[id]};try{return engine.sessions.commit(viewId);}finally{lease.current=null;}},cancel(){if(owned())engine.sessions.cancel('binding_canceled');lease.current=null;}}); + let lease:number|null=null; + const owned=()=>lease!==null&&engine.sessions.getSnapshot().gesture?.token===lease&&engine.sessions.getSnapshot().gesture?.viewId===viewId&&engine.sessions.getSnapshot().gesture?.phase==='active'; + return Object.freeze({begin():Availability{const result=engine.sessions.begin(viewId,[{id,incarnation}]);if(result.enabled)lease=engine.sessions.getSnapshot().gesture!.token;return result;},update(patch:Partial):Availability{return owned()?engine.sessions.update(viewId,{id,incarnation},patch):{enabled:false,code:'gesture_not_owned',targets:[id]};},commit():Result{if(!owned())return{status:'rejected',code:'gesture_not_owned',targets:[id]};try{return engine.sessions.commit(viewId);}finally{lease=null;}},cancel(){if(owned())engine.sessions.cancel('binding_canceled');lease=null;}}); },[engine,viewId,id,incarnation]); useEffect(()=>()=>controls.cancel(),[controls]);return useMemo(()=>Object.freeze({...state,...controls}),[state,controls]); } diff --git a/src/lib/canvas-bindings/session-sources.ts b/src/lib/canvas-bindings/session-sources.ts new file mode 100644 index 0000000..70de203 --- /dev/null +++ b/src/lib/canvas-bindings/session-sources.ts @@ -0,0 +1,54 @@ +import type {Id,Target,Geometry} from '../canvas-core/types.ts'; +import type {SessionSnapshot,GestureState} from '../canvas-core/gestures.ts'; +import {equal} from '../canvas-core/values.ts'; +import type {ExternalSource} from './selectors.ts'; + +export type GestureSelection={readonly preview:Geometry|undefined;readonly active:boolean}; +/** Derived view/target subscriptions. Values retain no document or full per-hook session. */ +export function createSessionSources(source:ExternalSource){ + type Observer={update(snapshot:SessionSnapshot):boolean;notify():void}; + const modes=new Map>>(),gestures=new Map>(); + const targetIndexes=new WeakMap>(); + let stop:(()=>void)|undefined,previous:SessionSnapshot|undefined; + function targets(gesture:GestureState){let index=targetIndexes.get(gesture.targets);if(!index){index=new Map(gesture.targets.map(target=>[target.id,target.incarnation]));targetIndexes.set(gesture.targets,index);}return index;} + function changed(){ + if(!previous)return;const before=previous,next=source.getSnapshot();previous=next; + const candidates=new Set(),add=(items:Set|undefined)=>{for(const item of items??[])candidates.add(item);}; + if(before.views!==next.views)for(const[viewId,byId]of modes){ + const old=before.views.get(viewId)?.modes,current=next.views.get(viewId)?.modes;if(old===current)continue; + for(const id of new Set([...(old?.keys()??[]),...(current?.keys()??[])]))if(old?.get(id)!==current?.get(id))add(byId.get(id)); + } + const old=before.gesture,current=next.gesture; + if(old!==current){ + const ids=new Set(); + if(!old||!current||old.token!==current.token||old.viewId!==current.viewId||old.targets!==current.targets){for(const gesture of [old,current])for(const target of gesture?.targets??[])ids.add(target.id);} + else if(old.preview!==current.preview){for(const[id,value]of old.preview)if(current.preview.get(id)!==value)ids.add(id);for(const[id,value]of current.preview)if(old.preview.get(id)!==value)ids.add(id);} + for(const id of ids)add(gestures.get(id)); + } + const notifications:Observer[]=[];for(const observer of candidates)if(observer.update(next))notifications.push(observer); + let failed=false,error:unknown;for(const observer of notifications)try{observer.notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error; + } + function channel(read:(snapshot:SessionSnapshot)=>T,register:(observer:Observer)=>()=>void):ExternalSource{ + let initialized=false,value:T; + return Object.freeze({ + getSnapshot(){const next=read(source.getSnapshot());if(!initialized||!equal(value,next))value=next;initialized=true;return value;}, + subscribe(notify:()=>void){ + let observed=read(source.getSnapshot()),failed=false; + const observer:Observer={notify,update(snapshot){try{const next=read(snapshot);if(!failed&&equal(observed,next))return false;observed=next;failed=false;}catch{failed=true;}return true;}}; + const remove=register(observer); + if(!stop){previous=source.getSnapshot();try{stop=source.subscribe(changed);}catch(error){remove();previous=undefined;throw error;}} + let active=true;return()=>{if(!active)return;active=false;remove();if(!modes.size&&!gestures.size){stop?.();stop=undefined;previous=undefined;}}; + }, + }); + } + return Object.freeze({ + mode(viewId:Id,id:Id):ExternalSource{return channel(snapshot=>snapshot.views.get(viewId)?.modes.get(id)??null,observer=>{ + let byId=modes.get(viewId);if(!byId){byId=new Map();modes.set(viewId,byId);}let bucket=byId.get(id);if(!bucket){bucket=new Set();byId.set(id,bucket);}bucket.add(observer); + return()=>{bucket!.delete(observer);if(!bucket!.size)byId!.delete(id);if(!byId!.size)modes.delete(viewId);}; + });}, + gesture(viewId:Id,target:Target):ExternalSource{const{id,incarnation}=target;return channel(snapshot=>{ + const gesture=snapshot.gesture,matched=!!gesture&&targets(gesture).get(id)===incarnation; + return{preview:matched?gesture!.preview.get(id):undefined,active:matched&&gesture!.viewId===viewId}; + },observer=>{let bucket=gestures.get(id);if(!bucket){bucket=new Set();gestures.set(id,bucket);}bucket.add(observer);return()=>{bucket!.delete(observer);if(!bucket!.size)gestures.delete(id);};});}, + }); +} diff --git a/tests/headless/package.integration.mjs b/tests/headless/package.integration.mjs index d27c2e6..394e398 100644 --- a/tests/headless/package.integration.mjs +++ b/tests/headless/package.integration.mjs @@ -78,10 +78,10 @@ try { import React from 'react';import {renderToStaticMarkup} from 'react-dom/server'; import {createReactRenderers,CanvasRendererCommandsContext,useCanvasRendererCommands} from '@endoleap/headless-canvas/react-flow'; import {createRegistry,builtinComponents} from '@endoleap/headless-canvas/core'; - import {CanvasProvider,useCanvas,useSyncStatus,useElement} from '@endoleap/headless-canvas/react'; - const snapshot={rootRevision:0,runtime:{ledger:{operations:new Map(),requests:new Map()}},document:{projectId:'react-consumer'}};const durable={rootRevision:0,durableRootRevision:0,closed:false,closing:false};const hookEngine={getSnapshot:()=>snapshot,getDurability:()=>durable,subscribeDurability:()=>()=>{},editor:{subscribe:()=>()=>{},getQueries:()=>({lookupElement:()=>({status:'absent'})})}}; - function HookView(){const id=useCanvas(value=>value.document.projectId),status=useSyncStatus(),elementStatus=useElement('missing',view=>view.status);return React.createElement('span',null,id+':'+status.status+':'+elementStatus);} - assert.equal(renderToStaticMarkup(React.createElement(CanvasProvider,{engine:hookEngine},React.createElement(HookView))),'react-consumer:synced:absent'); + import {CanvasProvider,useCanvas,useSyncStatus,useElement,useMode,useGestureBindings} from '@endoleap/headless-canvas/react'; + const snapshot={rootRevision:0,runtime:{ledger:{operations:new Map(),requests:new Map()}},document:{projectId:'react-consumer'}};const durable={rootRevision:0,durableRootRevision:0,closed:false,closing:false};const hookEngine={getSnapshot:()=>snapshot,getDurability:()=>durable,subscribeDurability:()=>()=>{},editor:{subscribe:()=>()=>{},getQueries:()=>({lookupElement:()=>({status:'absent'})})},sessions:{getSnapshot:()=>({views:new Map([['ssr',{modes:new Map()}]]),gesture:null}),subscribe:()=>()=>{}}}; + function HookView(){const id=useCanvas(value=>value.document.projectId),status=useSyncStatus(),elementStatus=useElement('missing',view=>view.status),mode=useMode({id:'missing',incarnation:1}),gesture=useGestureBindings({id:'missing',incarnation:1});return React.createElement('span',null,id+':'+status.status+':'+elementStatus+':'+mode.mode+':'+gesture.active);} + assert.equal(renderToStaticMarkup(React.createElement(CanvasProvider,{engine:hookEngine,viewId:'ssr'},React.createElement(HookView))),'react-consumer:synced:absent:null:false'); const registry=createRegistry(builtinComponents());let dispatched=0,seen;const commands={dispatch(){dispatched++;return{status:'applied',revision:1,operationIds:[]};},transaction(){throw Error('unused');}}; function View(){seen=useCanvasRendererCommands();return React.createElement('span',null,'peer renderer');} const renderers=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'fixture',render:View}],registry); diff --git a/tests/headless/react-hooks-fixture.tsx b/tests/headless/react-hooks-fixture.tsx index 0453e07..4b2a580 100644 --- a/tests/headless/react-hooks-fixture.tsx +++ b/tests/headless/react-hooks-fixture.tsx @@ -1,5 +1,5 @@ "use client"; -import {StrictMode,memo,useEffect} from 'react'; +import {StrictMode,memo,useEffect,useLayoutEffect} from 'react'; import {createRoot} from 'react-dom/client';import {flushSync} from 'react-dom'; import {CanvasProvider,useCanvas,useElement,useActions,useMode,useGestureBindings,useOperation,useAsset,useSyncStatus,type ElementView} from '@/lib/canvas-bindings/react'; import {createRegistry,builtinComponents,createDocument,applyChanges} from '@/lib/canvas-core/index'; @@ -24,7 +24,7 @@ async function run(){ const ElementTitle=memo(function ElementTitle(){titleOnly=useElement({id:'a',incarnation:1},selectTitle);counts.titleOnly++;return null;}); const Actions=memo(function Actions(){actions=useActions({id:'a',incarnation:1});return null;}); const Mode=memo(function Mode(){mode=useMode({id:'a',incarnation:1});counts.mode++;return null;}); - const Gesture=memo(function Gesture(){gesture=useGestureBindings({id:'a',incarnation:1});counts.gesture++;return null;}); + const Gesture=memo(function Gesture({targetId='a',beginOnLayout=false}:{targetId?:string;beginOnLayout?:boolean}){const binding=useGestureBindings({id:targetId,incarnation:1});gesture=binding;counts.gesture++;useLayoutEffect(()=>{if(beginOnLayout){assert(binding.begin().enabled,'replacement gesture did not begin');assert(binding.update({x:88}).enabled,'replacement gesture did not preview');}},[targetId,beginOnLayout]);return null;}); const Operation=memo(function Operation({id}:{id:string}){operation=useOperation(id);return null;}); const Status=memo(function Status(){status=useSyncStatus();return null;}); const Asset=memo(function Asset({id,slot}:{id:string;slot:'a'|'b'}){const value=useAsset(id);if(slot==='a')assetA=value;else assetB=value;return null;}); @@ -36,6 +36,8 @@ async function run(){ const renamed=actions.rename('renamed',{label:'Hook rename',groupId:'hook-rename'});await engine.settle();const renamedHistory=engine.getSnapshot().history.entries.get(engine.getSnapshot().history.undo.at(-1)!);assert(renamedHistory?.label==='Hook rename'&&renamedHistory.groupId==='hook-rename','action facade dropped command history options');await until(()=>title.title==='renamed'&&titleOnly==='renamed');assert(element.status==='present'&&element.element.data.title==='renamed','element did not observe canonical rename');await render(<><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id={renamed.operationIds[0]}/><Status/></>);assert(operation?.operation.phase==='resolved'&&operation.request?.phase==='resolved','operation hook did not include its request');reports.push('selectors suppress unrelated element and ACK renders; bound actions share validated history/ledger and operation/request observation'); const history=engine.getSnapshot().history.undo.length,revision=engine.getSnapshot().revision;assert(gesture.begin().enabled&&gesture.update({x:42}).enabled,'gesture did not acquire/update');await until(()=>gesture.active&&gesture.geometry.x===42);assert(engine.getSnapshot().document.elements.get('a')!.structure==='node'&&(engine.getSnapshot().document.elements.get('a') as any).geometry.x===0,'preview mutated committed geometry');assert(engine.getSnapshot().revision===revision,'preview created an edit');gesture.cancel();await until(()=>!gesture.active);assert(engine.getSnapshot().history.undo.length===history,'canceled preview changed history');assert(mode.setMode('editing').enabled,'view mode rejected');await until(()=>mode.mode==='editing');const modeRenders=counts.mode;engine.sessions.mode('w','a','resize');await tick();assert(counts.mode===modeRenders,'another view changed this view mode'); assert(gesture.begin().enabled,'second gesture did not acquire');gesture.update({x:55});assert(gesture.commit().status==='applied','gesture did not commit');await engine.settle();assert(engine.getSnapshot().history.undo.length===history+1,'gesture did not create exactly one history entry');assert(gesture.commit().status==='rejected','released gesture lease remained writable');reports.push('gesture preview/cancel/commit owns a token and one history unit; modes stay scoped to their view'); + const previousGesture=gesture,beforeOwnerSwap=engine.getSnapshot();await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture targetId="b" beginOnLayout/><Operation id={renamed.operationIds[0]}/><Status/></>);const replacementToken=engine.sessions.getSnapshot().gesture?.token;assert(!!replacementToken&&engine.sessions.getSnapshot().gesture?.targets[0].id==='b','old target cleanup canceled the new layout gesture');assert(previousGesture.commit().status==='rejected','old controls committed the replacement gesture');previousGesture.cancel();assert(engine.sessions.getSnapshot().gesture?.token===replacementToken,'old controls canceled the replacement gesture');assert(engine.getSnapshot()===beforeOwnerSwap,'owner switch or obsolete callback published document/history work');gesture.cancel();await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id={renamed.operationIds[0]}/><Status/></>);reports.push('gesture controller replacement owns an independent lease; old cleanup, cancel and commit cannot touch a new target gesture'); + engine.editor.updateRuntime(previous=>({...previous}));await engine.persist();await tick();await until(()=>status.status==='synced'&&!status.dirty);assert(!status.dirty,'durable-only metadata completion was not observable');f.fail(true);actions.rename('unsaved');await until(()=>status.status==='blocked'&&status.error==='quota');assert(status.dirty,'failed persistence appeared durable');f.fail(false);await engine.retry();await engine.settle();await until(()=>status.status==='synced'&&!status.dirty);reports.push('sync status observes durable-only completion and quota failure/retry without claiming in-memory edits are synced'); let resource='';engine.stageFiles([{name:'image.png',bytes:new Blob(['image bytes'],{type:'image/png'})}],([ref],tx)=>{resource=ref.id;tx.dispatch({type:'element.create',element:{...f.node('image'),componentId:'image',data:{title:'image',text:'',assetId:ref.id,src:ref.src}}});});await engine.settle();const reads=f.reads; await render(<><Asset id={resource} slot="a"/><Asset id={resource} slot="b"/><Status/></>);await until(()=>assetA?.status==='ready'&&assetB?.status==='ready');assert(assetA.url===assetB.url&&f.reads===reads+1,'two resource hooks did not share one display read/URL');const url=assetA.url;assert(await(await fetch(url)).text()==='image bytes','asset hook did not expose original bytes');await render(<><Asset id={resource} slot="a"/><Status/></>);assert(await(await fetch(url)).text()==='image bytes','unmounting one reader revoked the other');await render(<Status/>);let revoked=false;try{await fetch(url);}catch{revoked=true;}assert(revoked,'last resource reader did not release its URL');reports.push('resource hooks share a URL, retain remaining readers and release the last handle on unmount'); diff --git a/tests/headless/session-sources.test.mjs b/tests/headless/session-sources.test.mjs new file mode 100644 index 0000000..2278389 --- /dev/null +++ b/tests/headless/session-sources.test.mjs @@ -0,0 +1,31 @@ +import {test} from 'node:test';import assert from 'node:assert/strict'; +import {createSessionSources} from '../../src/lib/canvas-bindings/session-sources.ts'; +import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts'; +function fixture(){let state={views:new Map([['v',{modes:new Map(),viewport:{x:0,y:0,zoom:1}}],['w',{modes:new Map()}]]),gesture:null,connectionGesture:null},reads=0,broken=false;const listeners=new Set();const source={getSnapshot(){reads++;return state;},subscribe(fn){if(broken)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);}};return{source,get size(){return listeners.size;},get reads(){return reads;},reset(){reads=0;},broken(value){broken=value;},get:()=>state,emit(next){state=next;for(const fn of [...listeners])fn();},gesture(id='300',viewId='v',incarnation=1){this.emit({...state,gesture:{token:1,viewId,targets:[{id,incarnation}],preview:new Map(),phase:'active'}});},preview(id,x){this.emit({...state,gesture:{...state.gesture,preview:new Map(state.gesture.preview).set(id,{x,y:0,width:10,height:10,rotation:0})}});},mode(view,id,value){const current=state.views.get(view),modes=new Map(current.modes);if(value)modes.set(id,value);else modes.delete(id);this.emit({...state,views:new Map(state.views).set(view,{...current,modes})});}};} + +test('1000 gesture and mode subscribers share one upstream and only the moved target is evaluated',()=>{ + const f=fixture(),sources=createSessionSources(f.source),calls=new Map(),notified=new Set(),stops=[]; + for(let index=0;index<1000;index++)for(const kind of ['mode','gesture']){const id=String(index),key=kind+id,channel=kind==='mode'?sources.mode('v',id):sources.gesture('v',{id,incarnation:1}),store=createSelectorStore(channel,value=>{calls.set(key,(calls.get(key)??0)+1);return value;});stops.push(store.subscribe(()=>notified.add(key)));} + assert.equal(f.size,1);f.gesture();notified.clear();const before=new Map(calls);f.reset();f.preview('300',42);assert.deepEqual([...notified],['gesture300']);assert.equal(f.reads,2);for(const[key,count]of calls)assert.equal(count,before.get(key)+(key==='gesture300'?1:0)); + notified.clear();f.reset();f.emit({...f.get(),views:new Map(f.get().views).set('v',{...f.get().views.get('v'),viewport:{x:50,y:40,zoom:2}})});assert.equal(f.reads,1);assert.equal(notified.size,0);for(const stop of stops)stop();assert.equal(f.size,0); +}); + +test('mode changes route by view and target, and detaching a view resets only its active modes',()=>{ + const f=fixture(),sources=createSessionSources(f.source),seen=[],a=sources.mode('v','a'),b=sources.mode('w','a'),c=sources.mode('v','b'),stops=[a.subscribe(()=>seen.push('v:a')),b.subscribe(()=>seen.push('w:a')),c.subscribe(()=>seen.push('v:b'))];f.mode('w','a','editing');assert.deepEqual(seen,['w:a']);f.mode('v','b','resize');assert.deepEqual(seen,['w:a','v:b']);const views=new Map(f.get().views);views.delete('v');f.emit({...f.get(),views});assert.deepEqual(seen,['w:a','v:b','v:b']);assert.equal(a.getSnapshot(),null);assert.equal(b.getSnapshot(),'editing');for(const stop of stops)stop(); +}); + +test('preview is shared across views while active ownership and incarnation remain exact',()=>{ + const f=fixture(),sources=createSessionSources(f.source),v=sources.gesture('v',{id:'a',incarnation:1}),w=sources.gesture('w',{id:'a',incarnation:1}),stale=sources.gesture('v',{id:'a',incarnation:2}),stops=[v.subscribe(()=>{}),w.subscribe(()=>{}),stale.subscribe(()=>{})];f.gesture('a');f.preview('a',77);assert.equal(v.getSnapshot().active,true);assert.equal(w.getSnapshot().active,false);assert.equal(w.getSnapshot().preview.x,77);assert.deepEqual(stale.getSnapshot(),{preview:undefined,active:false});f.emit({...f.get(),gesture:null});assert.deepEqual(v.getSnapshot(),{preview:undefined,active:false});for(const stop of stops)stop(); +}); + +test('multi-target preview only notifies the target whose geometry changed',()=>{ + const f=fixture(),sources=createSessionSources(f.source),seen=[],a=sources.gesture('v',{id:'a',incarnation:1}),b=sources.gesture('v',{id:'b',incarnation:1}),stops=[a.subscribe(()=>seen.push('a')),b.subscribe(()=>seen.push('b'))];f.emit({...f.get(),gesture:{token:1,viewId:'v',targets:[{id:'a',incarnation:1},{id:'b',incarnation:1}],preview:new Map(),phase:'active'}});seen.length=0;f.preview('a',5);assert.deepEqual(seen,['a']);f.preview('b',8);assert.deepEqual(seen,['a','b']);const before=a.getSnapshot();f.emit({...f.get(),connectionGesture:{target:{id:'edge',incarnation:1}}});assert.strictEqual(a.getSnapshot(),before);for(const stop of stops)stop(); +}); + +test('unsubscribed reads stay fresh; stale captured upstream callbacks and startup failure clean up safely',()=>{ + const f=fixture();let stop;const earlier=f.source.subscribe(()=>stop?.()),sources=createSessionSources(f.source),channel=sources.mode('v','a');f.broken(true);assert.throws(()=>channel.subscribe(()=>{}),/subscribe failed/);f.broken(false);let calls=0;stop=channel.subscribe(()=>calls++);f.mode('v','a','editing');assert.equal(calls,0);assert.equal(channel.getSnapshot(),'editing');stop();earlier();assert.equal(f.size,0);const again=channel.subscribe(()=>calls++);f.mode('v','a','resize');assert.equal(calls,1);again();again();assert.equal(f.size,0); +}); + +test('observer errors do not starve other targets and all values advance before callbacks',()=>{ + const f=fixture(),sources=createSessionSources(f.source),a=sources.mode('v','a'),b=sources.mode('v','b');let calls=0;const stops=[a.subscribe(()=>{assert.equal(b.getSnapshot(),'editing');throw Error('observer failed');}),b.subscribe(()=>calls++)];assert.throws(()=>f.emit({...f.get(),views:new Map(f.get().views).set('v',{modes:new Map([['a','editing'],['b','editing']])})}),/observer failed/);assert.equal(calls,1);for(const stop of stops)stop(); +});