Expose React canvas hooks with scoped selectors and resource lifecycles
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -11,4 +11,4 @@ import { IconNode } from './icon-node';
|
||||
import { CanvasEdge } from './canvas-edge';
|
||||
function HeadlessFrameNode(props:NodeProps<CanvasNode>){return <FrameNode {...props} directAnchors />;}
|
||||
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;}));
|
||||
|
||||
@@ -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 ? <ReactFlowProvider><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||
}
|
||||
|
||||
@@ -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 <div style={{width:'100%',height:'100%',padding:14,border:'1px solid #ded7ce',borderRadius:8,background:'#fffdfa'}} aria-label="正在准备元素"><span>{typeof data.title==='string'?data.title:'正在准备元素…'}</span>{(data.__previewHandles as {id?:string;type:'source'|'target'}[]??[]).map((handle,index)=><Handle key={index} type={handle.type} id={handle.id} position={handle.type==='source'?Position.Right:Position.Left} isConnectable={false}/>)}</div>;}
|
||||
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<string,{id?:string;type:'source'|'target'}[]>();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<NodeTypes>(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...renderers.nodeTypes,unsupported:UnsupportedNode}))result[id]=props=><div inert style={{width:'100%',height:'100%'}}><Renderer {...props}/></div>;return result;},[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
||||
const nodeTypes=useMemo<NodeTypes>(()=>{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=><div inert style={{width:'100%',height:'100%'}}><Renderer {...props}/></div>;return result;},[renderers]),edgeTypes=useMemo(()=>({...Object.fromEntries(Object.keys(renderers.edgeTypes).map(id=>[id,renderers.previewEdgeTypes[id]??UnsupportedEdge])),unsupported:UnsupportedEdge}),[renderers]);
|
||||
return <div className={styles.editor} data-canvas-loading-preview aria-label="正在加载的画布,只读预览">
|
||||
<CanvasRendererCommandsContext.Provider value={commands}>
|
||||
<ReactFlow nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} nodesFocusable={false} edgesFocusable={false} panOnDrag panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} onMoveEnd={(_,viewport)=>onViewport(viewport)}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<definitions.length;index++){
|
||||
const definition=definitions[index],descriptor=catalog.definitions[index],render=definition.render,tag=render&&typeof render==='object'?(render as {$$typeof?:symbol}).$$typeof:undefined;
|
||||
const definition=definitions[index],descriptor=catalog.definitions[index],render=definition.render,preview=definition.preview,tag=render&&typeof render==='object'?(render as {$$typeof?:symbol}).$$typeof:undefined;
|
||||
if(typeof render!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(tag as symbol))throw fault('invalid_react_renderer');
|
||||
const previewTag=preview&&typeof preview==='object'?(preview as {$$typeof?:symbol}).$$typeof:undefined;if(preview!==undefined&&typeof preview!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(previewTag as symbol))throw fault('invalid_react_preview');
|
||||
if(registry){const model=registry.components.get(descriptor.componentId);if(!model||model.dataVersion!==descriptor.dataVersion||model.structure!==descriptor.structure)throw fault('renderer_model_mismatch',[descriptor.componentId]);}
|
||||
const table=descriptor.structure==='node'?nodes:edges;
|
||||
if(Object.hasOwn(table,descriptor.rendererId)&&table[descriptor.rendererId]!==render)throw fault('renderer_id_conflict',[descriptor.rendererId]);
|
||||
Object.defineProperty(table,descriptor.rendererId,{value:render,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render}) as ReactRendererDefinition);
|
||||
const table=descriptor.structure==='node'?nodes:edges,previews=descriptor.structure==='node'?previewNodes:previewEdges;
|
||||
if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==render||previews[descriptor.rendererId]!==preview))throw fault('renderer_id_conflict',[descriptor.rendererId]);
|
||||
Object.defineProperty(table,descriptor.rendererId,{value:render,enumerable:true,configurable:true});if(preview)Object.defineProperty(previews,descriptor.rendererId,{value:preview,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render,...(preview?{preview}:{})}) as ReactRendererDefinition);
|
||||
}
|
||||
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges)});
|
||||
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges),previewNodeTypes:Object.freeze(previewNodes),previewEdgeTypes:Object.freeze(previewEdges)});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
import {createContext,createElement,useCallback,useContext,useEffect,useMemo,useRef,useState,useSyncExternalStore,type ReactNode} from 'react';
|
||||
import {createSelectorStore,type ExternalSource} from './selectors.ts';
|
||||
import type {ProjectEngine} from '../canvas-runtime/workspace.ts';
|
||||
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
|
||||
import type {Target,Id,Data,Geometry,ElementRecord,Availability,Result} from '../canvas-core/types.ts';
|
||||
import type {SessionSnapshot} from '../canvas-core/gestures.ts';
|
||||
import type {ElementLookup} from '../canvas-core/queries.ts';
|
||||
import {equal,fault} from '../canvas-core/values.ts';
|
||||
import type {OperationRecord,FrozenRequest} from '../canvas-runtime/ledger.ts';
|
||||
import type {DisplayHandle} from '../canvas-runtime/resources.ts';
|
||||
|
||||
export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id};
|
||||
const CanvasContext=createContext<CanvasBinding|null>(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<S,T>(source:ExternalSource<S>,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<T>(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<ProjectEngine['editor']['dispatch']>[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<Geometry>,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<ViewSnapshot>{const {engine}=useCanvasBinding();return useMemo(()=>{let previous:ViewSnapshot|undefined;return{getSnapshot(){const root=engine.getSnapshot(),sessions=engine.sessions.getSnapshot();if(!previous||previous.root!==root||previous.sessions!==sessions)previous={root,sessions};return previous;},subscribe(notify:()=>void){if(engine.getDurability().closed)return()=>{};const a=engine.editor.subscribe(notify),b=engine.sessions.subscribe(notify);return()=>{a();b();};}};},[engine]);}
|
||||
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<number|null>(null);
|
||||
const state=useSelected(useViewSource(),useCallback(({root,sessions}:ViewSnapshot)=>{const element=root.document.elements.get(id),gesture=sessions.gesture;return{geometry:element?.incarnation===incarnation&&element.structure==='node'?sessions.gesture?.preview.get(id)??element.geometry:undefined,active:!!gesture&&gesture.viewId===viewId&&gesture.targets.some(t=>t.id===id&&t.incarnation===incarnation)};},[id,incarnation,viewId]),equal);
|
||||
const controls=useMemo(()=>{
|
||||
const owned=()=>lease.current!==null&&engine.sessions.getSnapshot().gesture?.token===lease.current&&engine.sessions.getSnapshot().gesture?.viewId===viewId&&engine.sessions.getSnapshot().gesture?.phase==='active';
|
||||
return Object.freeze({begin():Availability{const result=engine.sessions.begin(viewId,[{id,incarnation}]);if(result.enabled)lease.current=engine.sessions.getSnapshot().gesture!.token;return result;},update(patch:Partial<Geometry>):Availability{return owned()?engine.sessions.update(viewId,{id,incarnation},patch):{enabled:false,code:'gesture_not_owned',targets:[id]};},commit():Result{if(!owned())return{status:'rejected',code:'gesture_not_owned',targets:[id]};try{return engine.sessions.commit(viewId);}finally{lease.current=null;}},cancel(){if(owned())engine.sessions.cancel('binding_canceled');lease.current=null;}});
|
||||
},[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<ProjectEngine['getDurability']>;
|
||||
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<T>(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]);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** One immutable source, one cached selection; no second document or operation state. */
|
||||
export type ExternalSource<S>={getSnapshot():S;subscribe(notify:()=>void):()=>void};
|
||||
export function createSelectorStore<S,T>(source:ExternalSource<S>,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();}});}});
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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).durableRootRevision<states.at(-1).rootRevision);f.fail(false);await f.engine.persist();assert.equal(states.at(-1).error,undefined);assert.equal(states.at(-1).rootRevision,states.at(-1).durableRootRevision);assert.ok(f.errors.some(error=>error.message==='observer'));await f.engine.close();});
|
||||
@@ -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))),'<span>react-consumer:synced</span>');
|
||||
const registry=createRegistry(builtinComponents());let dispatched=0,seen;const commands={dispatch(){dispatched++;return{status:'applied',revision:1,operationIds:[]};},transaction(){throw Error('unused');}};
|
||||
function View(){seen=useCanvasRendererCommands();return React.createElement('span',null,'peer renderer');}
|
||||
const renderers=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'fixture',render:View}],registry);
|
||||
@@ -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<ReactFlowProjectionNode>){return createElement('div',null,data.title);}
|
||||
function Link({data}:EdgeProps<ReactFlowProjectionEdge>){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}]);
|
||||
|
||||
@@ -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<void>|undefined;
|
||||
const files=new Map<string,Blob>(),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<void>){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(<StrictMode><CanvasProvider engine={owner} viewId="v">{children}</CanvasProvider></StrictMode>));await tick();};
|
||||
try{
|
||||
await render(<><Title/><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;}
|
||||
@@ -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}/>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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);});
|
||||
Reference in New Issue
Block a user