Scope mode and gesture subscriptions and isolate controller leases
This commit is contained in:
+22
-3
@@ -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.
|
||||
|
||||
@@ -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<ViewSnapshot>{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<ProjectEngine,ReturnType<typeof createSessionSources>>();
|
||||
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<A,B>(left:ExternalSource<A>,right:ExternalSource<B>):ExternalSource<readonly[A,B]>{
|
||||
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<number|null>(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<Geometry>):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<Geometry>):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]);
|
||||
}
|
||||
|
||||
@@ -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<SessionSnapshot>){
|
||||
type Observer={update(snapshot:SessionSnapshot):boolean;notify():void};
|
||||
const modes=new Map<Id,Map<Id,Set<Observer>>>(),gestures=new Map<Id,Set<Observer>>();
|
||||
const targetIndexes=new WeakMap<readonly Target[],ReadonlyMap<Id,number>>();
|
||||
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<Observer>(),add=(items:Set<Observer>|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<Id>();
|
||||
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<T>(read:(snapshot:SessionSnapshot)=>T,register:(observer:Observer)=>()=>void):ExternalSource<T>{
|
||||
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<string|null>{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<GestureSelection>{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);};});},
|
||||
});
|
||||
}
|
||||
@@ -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))),'<span>react-consumer:synced:absent</span>');
|
||||
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))),'<span>react-consumer:synced:absent:null:false</span>');
|
||||
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);
|
||||
|
||||
@@ -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(<><Title/><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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user