From ccd61bfb9077b9c81eac09a7531f969cca6e749c Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 20:33:28 +0800 Subject: [PATCH] Route element hooks by ID and support field selectors --- headless/README.md | 27 ++++++++++- src/lib/canvas-bindings/element-sources.ts | 52 ++++++++++++++++++++++ src/lib/canvas-bindings/react.ts | 10 ++++- tests/headless/element-sources.test.mjs | 44 ++++++++++++++++++ tests/headless/package.integration.mjs | 10 ++--- tests/headless/react-hooks-fixture.tsx | 13 +++--- 6 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 src/lib/canvas-bindings/element-sources.ts create mode 100644 tests/headless/element-sources.test.mjs diff --git a/headless/README.md b/headless/README.md index cdfcaea..7a47bcd 100644 --- a/headless/README.md +++ b/headless/README.md @@ -262,8 +262,10 @@ Import from `/react` for React only, or the re-export in `/react-flow`. - `useCanvas(selector, isEqual?)` observes the one immutable project Root. Default equality is `Object.is`; choose an appropriate comparator for allocated results. Snapshots are cached per selection and unchanged selections do not notify React. -- `useElement(idOrTarget)` returns `present`, `absent`, `unloaded`, or `stale`. - A Target includes incarnation; a restored element does not silently retarget it. +- `useElement(idOrTarget, selector?, isEqual?)` returns `present`, `absent`, + `unloaded`, or `stale`, or the supplied selector result. A Target includes + incarnation; a restored element does not silently retarget it. Custom selectors + use `Object.is` by default; provide a comparator for allocated field groups. - `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. @@ -500,3 +502,24 @@ streaming SHA implementation. Main-thread fallback stays chunked/cooperative. Native read/hash errors remain visible; they do not silently select another path. Worker termination still owns cancellation. The full source check remains before imported elements are published; no journal revisions or integrity gates are skipped. + + +Element hooks share one upstream editor listener per engine while subscribed. +An ID→bucket Map and listener Sets route explicit changes to their targets; +runtime-only cuts do no element lookup work. Document replacements check active +IDs, and loading coverage changes recheck missing lookups. Reads still consult the +current engine, including while unsubscribed. Buckets retain lookup values, not +per-element Root snapshots; the last listener releases its bucket and the final +bucket releases the upstream listener. No document, history or durable state is +copied into the bindings. Arbitrary `useCanvas` selectors remain Root observers. + +```tsx +const title = useElement(target, view => + view.status === 'present' && typeof view.element.data.title === 'string' + ? view.element.data.title : '' +); +``` + +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. diff --git a/src/lib/canvas-bindings/element-sources.ts b/src/lib/canvas-bindings/element-sources.ts new file mode 100644 index 0000000..9d1aa48 --- /dev/null +++ b/src/lib/canvas-bindings/element-sources.ts @@ -0,0 +1,52 @@ +import type {Commit,Id} from '../canvas-core/types.ts'; +import type {ElementLookup} from '../canvas-core/queries.ts'; +import type {EngineSnapshot} from '../canvas-runtime/contracts.ts'; +import {equal} from '../canvas-core/values.ts'; +import type {ExternalSource} from './selectors.ts'; + +export type ElementSourcePorts={ + getSnapshot():EngineSnapshot; + subscribe(listener:(commit:Commit,snapshot:EngineSnapshot)=>void):()=>void; + lookup(snapshot:EngineSnapshot,id:Id):ElementLookup; +}; +/** One upstream subscription, ID buckets and cached lookup values, never another document. + * Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */ +export function createElementSources(ports:ElementSourcePorts){ + type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean}; + const buckets=new Map();let stop:(()=>void)|undefined,previous:EngineSnapshot|undefined; + function changed(commit:Commit,next:EngineSnapshot){ + // The editor snapshots its listeners; this callback can already be unsubscribed. + if(!previous||!buckets.size)return; + const before=previous!;previous=next; + const ids=new Set(); + if(before.document!==next.document){ + if(commit.changes.length){for(const change of commit.changes)if(change.kind==='field')ids.add(change.target.id);else if(change.kind==='element')ids.add(change.id);} + else for(const id of buckets.keys())ids.add(id); + } + const coverageChanged=before.runtime.loading!==next.runtime.loading||!!next.runtime.loading&&(before.runtime.loadingRead?.document??before.runtime.acknowledged)!==(next.runtime.loadingRead?.document??next.runtime.acknowledged); + if(coverageChanged)for(const[id,bucket]of buckets)if(bucket.value.status!=='present')ids.add(id); + const notifications=new Set<()=>void>(); + for(const id of ids){ + const bucket=buckets.get(id);if(!bucket)continue; + try{const value=ports.lookup(next,id);if(!bucket.failed&&equal(bucket.value,value))continue;bucket.value=value;bucket.failed=false;}catch{bucket.failed=true;/* Snapshot readers retry/raise through their own boundary. */} + for(const notify of bucket.listeners)notifications.add(notify); + } + // All bucket values advance before observers run; a failing observer cannot starve others. + let failed=false,error:unknown; + for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}} + if(failed)throw error; + } + return Object.freeze({forElement(id:Id):ExternalSource{ + let value:ElementLookup|undefined; + return Object.freeze({ + getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;}, + subscribe(notify:()=>void){ + let bucket=buckets.get(id);if(!bucket){bucket={value:ports.lookup(ports.getSnapshot(),id),listeners:new Set()};buckets.set(id,bucket);} + // Distinct subscriptions may intentionally share the same callback identity. + const listener=()=>notify();bucket.listeners.add(listener); + if(!stop){previous=ports.getSnapshot();try{stop=ports.subscribe(changed);}catch(error){bucket.listeners.delete(listener);if(!bucket.listeners.size)buckets.delete(id);if(!buckets.size)previous=undefined;throw error;}} + let active=true;return()=>{if(!active)return;active=false;bucket!.listeners.delete(listener);if(!bucket!.listeners.size)buckets.delete(id);if(!buckets.size){stop?.();stop=undefined;previous=undefined;}}; + }, + }); + }}); +} diff --git a/src/lib/canvas-bindings/react.ts b/src/lib/canvas-bindings/react.ts index 67630b0..d88d13a 100644 --- a/src/lib/canvas-bindings/react.ts +++ b/src/lib/canvas-bindings/react.ts @@ -1,6 +1,7 @@ "use client"; 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 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'; @@ -24,9 +25,14 @@ function useSelected(source:ExternalSource,selector:(snapshot:S)=>T,isEq function useRootSource(){const {engine}=useCanvasBinding();return useMemo(()=>({getSnapshot:engine.getSnapshot,subscribe:(notify:()=>void)=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)}),[engine]);} export function useCanvas(selector:(snapshot:EngineSnapshot)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{return useSelected(useRootSource(),selector,isEqual);} export type ElementView=ElementLookup|{readonly status:'stale';readonly element:ElementRecord}; -export function useElement(ref:Id|Target):ElementView{ +const elementSources=new WeakMap>(); +function elementSourcesFor(engine:ProjectEngine){let sources=elementSources.get(engine);if(!sources){sources=createElementSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify),lookup:(root,id)=>engine.editor.getQueries(root.document).lookupElement(id)});elementSources.set(engine,sources);}return sources;} +export function useElement(ref:Id|Target):ElementView; +export function useElement(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T; +export function useElement(ref:Id|Target,selector?:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T|ElementView{ const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation; - return useCanvas(useCallback(snapshot=>{const found=engine.editor.getQueries(snapshot.document).lookupElement(id);return found.status==='present'&&incarnation!==undefined&&found.element.incarnation!==incarnation?{status:'stale' as const,element:found.element}:found;},[engine,id,incarnation]),equal); + const source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]); + return useSelected(source,useCallback((found:ElementLookup)=>{const view=found.status==='present'&&incarnation!==undefined&&found.element.incarnation!==incarnation?{status:'stale' as const,element:found.element}:found;return selector?selector(view):view;},[incarnation,selector]),(isEqual as ((a:T|ElementView,b:T|ElementView)=>boolean)|undefined)??(selector?Object.is:equal)); } export type ActionOptions=Parameters[1]; export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({ diff --git a/tests/headless/element-sources.test.mjs b/tests/headless/element-sources.test.mjs new file mode 100644 index 0000000..4e6617a --- /dev/null +++ b/tests/headless/element-sources.test.mjs @@ -0,0 +1,44 @@ +import {test} from 'node:test';import assert from 'node:assert/strict'; +import {createElementSources} from '../../src/lib/canvas-bindings/element-sources.ts'; +import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts'; +function fixture(count=3){ + const element=id=>({id,incarnation:1,ownerCanvasId:'root',data:{title:id}}),document={elements:new Map(Array.from({length:count},(_,index)=>{const id=String(index);return[id,element(id)];}))};let root={document,runtime:{acknowledged:document}},throwSubscribe=false;const listeners=new Set(),lookups=[]; + const ports={getSnapshot:()=>root,subscribe(fn){if(throwSubscribe)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);},lookup(snapshot,id){lookups.push(id);const element=snapshot.document.elements.get(id);return element?{status:'present',element}:{status:snapshot.runtime.loading?'unloaded':'absent'};}}; + return{ports,lookups,element,get size(){return listeners.size;},root:()=>root,throwSubscribe(value){throwSubscribe=value;},emit(next,changes=[]){root=next;for(const fn of [...listeners])fn({changes},root);},edit(id,title){const elements=new Map(root.document.elements);elements.set(id,{...elements.get(id),data:{title}});this.emit({...root,document:{elements}},[{kind:'field',target:{id,incarnation:1},field:'data'}]);}}; +} + +test('one ID edit among 1000 subscriptions evaluates only that target; runtime-only cuts do no element work',()=>{ + const f=fixture(1000),sources=createElementSources(f.ports),calls=new Map(),notified=new Set(),stops=[]; + for(let index=0;index<1000;index++){const id=String(index),store=createSelectorStore(sources.forElement(id),view=>{calls.set(id,(calls.get(id)??0)+1);return view.element.data.title;});stops.push(store.subscribe(()=>notified.add(id)));} + assert.equal(f.size,1);const before=new Map(calls);f.lookups.length=0;f.edit('300','changed');assert.deepEqual([...new Set(f.lookups)],['300']);assert.deepEqual([...notified],['300']);for(const[id,count]of calls)assert.equal(count,before.get(id)+(id==='300'?1:0)); + f.lookups.length=0;f.emit({...f.root(),runtime:{...f.root().runtime,metadata:1}});assert.equal(f.lookups.length,0);for(const stop of stops)stop();assert.equal(f.size,0); +}); + +test('ACK replacement checks active targets, ignores equal cloned records and delivers unlisted changes',()=>{ + const f=fixture(),sources=createElementSources(f.ports),seen=[];const a=sources.forElement('0'),b=sources.forElement('1'),stopA=a.subscribe(()=>seen.push('0')),stopB=b.subscribe(()=>seen.push('1')),old=a.getSnapshot(); + const elements=new Map([...f.root().document.elements].map(([id,row])=>[id,structuredClone(row)]));elements.get('1').data.title='canonical';f.emit({...f.root(),document:{elements}});assert.deepEqual(seen,['1']);assert.strictEqual(a.getSnapshot(),old);assert.equal(b.getSnapshot().element.data.title,'canonical');stopA();stopB(); +}); + +test('loading coverage invalidates missing lookups at EOF without waking present targets',()=>{ + const f=fixture(),sources=createElementSources(f.ports);f.emit({...f.root(),runtime:{...f.root().runtime,loading:{version:6}}});const missing=sources.forElement('later'),present=sources.forElement('0'),seen=[];const a=missing.subscribe(()=>seen.push('missing')),b=present.subscribe(()=>seen.push('present'));assert.equal(missing.getSnapshot().status,'unloaded');f.emit({...f.root(),runtime:{...f.root().runtime,loading:undefined}});assert.deepEqual(seen,['missing']);assert.equal(missing.getSnapshot().status,'absent');a();b(); +}); + +test('getSnapshot stays fresh while unsubscribed, repeated cleanup is safe and late subscriptions do not reuse old baselines',()=>{ + const f=fixture(),sources=createElementSources(f.ports),channel=sources.forElement('0');let calls=0;const stop=channel.subscribe(()=>calls++);stop();stop();f.edit('0','unsubscribed');assert.equal(calls,0);assert.equal(channel.getSnapshot().element.data.title,'unsubscribed');const again=channel.subscribe(()=>calls++);f.edit('0','current');assert.equal(calls,1);again();assert.equal(f.size,0); +}); + +test('observer failures do not starve other buckets and failed upstream subscription retains no listener',()=>{ + const f=fixture(),sources=createElementSources(f.ports);f.throwSubscribe(true);assert.throws(()=>sources.forElement('0').subscribe(()=>{}),/subscribe failed/);f.throwSubscribe(false);let b=0;const a=sources.forElement('0').subscribe(()=>{throw Error('observer failed');}),stopB=sources.forElement('1').subscribe(()=>b++),elements=new Map(f.root().document.elements);for(const id of ['0','1'])elements.set(id,{...elements.get(id),data:{title:'new'}});assert.throws(()=>f.emit({...f.root(),document:{elements}},['0','1'].map(id=>({kind:'element',id}))),/observer failed/);assert.equal(b,1);a();stopB();assert.equal(f.size,0); +}); + +test('last unsubscribe before the captured upstream callback runs does not retain or dereference a dead baseline',()=>{ + const f=fixture();let stop;const earlier=f.ports.subscribe(()=>stop()),sources=createElementSources(f.ports);let notified=0;stop=sources.forElement('0').subscribe(()=>notified++);assert.doesNotThrow(()=>f.edit('0','after detach'));assert.equal(notified,0);assert.equal(f.size,1);earlier();assert.equal(f.size,0); +}); + +test('lookup failure notifies readers and clearing the failure re-notifies even when the prior value is restored',()=>{ + const f=fixture(),lookup=f.ports.lookup;let fail=false;f.ports.lookup=(...args)=>{if(fail)throw Error('lookup failed');return lookup(...args);};const channel=createElementSources(f.ports).forElement('0');let calls=0;const stop=channel.subscribe(()=>calls++),before=channel.getSnapshot();fail=true;f.emit({...f.root(),document:{...f.root().document}});assert.equal(calls,1);assert.throws(channel.getSnapshot,/lookup failed/);fail=false;f.emit({...f.root(),document:{...f.root().document}});assert.equal(calls,2);assert.strictEqual(channel.getSnapshot(),before);stop(); +}); + +test('an advanced ACK does not recheck missing IDs against an unchanged captured loading baseline',()=>{ + const f=fixture(),base=f.root().document;f.emit({...f.root(),runtime:{...f.root().runtime,loading:{version:6},loadingRead:{document:base}}});const sources=createElementSources(f.ports),stop=sources.forElement('missing').subscribe(()=>{});f.lookups.length=0;f.emit({...f.root(),runtime:{...f.root().runtime,acknowledged:{...base}}});assert.deepEqual(f.lookups,[]);f.emit({...f.root(),runtime:{...f.root().runtime,loadingRead:{document:{...base}}}});assert.deepEqual(f.lookups,['missing']);stop(); +}); diff --git a/tests/headless/package.integration.mjs b/tests/headless/package.integration.mjs index bbad4b3..d27c2e6 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} 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:()=>()=>{}}}; - function HookView(){const id=useCanvas(value=>value.document.projectId),status=useSyncStatus();return React.createElement('span',null,id+':'+status.status);} - assert.equal(renderToStaticMarkup(React.createElement(CanvasProvider,{engine:hookEngine},React.createElement(HookView))),'react-consumer:synced'); + 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'); 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); @@ -94,7 +94,7 @@ try { import {createReactRenderers,type ReactFlowProjectionNode,type ReactFlowProjectionEdge} from '@endoleap/headless-canvas/react-flow'; import {CanvasProvider,useCanvas,useElement,useActions,useMode,useGestureBindings,useOperation,useAsset,useSyncStatus} from '@endoleap/headless-canvas/react'; import type {ProjectEngine} from '@endoleap/headless-canvas/runtime'; - function Hooks(){const ref={id:'a',incarnation:1};const title=useCanvas(root=>root.document.projectId);useElement(ref);const actions=useActions(ref);useMode(ref);useGestureBindings(ref);useOperation('operation');useAsset('resource');useSyncStatus('canvas');return createElement('button',{onClick:()=>actions.rename(title,{label:'Rename'})},title);} + function Hooks(){const ref={id:'a',incarnation:1};const title=useCanvas(root=>root.document.projectId);useElement(ref);const selectedId:string|undefined=useElement(ref,view=>view.status==='present'?view.element.id:undefined);useElement(ref,view=>({status:view.status}),(a,b)=>a.status===b.status);void selectedId;const actions=useActions(ref);useMode(ref);useGestureBindings(ref);useOperation('operation');useAsset('resource');useSyncStatus('canvas');return createElement('button',{onClick:()=>actions.rename(title,{label:'Rename'})},title);} export function BoundHooks({engine}:{engine:ProjectEngine}){return createElement(CanvasProvider,{engine,viewId:'view'},createElement(Hooks));} function Card({data}:NodeProps){return createElement('div',null,data.title);} function Link({data}:EdgeProps){return createElement('span',null,data?.__headlessRecord?.id);} diff --git a/tests/headless/react-hooks-fixture.tsx b/tests/headless/react-hooks-fixture.tsx index f48fbd1..0453e07 100644 --- a/tests/headless/react-hooks-fixture.tsx +++ b/tests/headless/react-hooks-fixture.tsx @@ -1,7 +1,7 @@ "use client"; import {StrictMode,memo,useEffect} from 'react'; import {createRoot} from 'react-dom/client';import {flushSync} from 'react-dom'; -import {CanvasProvider,useCanvas,useElement,useActions,useMode,useGestureBindings,useOperation,useAsset,useSyncStatus} from '@/lib/canvas-bindings/react'; +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'; import {openProjectEngine} from '@/lib/canvas-runtime/engine'; import {equal} from '@/lib/canvas-core/values'; @@ -17,9 +17,11 @@ async function fixture(partial=false){ return{engine,node,files,errors,full:server,get reads(){return reads;},fail(value:boolean){fail=value;},gate(value?:Promise){readGate=value;},detach(){detachA();detachB();}}; } async function run(){ - const f=await fixture(),engine=f.engine,container=document.createElement('div');container.style.display='none';document.body.append(container);const root=createRoot(container),reports:string[]=[],counts={title:0,element:0,mode:0,gesture:0};let title:any,element:any,actions:any,mode:any,gesture:any,operation:any,status:any,assetA:any,assetB:any; + const f=await fixture(),engine=f.engine,container=document.createElement('div');container.style.display='none';document.body.append(container);const root=createRoot(container),reports:string[]=[],counts={title:0,element:0,mode:0,gesture:0,titleOnly:0};let titleOnly:unknown,titleSelections=0;let title:any,element:any,actions:any,mode:any,gesture:any,operation:any,status:any,assetA:any,assetB:any; const Title=memo(function Title(){title=useCanvas(snapshot=>({title:snapshot.document.elements.get('a')?.data.title}),equal);counts.title++;return null;}); const Element=memo(function Element(){element=useElement({id:'a',incarnation:1});counts.element++;return null;}); + const selectTitle=(view:ElementView)=>{titleSelections++;return view.status==='present'?view.element.data.title:view.status;}; + 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;}); @@ -28,9 +30,10 @@ async function run(){ 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;}); const render=async(children:any,owner=engine)=>{flushSync(()=>root.render({children}));await tick();}; try{ - await render(<><Element/><Actions/><Mode/><Gesture/><Operation id="missing"/><Status/></>);await until(()=>status?.status==='synced');const baseline={...counts},originalActions=actions; - engine.editor.dispatch({type:'element.data',target:{id:'b',incarnation:1},patch:{title:'unrelated'}});await engine.settle();await tick();assert(equal(baseline,counts),'unrelated element/ACK rerendered narrow hooks');assert(actions===originalActions,'action facade identity drifted'); - 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');assert(element.status==='present'&&element.element.data.title==='renamed','element did not observe canonical rename');await render(<><Title/><Element/><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'); + await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id="missing"/><Status/></>);await until(()=>status?.status==='synced');const baseline={...counts},originalActions=actions,initialTitleSelections=titleSelections; + engine.editor.dispatch({type:'element.data',target:{id:'b',incarnation:1},patch:{title:'unrelated'}});await engine.settle();await tick();assert(equal(baseline,counts),'unrelated element/ACK rerendered narrow hooks');assert(actions===originalActions,'action facade identity drifted');assert(titleSelections===initialTitleSelections,'unrelated edit/ACK evaluated the element selector'); + const titleRenders=counts.titleOnly;engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'other field'}});await engine.settle();await tick();assert(titleSelections>initialTitleSelections&&counts.titleOnly===titleRenders&&titleOnly==='a','field selector did not isolate title from another field on the same target');reports.push('element ID routing skips unrelated selector evaluation; field selectors observe their target without rerendering for other fields'); + 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'); 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');