From 575b5a9256e757d5fcaa4d1573bb8ec26bbf644d Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 17:34:56 +0800 Subject: [PATCH] Expose React canvas hooks with scoped selectors and resource lifecycles --- headless/README.md | 48 +++++++++++ headless/package.json | 4 + src/components/canvas/canvas-renderers.tsx | 2 +- .../canvas/headless-graph-canvas.tsx | 4 +- .../canvas/loading-graph-canvas.tsx | 8 +- src/lib/canvas-bindings/index.ts | 3 + src/lib/canvas-bindings/react-flow.ts | 1 + src/lib/canvas-bindings/react-renderers.ts | 17 ++-- src/lib/canvas-bindings/react.ts | 81 +++++++++++++++++++ src/lib/canvas-bindings/selectors.ts | 11 +++ src/lib/canvas-runtime/engine.ts | 18 +++-- tests/headless/durability.test.mjs | 6 ++ tests/headless/package.integration.mjs | 8 ++ tests/headless/react-hooks-fixture.tsx | 53 ++++++++++++ tests/headless/renderer-extension.tsx | 4 +- tests/headless/renderer.integration.mjs | 7 +- tests/headless/renderers.test.mjs | 3 + tests/headless/selectors.test.mjs | 6 ++ 18 files changed, 259 insertions(+), 25 deletions(-) create mode 100644 src/lib/canvas-bindings/react.ts create mode 100644 src/lib/canvas-bindings/selectors.ts create mode 100644 tests/headless/durability.test.mjs create mode 100644 tests/headless/react-hooks-fixture.tsx create mode 100644 tests/headless/selectors.test.mjs diff --git a/headless/README.md b/headless/README.md index 1203019..3901379 100644 --- a/headless/README.md +++ b/headless/README.md @@ -13,6 +13,7 @@ Public exports: | `/service` | Foundry protocol codecs and transport adapters; credentials remain in the host | | `/browser` | Explicit IndexedDB/Web Locks and module Worker adapters | | `/bindings` | Portable projection, renderer descriptors, caches, connectors and hit queries | +| `/react` | React Provider and selector, element, action, mode, gesture, operation, asset and sync hooks; no React Flow runtime dependency | | `/react-flow` | Explicit client adapter, renderer command context and framework node/edge types | The portable entries have no React, Next, React Flow or other runtime package @@ -248,3 +249,50 @@ its last live identity is retired. An older Root cannot release temporary bytes that belong to a newer adoption revision. Collection is explicit and local; this preview does not enable background deletion on Foundry or claim the full retention, permission-race and product acceptance matrix is finished. + + +React bindings: + +Wrap descendants in `CanvasProvider` with an existing `engine` and, for session +hooks, an already attached `viewId`. The Provider borrows both; unmounting it does +not close the project or detach a view owned by the host. Explicitly unmount the +surface, detach its view, and close the engine at their respective lifetimes. +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. +- `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. +- `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. +- `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. + Ready means the display handle resolved; it does not prove upload ACK, image + decoding or a successful subsequent fetch of a remote fallback URL. +- `useSyncStatus(canvasId?)` reports pending/blocked/unknown work plus loading, + dirty and persistence errors. Durability is project-wide even when filtering + operations to one canvas. It does not call an undurable Root synced. Pending + counts logical ledger work; running generation/import job progress is observed + separately from document synchronization. + +`engine.getDurability()` now returns a stable readonly snapshot including closing, +closed and an optional error. `subscribeDurability` observes successful cuts, +failures and closure without creating another business Root. Hooks and the Provider +own no second document, ledger, network queue or storage lifecycle. Source selectors +still run on Root notifications; this is not a claim of complete per-field indexing. + +A React renderer may use these hooks once its real engine exists. For the early +read-only preview before an engine opens, declare a separate `preview` renderer in +its `ReactRendererDefinition`. The assembly exposes `previewNodeTypes` and +`previewEdgeTypes`. An omitted preview gets a passive preparing placeholder; the +app never constructs a fake editor merely to satisfy the hooks. Preview functions +must render the supplied records without requiring CanvasProvider. Built-in preview +renderers are registered explicitly. Framework definitions never enter core data. diff --git a/headless/package.json b/headless/package.json index b5dd996..08af9df 100644 --- a/headless/package.json +++ b/headless/package.json @@ -34,6 +34,10 @@ "./react-flow": { "types": "./modules/canvas-bindings/react-flow.d.ts", "import": "./modules/canvas-bindings/react-flow.js" + }, + "./react": { + "types": "./modules/canvas-bindings/react.d.ts", + "import": "./modules/canvas-bindings/react.js" } }, "files": [ diff --git a/src/components/canvas/canvas-renderers.tsx b/src/components/canvas/canvas-renderers.tsx index ce857c5..fbb5bdb 100644 --- a/src/components/canvas/canvas-renderers.tsx +++ b/src/components/canvas/canvas-renderers.tsx @@ -11,4 +11,4 @@ import { IconNode } from './icon-node'; import { CanvasEdge } from './canvas-edge'; function HeadlessFrameNode(props:NodeProps){return ;} const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImageNode,frame:HeadlessFrameNode,icon:IconNode}; -export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>({...definition,render:definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge}) as ReactRendererDefinition)); +export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>{const render=definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge;return{...definition,render,preview:render} as ReactRendererDefinition;})); diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index a511bd1..53beaf0 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -5,7 +5,7 @@ import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProv import { Copy, Download, Frame, Hand, ImagePlus, Images, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react"; import type { Board } from "@/lib/workspace"; import type { ReactRenderers } from "@/lib/canvas-bindings/react-flow"; -import { CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/react-flow"; +import { CanvasProvider, CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/react-flow"; import { defaultCanvasRenderers } from "./canvas-renderers"; import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types"; import { equal } from "@/lib/canvas-core/values"; @@ -275,5 +275,5 @@ export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCa const [handoffViewport]=useState(initialViewport); const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false); useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]); - return attached ? :

正在准备画布…

; + return attached ? :

正在准备画布…

; } diff --git a/src/components/canvas/loading-graph-canvas.tsx b/src/components/canvas/loading-graph-canvas.tsx index e23a1b9..24d7295 100644 --- a/src/components/canvas/loading-graph-canvas.tsx +++ b/src/components/canvas/loading-graph-canvas.tsx @@ -1,7 +1,7 @@ "use client"; import {useEffect,useMemo,useRef} from 'react'; -import {Background,BackgroundVariant,ReactFlow,useReactFlow,type Viewport,type NodeTypes} from '@xyflow/react'; +import {Background,BackgroundVariant,ReactFlow,useReactFlow,type Viewport,type NodeTypes, type NodeProps,Handle,Position} from '@xyflow/react'; import type {Registry} from '@/lib/canvas-core/types'; import type {ModelPreview} from '@/lib/canvas-runtime/model-preview'; import type {ReactRenderers} from '@/lib/canvas-bindings/react-flow'; @@ -11,6 +11,7 @@ import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/c import {UnsupportedNode,UnsupportedEdge} from './unsupported-elements'; import styles from './canvas.module.css'; +function PreparingNode({data}:NodeProps){return
{typeof data.title==='string'?data.title:'正在准备元素…'}{(data.__previewHandles as {id?:string;type:'source'|'target'}[]??[]).map((handle,index)=>)}
;} const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]}); const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject}); function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){ @@ -26,9 +27,10 @@ export default function LoadingGraphCanvas({preview,canvasId,registry,renderers, const catalog=useMemo(()=>createLoadingRendererCatalog(registry,renderers.catalog),[registry,renderers]); const projection=useMemo(()=>{ const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog); - return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&®istry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))}; + const handles=new Map();for(const edge of value.edges)for(const type of ['source','target'] as const){const id=edge[type],rows=handles.get(id)??[];rows.push({type,id:edge[type==='source'?'sourceHandle':'targetHandle']??undefined});handles.set(id,rows);} + return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,__previewHandles:handles.get(node.id)??[],...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&®istry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))}; },[preview.document,canvasId,workspaceId,registry,catalog]); - const nodeTypes=useMemo(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...renderers.nodeTypes,unsupported:UnsupportedNode}))result[id]=props=>
;return result;},[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]); + const nodeTypes=useMemo(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...Object.fromEntries(Object.keys(renderers.nodeTypes).map(id=>[id,renderers.previewNodeTypes[id]??PreparingNode])),unsupported:UnsupportedNode}))result[id]=props=>
;return result;},[renderers]),edgeTypes=useMemo(()=>({...Object.fromEntries(Object.keys(renderers.edgeTypes).map(id=>[id,renderers.previewEdgeTypes[id]??UnsupportedEdge])),unsupported:UnsupportedEdge}),[renderers]); return
onViewport(viewport)}> diff --git a/src/lib/canvas-bindings/index.ts b/src/lib/canvas-bindings/index.ts index bfcc872..9e6611f 100644 --- a/src/lib/canvas-bindings/index.ts +++ b/src/lib/canvas-bindings/index.ts @@ -7,3 +7,6 @@ export * from './renderers.ts'; export * from './connectors.ts'; export * from './resource-view.ts'; export type * from './compat-types.ts'; + +export {createSelectorStore} from './selectors.ts'; +export type {ExternalSource} from './selectors.ts'; diff --git a/src/lib/canvas-bindings/react-flow.ts b/src/lib/canvas-bindings/react-flow.ts index 8e5adb5..23f102e 100644 --- a/src/lib/canvas-bindings/react-flow.ts +++ b/src/lib/canvas-bindings/react-flow.ts @@ -2,6 +2,7 @@ /** Explicit framework boundary; consumers install the declared optional peers. */ export * from './react-renderers.ts'; export * from './renderer-context.ts'; +export * from './react.ts'; import type {Node,Edge} from '@xyflow/react'; import type {ProjectionNode,ProjectionEdge} from './projection.ts'; diff --git a/src/lib/canvas-bindings/react-renderers.ts b/src/lib/canvas-bindings/react-renderers.ts index 271a0c1..8f4744a 100644 --- a/src/lib/canvas-bindings/react-renderers.ts +++ b/src/lib/canvas-bindings/react-renderers.ts @@ -2,18 +2,19 @@ import type { NodeTypes, EdgeTypes } from '@xyflow/react'; import type { ComponentDefinition, Registry } from '../canvas-core/types.ts'; import { fault } from '../canvas-core/values.ts'; import { createRendererCatalog, type RendererDescriptor, type RendererCatalog } from './renderers.ts'; -export type ReactRendererDefinition=RendererDescriptor&({readonly structure:'node';readonly render:NodeTypes[string]}|{readonly structure:'connection';readonly render:EdgeTypes[string]}); -export type ReactRenderers={readonly catalog:RendererCatalog;readonly definitions:readonly ReactRendererDefinition[];readonly nodeTypes:NodeTypes;readonly edgeTypes:EdgeTypes}; +export type ReactRendererDefinition=RendererDescriptor&({readonly structure:'node';readonly render:NodeTypes[string];readonly preview?:NodeTypes[string]}|{readonly structure:'connection';readonly render:EdgeTypes[string];readonly preview?:EdgeTypes[string]}); +export type ReactRenderers={readonly catalog:RendererCatalog;readonly definitions:readonly ReactRendererDefinition[];readonly nodeTypes:NodeTypes;readonly edgeTypes:EdgeTypes;readonly previewNodeTypes:NodeTypes;readonly previewEdgeTypes:EdgeTypes}; export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]}; export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{ - const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[]; + const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),previewNodes:NodeTypes=Object.create(null),previewEdges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[]; for(let index=0;index(null); +/** Borrows the engine and an already attached view. Unmount never closes the project. */ +export function CanvasProvider({engine,viewId,children}:{engine:ProjectEngine;viewId?:Id;children?:ReactNode}){const value=useMemo(()=>Object.freeze({engine,viewId}),[engine,viewId]);return createElement(CanvasContext.Provider,{value},children);} +export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;} +function useSelected(source:ExternalSource,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{ + const committed=useRef<{value:T}|null>(null); + const store=useMemo(()=>createSelectorStore(source,(snapshot:S)=>{const selected=selector(snapshot);return committed.current&&isEqual(committed.current.value,selected)?committed.current.value:selected;},isEqual),[source,selector,isEqual]); + const value=useSyncExternalStore(store.subscribe,store.getSnapshot,store.getSnapshot); + useEffect(()=>{committed.current={value};},[value]);return value; +} +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 {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); +} +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({ + rename:(title:string,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch:{title}},options), + setData:(patch:Data,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch},options), + resize:(patch:Partial,options?:ActionOptions)=>engine.editor.dispatch({type:'element.geometry',target:ref,patch},options), + 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]);} +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 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 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;}}); + },[engine,viewId,id,incarnation]); + useEffect(()=>()=>controls.cancel(),[controls]);return useMemo(()=>Object.freeze({...state,...controls}),[state,controls]); +} +export type OperationView={readonly operation:OperationRecord;readonly request?:FrozenRequest}|undefined; +export function useOperation(id:Id):OperationView{return useCanvas(useCallback(root=>{const operation=root.runtime.ledger.operations.get(id);return operation?{operation,...(operation.requestId?{request:root.runtime.ledger.requests.get(operation.requestId)}:{})}:undefined;},[id]),equal);} +type Durability=ReturnType; +const syncDurability=(value:Durability)=>({dirty:value.rootRevision>value.durableRootRevision,error:value.error,closing:value.closing,closed:value.closed}); +const inactiveOwner=(value:Durability)=>value.closing||value.closed; +function useDurability(selector:(value:Durability)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){const {engine}=useCanvasBinding();const source=useMemo(()=>({getSnapshot:engine.getDurability,subscribe:engine.subscribeDurability}),[engine]);return useSelected(source,selector,isEqual);} +export function useSyncStatus(canvasId?:Id){ + const durable=useDurability(syncDurability,equal),counts=useCanvas(useCallback(root=>{ + const operations=[...root.runtime.ledger.operations.values()].filter(row=>row.phase!=='resolved'&&(canvasId===undefined||row.intent.canvasIds.includes(canvasId))),ids=new Set(operations.map(row=>row.intent.id)),requests=[...root.runtime.ledger.requests.values()].filter(row=>row.phase!=='resolved'&&row.operationIds.some(id=>ids.has(id))); + const blocked=operations.filter(row=>row.phase==='blocked'&&row.blockers.some(reason=>reason!=='waiting_digest')).length+requests.filter(row=>row.error&&row.retryAt===undefined).length,unknown=requests.filter(row=>row.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(row.phase)).length; + return {pending:operations.length,blocked,unknown,loading:!!root.runtime.loading}; + },[canvasId]),equal); + const dirty=durable.dirty; + return useMemo(()=>Object.freeze({...counts,dirty,error:durable.error,status:durable.closed?'closed' as const:durable.error||counts.blocked?'blocked' as const:durable.closing?'closing' as const:dirty?'saving' as const:counts.pending?'syncing' as const:counts.loading?'loading' as const:'synced' as const}),[counts,dirty,durable.error,durable.closing,durable.closed]); +} +export function useAsset(id:Id){ + const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner); + const metadata=useCanvas(useCallback(root=>({record:root.runtime.resources.get(id),binding:root.runtime.assetBindings?.get(id)}),[id]),equal); + const key=JSON.stringify([metadata.record?.operationId,metadata.record?.retirement?.purgedAt,metadata.binding?.authority,metadata.binding?.digest]); + const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>(); + useEffect(()=>{if(inactive){setState({engine,id,key,status:'error',error:fault('engine_closing')});return;}const controller=new AbortController();let handle:DisplayHandle|undefined;setState({engine,id,key,status:'loading'});void engine.displayResource(id,{signal:controller.signal}).then(value=>{if(controller.signal.aborted){value.release();return;}handle=value;setState({engine,id,key,status:'ready',url:value.url});},error=>{if(!controller.signal.aborted)setState({engine,id,key,status:'error',error});});return()=>{controller.abort();handle?.release();};},[engine,id,key,attempt,inactive]); + const retry=useCallback(()=>setAttempt(value=>value+1),[]); + return useMemo(()=>Object.freeze({...metadata,...(inactive?{status:'error' as const,url:undefined,error:fault('engine_closing')}:state?.engine===engine&&state.id===id&&state.key===key?{status:state.status,url:state.url,error:state.error}:{status:'loading' as const,url:undefined,error:undefined}),retry}),[metadata,state,engine,id,key,retry,inactive]); +} diff --git a/src/lib/canvas-bindings/selectors.ts b/src/lib/canvas-bindings/selectors.ts new file mode 100644 index 0000000..9d4edd4 --- /dev/null +++ b/src/lib/canvas-bindings/selectors.ts @@ -0,0 +1,11 @@ +/** One immutable source, one cached selection; no second document or operation state. */ +export type ExternalSource={getSnapshot():S;subscribe(notify:()=>void):()=>void}; +export function createSelectorStore(source:ExternalSource,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){ + let initialized=false,snapshot:S,selection:T; + function getSnapshot():T{ + const current=source.getSnapshot();if(initialized&&Object.is(snapshot,current))return selection; + const next=selector(current),same=initialized&&isEqual(selection,next); + snapshot=current;if(!same)selection=next;initialized=true;return selection; + } + return Object.freeze({getSnapshot,subscribe(notify:()=>void){let observed=getSnapshot();return source.subscribe(()=>{let next:T;try{next=getSnapshot();}catch{notify();return;}if(!Object.is(observed,next)){observed=next;notify();}});}}); +} diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index d43e5d1..2456868 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -75,6 +75,9 @@ export async function openProjectEngine(options: EngineOptions) { const digestLifecycle=new AbortController(); const sourceDigest=(bytes:Blob,signal?:AbortSignal)=>{const combined=signal?AbortSignal.any([signal,digestLifecycle.signal]):digestLifecycle.signal;return options.digests?options.digests.digest(bytes,combined):importSourceDigest(bytes,combined);}; const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Error reporting cannot alter state. */ } }; + const durabilityListeners=new Set<()=>void>();let durabilityError:string|undefined,durabilityCache:Readonly<{rootRevision:number;durableRootRevision:number;closing:boolean;closed:boolean;error?:string}>|undefined; + function getDurability(){const rootRevision=editor.getSnapshot().rootRevision;if(!durabilityCache||durabilityCache.rootRevision!==rootRevision||durabilityCache.durableRootRevision!==durableRootRevision||durabilityCache.closing!==closing||durabilityCache.closed!==closed||durabilityCache.error!==durabilityError)durabilityCache=Object.freeze({rootRevision,durableRootRevision,closing,closed,...(durabilityError?{error:durabilityError}:{})});return durabilityCache;} + function notifyDurability(){for(const listener of [...durabilityListeners])try{listener();}catch(error){report(error);}} const createId = options.createId ?? (() => crypto.randomUUID()); const network = createNetworkGate(2); const depot=options.assetDepot??(options.storage.assetDepot?createAssetDepot({scope:options.scope,port:options.storage.assetDepot,current:()=>!closing&&!closed&&(!options.isCurrentIdentity||options.isCurrentIdentity()),digest:sourceDigest}):undefined); @@ -195,15 +198,15 @@ export async function openProjectEngine(options: EngineOptions) { const depot=options.assets?.authority&&options.storage.assetDepot?await prepareDepotCut({snapshot,registry:options.registry,storage:options.storage,leader,authority:options.assets.authority.scope,files:fileWrites,adoptedFiles,displays:displayPins,temporary:admissionPins(snapshot)}):undefined; const receipt = await options.storage.commit(leader, { image: encodeRecovery(snapshot), files,deleteFiles,...(depot?{depot}:{}) }); if (receipt.durableRootRevision !== revision) throw fault('invalid_durable_receipt'); - durableRootRevision = revision; pending.delete(revision); + durableRootRevision = revision; pending.delete(revision);notifyDurability(); for(const id of deleteFiles){durablyPurged.add(id);collectionReservations.delete(id);} for (const file of files) fileWrites.delete(file.id); for(const [id,entry]of adoptedFiles)if(entry.rootRevision<=snapshot.rootRevision&&depot?.writes.some(write=>write.ref.asset.id===id&&write.bytes===entry.bytes))adoptedFiles.delete(id); if (editor.getSnapshot().runtime.ledger.durableGeneration < snapshot.runtime.ledger.generation) mutate(ledger => ledger.markDurable(snapshot.runtime.ledger.generation)); } - persistenceFailed = false; + persistenceFailed = false;durabilityError=undefined;notifyDurability(); })(); - writing = task.catch(error => { persistenceFailed = true; throw error; }).finally(() => { writing = undefined;if(!persistenceFailed){if(loadingCompletionRevision!==undefined&&durableRootRevision>=loadingCompletionRevision&&!editor.getSnapshot().runtime.loading){const release=loadingRelease;loadingRelease=undefined;loadingCompletionRevision=undefined;release?.();}if(!closing&&!closed)scheduler.refreshAdmission();} }); + writing = task.catch(error => { persistenceFailed = true;const failure=error&&typeof error==='object'?error as {code?:unknown;message?:unknown}:{};durabilityError=typeof failure.code==='string'&&failure.code?failure.code:typeof failure.message==='string'&&failure.message?failure.message:'persistence_failed';notifyDurability(); throw error; }).finally(() => { writing = undefined;if(!persistenceFailed){if(loadingCompletionRevision!==undefined&&durableRootRevision>=loadingCompletionRevision&&!editor.getSnapshot().runtime.loading){const release=loadingRelease;loadingRelease=undefined;loadingCompletionRevision=undefined;release?.();}if(!closing&&!closed)scheduler.refreshAdmission();} }); return writing; } function collectResources(settings:{retentionMs:number;maxFiles?:number}) { @@ -518,7 +521,7 @@ export async function openProjectEngine(options: EngineOptions) { const sessions = createSessions(editor); const unsubscribeSizing = sessions.subscribe(() => { if (!sessions.getSnapshot().gesture) queueDefaultSizes(); }); const unsubscribe = editor.subscribe((commit, snapshot) => { - pending.set(snapshot.rootRevision, snapshot); + pending.set(snapshot.rootRevision, snapshot);notifyDurability(); const addedRuns=[...snapshot.runtime.runs.keys()].filter(id=>!trackedRuns.has(id));for(const id of addedRuns)trackedRuns.add(id); if(addedRuns.length)queueMicrotask(()=>runTracker.notify(addedRuns)); if (!scheduled) { scheduled = true; queueMicrotask(() => { scheduled = false; void persist().catch(report); }); } @@ -980,17 +983,18 @@ export async function openProjectEngine(options: EngineOptions) { }, getSnapshot: editor.getSnapshot, persist, cancelRecovery:async()=>{await recovery?.cancel();}, - getDurability: () => Object.freeze({ rootRevision: editor.getSnapshot().rootRevision, durableRootRevision }), + getDurability, + subscribeDurability(listener:()=>void){if(closed)return()=>{};durabilityListeners.add(listener);return()=>{durabilityListeners.delete(listener);};}, retry: async () => { if(recovery)await recovery.cancel();assertWritable(false);await persist();retryDefaultSizes();const runtime=editor.getSnapshot().runtime,ids=[...runtime.resources.values()].filter(resource=>!resource.digest&&runtime.ledger.operations.get(resource.operationId)?.blockers.some(code=>digestFailures.has(code))).map(resource=>resource.operationId);if(ids.length){mutate(ledger=>{for(const id of ids)ledger.block(id,['waiting_digest']);});prepareDigests();}if(options.images){editor.updateRuntime(previous=>{const resources=new Map(previous.resources);let changed=false;for(const [id,resource]of resources)if(resource.image?.version===1&&resource.image.status==='failed'){resources.set(id,freeze({...resource,image:{version:1 as const,status:'pending' as const}}));changed=true;}return changed?Object.freeze({...previous,resources:readMap(resources)}):previous;});prepareImages();}await scheduler.resume();await runTracker.retry(); }, async settle() { await Promise.resolve();await Promise.allSettled([...adoptionTasks.values()]);await Promise.allSettled([...copyPreparations.values()]);while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]);while(imageTasks.size)await Promise.all([...imageTasks.values()]);while(sizingTask)await sizingTask; await scheduler.settle(); await runTracker.settle(); await persist();await scheduler.settle();await persist(); }, async close() { if (closed) return; - closing = true;loadingReadController?.abort();await loadingRead?.catch(()=>{});unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]);for(const controller of adoptionTasks.keys())controller.abort();await Promise.allSettled([...adoptionTasks.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop(); + closing = true;notifyDurability();loadingReadController?.abort();await loadingRead?.catch(()=>{});unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]);for(const controller of adoptionTasks.keys())controller.abort();await Promise.allSettled([...adoptionTasks.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop(); await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]);while(imageTasks.size)await Promise.allSettled([...imageTasks.values()]);while(sizingTask)await sizingTask; recovery?.controller.abort();recovery?.release(); for (const release of [...displays]) release();displayCache.releaseOwner(displayOwner);if(!options.displays)displayCache.close(); // Failure keeps the leader/recovery material available for an explicit retry of close. - await persist(); unsubscribe(); editor.dispose();if(!options.assetDepot)depot?.close();adoptedFiles.clear(); await leader.release(); closed = true; + await persist(); unsubscribe(); editor.dispose();if(!options.assetDepot)depot?.close();adoptedFiles.clear(); await leader.release(); closed = true;notifyDurability();durabilityListeners.clear(); }, }); } catch (error) { await leader.release(); throw error; } diff --git a/tests/headless/durability.test.mjs b/tests/headless/durability.test.mjs new file mode 100644 index 0000000..1063675 --- /dev/null +++ b/tests/headless/durability.test.mjs @@ -0,0 +1,6 @@ +import {test} from 'node:test';import assert from 'node:assert/strict'; +import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts'; +import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts'; +async function fixture(){let broken=false;const errors=[],engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:'p'},registry:createRegistry(builtinComponents()),document:createDocument('p',[{id:'canvas',parent:null}],[]),versions:new Map([['canvas',1]]),storage:{async claim(scope){return{scope,active:true,fence:1,async release(){this.active=false;}};},async recover(){return null;},async commit(_leader,batch){if(broken)throw{code:22,message:'quota'};return{durableRootRevision:batch.image.rootRevision};},async readFile(){return;},close(){}},persistence:{compile(){throw Error('unexpected write');},async submit(){throw Error('unexpected write');},async query(){throw Error('unexpected query');},canonical(){throw Error('unexpected canonical');}},onError:error=>errors.push(error)});await engine.persist();return{engine,errors,fail:value=>broken=value};} +test('durability subscribers observe dirty metadata, final durable cut and closure without another business Root',async()=>{const{engine}=await fixture(),states=[];const stop=engine.subscribeDurability(()=>states.push(engine.getDurability()));assert.strictEqual(engine.getDurability(),engine.getDurability());engine.editor.updateRuntime(previous=>({...previous}));const revision=engine.getSnapshot().rootRevision;assert.ok(states.some(state=>state.rootRevision>state.durableRootRevision));await engine.persist();assert.equal(states.at(-1).durableRootRevision,revision);assert.equal(engine.getSnapshot().rootRevision,revision);await engine.close();assert.ok(states.some(state=>state.closing&&!state.closed));assert.equal(states.at(-1).closed,true);stop();const noop=engine.subscribeDurability(()=>assert.fail('closed owner notified'));noop();}); +test('storage failures remain observable and normalize numeric DOM-style codes without listener exceptions changing durability',async()=>{const f=await fixture();const states=[];f.engine.subscribeDurability(()=>{throw Error('observer');});f.engine.subscribeDurability(()=>states.push(f.engine.getDurability()));f.fail(true);f.engine.editor.updateRuntime(previous=>({...previous}));await assert.rejects(f.engine.persist());assert.equal(states.at(-1).error,'quota');assert.equal(typeof states.at(-1).error,'string');assert.ok(states.at(-1).durableRootRevisionerror.message==='observer'));await f.engine.close();}); diff --git a/tests/headless/package.integration.mjs b/tests/headless/package.integration.mjs index bca2f5b..bbad4b3 100644 --- a/tests/headless/package.integration.mjs +++ b/tests/headless/package.integration.mjs @@ -78,6 +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'); 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); @@ -88,6 +92,10 @@ try { await writeFile(join(packageRoot,'react-consumer-types.mts'),` import {createElement} from 'react';import type {NodeProps,EdgeProps} from '@xyflow/react'; 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);} + 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);} export const adapter=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'card',render:Card},{componentId:'connection',dataVersion:1,structure:'connection',rendererId:'link',render:Link}]); diff --git a/tests/headless/react-hooks-fixture.tsx b/tests/headless/react-hooks-fixture.tsx new file mode 100644 index 0000000..f48fbd1 --- /dev/null +++ b/tests/headless/react-hooks-fixture.tsx @@ -0,0 +1,53 @@ +"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 {createRegistry,builtinComponents,createDocument,applyChanges} from '@/lib/canvas-core/index'; +import {openProjectEngine} from '@/lib/canvas-runtime/engine'; +import {equal} from '@/lib/canvas-core/values'; +const assert=(value:unknown,message:string)=>{if(!value)throw Error(message);}; +const tick=()=>new Promise(resolve=>setTimeout(resolve,5));async function until(check:()=>boolean){for(let i=0;i<1000;i++){if(check())return;await tick();}throw Error('hook observation timed out');} +async function fixture(partial=false){ + const node=(id:string)=>({id,incarnation:1,structure:'node' as const,componentId:'text',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:100,height:80,rotation:0},data:{title:id,text:''}});let server=createDocument(partial?'canvas':'hooks',[{id:'canvas',parent:null}],[node('a'),node('b')]),version=1,serial=0,image:any=null,fail=false,readGate:Promise|undefined; + const files=new Map(),receipts=new Map(),errors:unknown[]=[];let reads=0; + const storage={async claim(scope:any){return{scope,fence:1,active:true,async release(){this.active=false;}};},async recover(){return image;},async commit(_leader:any,batch:any){if(fail)throw Error('quota');for(const file of batch.files??[])files.set(file.id,file.bytes);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(_leader:any,id:string){reads++;if(readGate)await readGate;return files.get(id);},close(){}}; + const assets={sourceURL:(id:string)=>`https://assets.test/${id}`,displayURL:(id:string)=>`/assets/${id}`,async upload(request:any,bytes:Blob){const payload=JSON.parse(request.body);return{outcome:'applied' as const,canonical:{id:payload.resource_id,url:assets.sourceURL(payload.resource_id),bytes:bytes.size,mime_type:bytes.type,width:2,height:1}};},async query(){return{outcome:'unresolved' as const};},accept:(_request:any,status:any)=>status.canonical}; + const persistence={compile(intent:any,id:string,snapshot:any){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request:any){server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['canvas',++version]])});return{outcome:'applied' as const};},async query(){return{outcome:'unresolved' as const};},canonical:(request:any)=>receipts.get(request.id)}; + const engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:partial?'canvas':'hooks'},document:partial?createDocument('canvas',[{id:'canvas',parent:null}],[node('a')]):server,...(partial?{loading:{version:1 as const,sourceToken:'hook-partial',declaredCanvasIds:['canvas'],receivedElements:1,totalElements:2}}:{}),versions:new Map([['canvas',1]]),registry:createRegistry(builtinComponents()),storage,assets,persistence,createId:()=>`hook-${++serial}`,onError:error=>errors.push(error)});const detachA=engine.sessions.attach('v','canvas'),detachB=engine.sessions.attach('w','canvas');await engine.settle(); + 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 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 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 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;}); + 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'); + 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'); + 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'); + let release!:()=>void;f.gate(new Promise<void>(resolve=>release=resolve));const before=f.reads;await render(<><Asset id={resource} slot="a"/><Status/></>);await until(()=>f.reads>before);await render(<Status/>);release();f.gate();await tick();assert(!engine.getResourcePins().pins.get(resource)?.has('display'),'late resource load leaked a display pin'); + await render(<><Element/><Actions/><Gesture/><Status/></>);const staleActions=actions;engine.editor.dispatch({type:'element.remove',target:{id:'a',incarnation:1}});await engine.settle();engine.editor.history.undo();await engine.settle();await until(()=>element.status==='stale');const snapshot=engine.getSnapshot();assert(staleActions.rename('stale').status==='rejected'&&engine.getSnapshot()===snapshot,'old incarnation facade edited a restored element');reports.push('late resource loads release their pins and stale action targets cannot retarget restored incarnations'); + await render(<><Asset id={resource} slot="a"/><Status/></>);await until(()=>assetA.status==='ready');await engine.close();await until(()=>status.status==='closed'&&assetA.status==='error');reports.push('engine close is observable and releases mounted resource handles without owning a second project'); + const other=await fixture();try{ + let otherResource='';other.engine.stageFiles([{name:'other.png',bytes:new Blob(['other identity bytes'],{type:'image/png'})}],([ref],tx)=>{otherResource=ref.id;tx.dispatch({type:'element.create',element:{...other.node('other-image'),componentId:'image',data:{title:'other',text:'',assetId:ref.id,src:ref.src}}});});await other.engine.settle(); + const oldSnapshot=engine.getSnapshot();await render(<><Asset id={otherResource} slot="a"/><Actions/><Status/></>,other.engine);await until(()=>assetA.status==='ready');assert(await(await fetch(assetA.url)).text()==='other identity bytes','Provider change reused another engine resource handle');assert(actions.rename('second owner').status==='applied','new owner action did not dispatch');await other.engine.settle();assert(other.engine.getSnapshot().document.elements.get('a')!.data.title==='second owner'&&engine.getSnapshot()===oldSnapshot,'Provider change routed commands to the old owner');reports.push('Provider owner changes replace resource handles and action targets without writing to the previous engine'); + }finally{await other.engine.close();} + const partial=await fixture(true);try{ + let b:any,missing:any;function Lookup(){b=useElement('b');missing=useElement('not-in-source');return null;} + await render(<><Lookup/><Status/></>,partial.engine);assert(b.status==='unloaded'&&missing.status==='unloaded','partial lookup guessed absence');const completed=await partial.engine.loading.complete(partial.full,new Map([['canvas',1]]),{sourceToken:'hook-partial'});assert(completed.status==='applied','hook partial fixture did not complete');await partial.engine.settle();await until(()=>b.status==='present'&&missing.status==='absent');reports.push('element hooks preserve unloaded versus absent until source completion and then expose the newly loaded element'); + }finally{await partial.engine.close();} + return reports; + }finally{flushSync(()=>root.unmount());container.remove();f.fail(false);f.detach();await engine.close();} +} +export function HookChecks(){useEffect(()=>{const host=globalThis as typeof globalThis&{__runCanvasHookChecks?:()=>ReturnType<typeof run>};host.__runCanvasHookChecks=run;return()=>{if(host.__runCanvasHookChecks===run)delete host.__runCanvasHookChecks;};},[]);return null;} diff --git a/tests/headless/renderer-extension.tsx b/tests/headless/renderer-extension.tsx index 6921bbb..dee48f4 100644 --- a/tests/headless/renderer-extension.tsx +++ b/tests/headless/renderer-extension.tsx @@ -3,9 +3,9 @@ import { Handle, Position, BaseEdge, getBezierPath, type NodeProps, type EdgePro import { defineComponent, editable, movable } from '@/lib/canvas-core/index'; import type {ReactFlowProjectionNode as ProjectionNode,ReactFlowProjectionEdge as ProjectionEdge} from '@/lib/canvas-bindings/react-flow'; import type { CanvasRuntimeExtension } from '@/lib/canvas-bindings/react-flow'; -import { useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow'; +import { useElement, useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow'; function Meter({data}:NodeProps<ProjectionNode>){ - const commands=useCanvasRendererCommands(),record=data.__headlessRecord!; + const commands=useCanvasRendererCommands(),lookup=useElement(data.__headlessRecord!),record=lookup.status==='present'?lookup.element:data.__headlessRecord!; return <div data-renderer-fixture="meter" style={{width:'100%',height:'100%',background:'white',border:'1px solid #ded7ce',padding:16}}> <span>Metric {String(record.data.metric)}</span><button className="nodrag" aria-label={`Increase ${record.id}`} onClick={event=>{event.stopPropagation();commands.transaction(tx=>{tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{metric:Number(record.data.metric)+1}});tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{raw:{retained:true,edited:true}}});},{label:'Increase metric'});}}>Increase</button> <Handle type="source" id="custom-out" position={Position.Right}/><Handle type="source" id="custom-in" position={Position.Left}/> diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index d8cf93f..4107fa5 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -8,7 +8,7 @@ import { tmpdir } from 'node:os'; import { spawn } from 'node:child_process'; import { setTimeout as pause } from 'node:timers/promises'; import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts'; -const performanceMode=process.argv.includes('--performance'),performanceCount=Number(process.env.HEADLESS_PERFORMANCE_COUNT??1000); +const hooksOnly=process.argv.includes('--hooks-only'),performanceMode=process.argv.includes('--performance'),performanceCount=Number(process.env.HEADLESS_PERFORMANCE_COUNT??1000); if(performanceMode&&(!Number.isSafeInteger(performanceCount)||performanceCount<1||performanceCount>10000))throw Error('invalid performance count'); const source = resolve(import.meta.dirname, '../..'), directory = await mkdtemp(join(tmpdir(), 'endoleap-renderer-')), profile = await mkdtemp(join(tmpdir(), 'endoleap-renderer-browser-')); const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`; @@ -107,11 +107,12 @@ try { await cp(join(source, 'public'), join(directory, 'public'), { recursive: true }); if(!performanceMode){ await copyFile(join(source,'tests/headless/renderer-extension.tsx'),join(directory,'src/components/canvas/renderer-fixture.tsx')); + await copyFile(join(source,'tests/headless/react-hooks-fixture.tsx'),join(directory,'src/components/canvas/hooks-fixture.tsx')); // A fixture Host action exercises the public engine API; no debug entry point ships in the app. const importsFile=join(directory,'src/components/canvas/headless-imports.tsx'),importsSource=await readFile(importsFile,'utf8'); await writeFile(importsFile,importsSource.replace('import { useCallback,','import { useEffect, useCallback,').replace(' const subscribe =',` useEffect(()=>{const host=globalThis as typeof globalThis&{__headlessCollectSources?:()=>ReturnType<ProjectEngine['collectImportSources']>};const collect=()=>engine.collectImportSources({retentionMs:0});host.__headlessCollectSources=collect;return()=>{if(host.__headlessCollectSources===collect)delete host.__headlessCollectSources;};},[engine]); const subscribe =`)); - const hostFile=join(directory,'src/components/workspace/canvas-runtime-host.tsx'),host=await readFile(hostFile,'utf8');assert.ok(host.includes('extensions=[]'),'host extension installation point changed');await writeFile(hostFile,host.replace('"use client";','"use client";\nimport {fixtureExtension} from "@/components/canvas/renderer-fixture";').replace('extensions=[]','extensions=[fixtureExtension]')); + const hostFile=join(directory,'src/components/workspace/canvas-runtime-host.tsx'),host=await readFile(hostFile,'utf8');assert.ok(host.includes('extensions=[]'),'host extension installation point changed');await writeFile(hostFile,host.replace('"use client";','"use client";\nimport {fixtureExtension} from "@/components/canvas/renderer-fixture";\nimport {HookChecks} from "@/components/canvas/hooks-fixture";').replace('extensions=[]','extensions=[fixtureExtension]').replace('{children}</Renderers.Provider>','{children}<HookChecks/></Renderers.Provider>')); } for (const file of ['package.json', 'tsconfig.json', 'postcss.config.mjs']) await copyFile(join(source, file), join(directory, file)); @@ -135,6 +136,7 @@ try { await call('Page.navigate', { url: route }); if(performanceMode){ await measureRenderer({call,evaluate,documents,root,partition,count:performanceCount,exceptions,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')}); + }else if(hooksOnly){await until('typeof window.__runCanvasHookChecks==="function"','hook fixture did not mount');const reports=await evaluate('window.__runCanvasHookChecks()');for(const report of reports)console.log('PASS React hooks: '+report); }else{ await until('!!document.querySelector("[data-headless-canvas][data-model-loading] .react-flow__node[data-id=a]")','first editable model prefix did not open before the gated tail'); assert.equal(await evaluate('!!document.querySelector(".react-flow__node[data-id=b]")'),false); @@ -310,6 +312,7 @@ try { assert.equal(uploads.length, 1); assert.equal(calls.some(call => call.path.endsWith('/canvas')), false); console.log('PASS pending image displays before upload; navigation preserves work and fences late view updates; reload retains resource identity'); console.log('PASS canvas floating controls remain visible while the sidebar is expanded'); + const hookReports=await evaluate('window.__runCanvasHookChecks()');for(const report of hookReports)console.log('PASS React hooks: '+report); const beforeImport=documents.get(root).elements.size,importRecord={kind:'element',key:'ui-import',record:{id:'ui-import',incarnation:1,structure:'node',ownerCanvasId:'$target',componentId:'text',dataVersion:1,data:{title:'Imported NDJSON node',text:'Visible before ACK'},geometry:{x:700,y:450,width:240,height:160,rotation:0}}},nativeText=JSON.stringify(importRecord)+'\n{bad}'; await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(nativeText)}],'canvas.ndjson',{type:'application/x-ndjson'}));const input=document.querySelector('[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`); await until(`document.querySelectorAll('.react-flow__node').length===6&&document.body.innerText.includes('部分加入')`,'native import did not show its successful prefix and partial report'); diff --git a/tests/headless/renderers.test.mjs b/tests/headless/renderers.test.mjs index c6f23f1..3873ca4 100644 --- a/tests/headless/renderers.test.mjs +++ b/tests/headless/renderers.test.mjs @@ -21,3 +21,6 @@ test('installed node and edge renderers receive original records and transforms assert.equal(p.nodes[0].type,'meter');assert.equal(p.nodes[0].data.__headlessFallback,undefined);assert.equal(p.nodes[0].data.__headlessRecord,source.elements.get('meter'));assert.deepEqual(p.nodes[0].data.__headlessRecord.data.title,{opaque:true});assert.equal(p.nodes[0].data.__headlessTransform.scale,1);assert.equal(p.edges[0].type,'meter-edge');assert.equal(p.edges[0].data.__headlessRecord,source.elements.get('edge'));assert.deepEqual(p.edges[0].data.__headlessRecord.path,{type:'custom',retained:true}); const missing=projectCanvas(source,'root',r,new Set(),()=>undefined);assert.equal(missing.nodes[0].type,'unsupported');assert.equal(missing.edges[0].type,'unsupported');const future=createDocument('project',[{id:'root',parent:null}],[{...node,dataVersion:2}]);assert.equal(projectCanvas(future,'root',r,new Set(),()=>undefined,null,catalog).nodes[0].type,'unsupported'); }); +test('pre-engine preview renderers are explicit and captured independently from engine-bound renderers',()=>{ + const render=()=>null,preview=()=>null,assembly=createReactRenderers([{...descriptor,render,preview}]);assert.equal(assembly.nodeTypes.meter,render);assert.equal(assembly.previewNodeTypes.meter,preview);assert.equal(assembly.definitions[0].preview,preview);assert.ok(Object.isFrozen(assembly.previewNodeTypes));assert.equal(createReactRenderers([{...descriptor,render}]).previewNodeTypes.meter,undefined);assert.throws(()=>createReactRenderers([{...descriptor,render,preview:0}]),/invalid_react_preview/);assert.throws(()=>createReactRenderers([{...descriptor,render,preview},{...descriptor,componentId:'other',render}]),/renderer_id_conflict/); +}); diff --git a/tests/headless/selectors.test.mjs b/tests/headless/selectors.test.mjs new file mode 100644 index 0000000..6a26330 --- /dev/null +++ b/tests/headless/selectors.test.mjs @@ -0,0 +1,6 @@ +import {test} from 'node:test';import assert from 'node:assert/strict'; +import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts'; +function source(initial){let state=initial;const listeners=new Set();return{getSnapshot:()=>state,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);},set(next){state=next;for(const fn of [...listeners])fn();},get size(){return listeners.size;}};} +test('selector store caches immutable reads and notifies only changed selections',()=>{const s=source({a:1,b:1});let selections=0,notifications=0;const store=createSelectorStore(s,value=>{selections++;return{a:value.a};},(a,b)=>a.a===b.a),first=store.getSnapshot();assert.strictEqual(store.getSnapshot(),first);assert.equal(selections,1);const stop=store.subscribe(()=>notifications++);s.set({a:1,b:2});assert.strictEqual(store.getSnapshot(),first);assert.equal(notifications,0);s.set({a:2,b:2});assert.equal(notifications,1);assert.equal(store.getSnapshot().a,2);stop();assert.equal(s.size,0);}); +test('selector errors notify React to retry the snapshot and retain no invalid cached value',()=>{const s=source({value:1});const store=createSelectorStore(s,next=>{if(next.value<0)throw Error('invalid');return next.value;});let notifications=0;const stop=store.subscribe(()=>notifications++);s.set({value:-1});assert.equal(notifications,1);assert.throws(store.getSnapshot,/invalid/);s.set({value:2});assert.equal(store.getSnapshot(),2);assert.equal(notifications,2);stop();}); +test('distinct subscribers observe changes independently and unsubscribe during notification',()=>{const s=source(0),store=createSelectorStore(s,n=>n);let a=0,b=0,stopA;stopA=store.subscribe(()=>{a++;stopA();});const stopB=store.subscribe(()=>b++);s.set(1);s.set(2);assert.equal(a,1);assert.equal(b,2);stopB();assert.equal(s.size,0);});