diff --git a/headless/README.md b/headless/README.md index aee6ff4..33644cb 100644 --- a/headless/README.md +++ b/headless/README.md @@ -563,3 +563,15 @@ still require a traversal, not a per-record ledger write-set update. Asset display effects keep their previous ownership, abort/release and retry rules. Durable-only completion/error/close is still read from the independent durability source, so saving and synchronization remain distinct. + +The host's view-owned scene cache retains one current projection and its document, +canvas, registry/catalog, selection, geometry preview, and connection target/path +inputs. Viewport, tool, modes, task receipts and other views' selections do not +rebuild that scene. Geometries are read from the captured session preview rather +than a newer live session; shared previews still appear in every view. An empty +node gesture and phase-only transitions do not require geometry projection. +Changing a document, selection, preview, canvas or renderer recomputes the scene; +this is not yet an element write-set driven traversal. Numeric geometry caches +remain derived Maps, with no history or durable task ownership. Engine/view +replacement discards this cache; `clear()` also drops the retained scene and +numeric caches. Query provenance is validated even on a cache hit. diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 2c798e4..8dfc0cd 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -9,7 +9,7 @@ import { CanvasProvider, CanvasRendererCommandsContext, type CanvasRendererComma 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"; -import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache"; +import {createSceneProjection} from "@/lib/canvas-bindings/scene-projection"; import {createConnectionPaintIndex} from "@/lib/canvas-bindings/connection-paint"; import {nativeRectVisible} from "@/lib/canvas-bindings/visibility"; import {createOverlayIndex} from "@/lib/canvas-bindings/overlays"; @@ -23,7 +23,7 @@ import {createLoadingRendererCatalog} from '@/lib/canvas-bindings/renderers'; import { createProjectionCache } from "@/lib/canvas-bindings/projection-cache"; import { createResourceView, type ResourceViewSource } from "@/lib/canvas-bindings/resource-view"; import { ancestors, commonCanvas } from "@/lib/canvas-core/document"; -import { projectCanvas, toLocal, transformPath, type ProjectionNode } from "@/lib/canvas-bindings/projection"; +import { toLocal, transformPath, type ProjectionNode } from "@/lib/canvas-bindings/projection"; import type { ProjectEngine } from "@/lib/canvas-runtime/workspace"; import { errorText } from "@/lib/api"; import { IconButton } from "@/components/ui/icon-button"; @@ -65,20 +65,21 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map } | null>(null); const projectionCache = useRef | null>(null); projectionCache.current ??= createProjectionCache(); - const geometryCache=useRef|null>(null);geometryCache.current??=createGeometryProjectionCache(); + const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]); const measurements = useRef(new Map()); const imageInput = useRef(null), groups = useRef(new Map()); useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]); const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]); + const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document)); const projection = useMemo(() => { - const result = projectCanvas(root.document, view.canvasId, engine.editor.registry, view.selection, engine.sessions.effectiveGeometry, sessions.connectionGesture,catalog,{queries:engine.editor.getQueries(root.document),geometry:geometryCache.current!,owner:engine}); + const result = scene; // Preserve renderer measurements across metadata/preview updates. Dropping them // makes React Flow discard handle bounds and unmount active edge controls. const visibleIds = new Set(result.nodes.map(node => node.id)); for (const id of measurements.current.keys()) if (!visibleIds.has(id)) measurements.current.delete(id); for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; } return projectionCache.current!.reuse(engine,root.document,view.canvasId,result); - }, [engine, root.document, sessions, view,renderers,catalog]); + }, [engine, root.document, scene,view.canvasId]); const surface=useRef(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]); const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]); const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot); diff --git a/src/lib/canvas-bindings/scene-projection.ts b/src/lib/canvas-bindings/scene-projection.ts new file mode 100644 index 0000000..b31e345 --- /dev/null +++ b/src/lib/canvas-bindings/scene-projection.ts @@ -0,0 +1,27 @@ +import {fault} from '../canvas-core/values.ts'; +import type {Document,Registry} from '../canvas-core/types.ts'; +import type {SessionSnapshot,ViewState} from '../canvas-core/gestures.ts'; +import type {DocumentQueries} from '../canvas-core/queries.ts'; +import {createGeometryProjectionCache} from './geometry-cache.ts'; +import {projectCanvas} from './projection.ts'; +import type {RendererCatalog} from './renderers.ts'; + +/** One view's derived scene. Viewport/tool/mode and task status do not change scene inputs. */ +export function createSceneProjection(){ + let geometry=createGeometryProjectionCache(); + type Inputs={owner:object;document:Document;registry:Registry;catalog:RendererCatalog;canvasId:ViewState['canvasId'];selection:ViewState['selection'];preview:NonNullable['preview']|undefined;edgeId:string|undefined;path:NonNullable['path']|undefined}; + let previous:Inputs|undefined,value:ReturnType|undefined; + return Object.freeze({ + project(owner:object,document:Document,view:ViewState,sessions:SessionSnapshot,registry:Registry,catalog:RendererCatalog,queries?:DocumentQueries){ + if(queries&&queries.document!==document)throw fault('stale_query_snapshot'); + if(queries&&queries.registry!==registry)throw fault('query_registry_changed'); + const preview=sessions.gesture?.preview,connection=sessions.connectionGesture; + const next:Inputs={owner,document,registry,catalog,canvasId:view.canvasId,selection:view.selection,preview:preview?.size?preview:undefined,edgeId:connection?.target.id,path:connection?.path}; + if(previous&&Object.keys(next).every(key=>Object.is(next[key as keyof Inputs],previous![key as keyof Inputs])))return value!; + // Read the captured session, not a newer live gesture during an older React render. + const result=projectCanvas(document,view.canvasId,registry,view.selection,id=>next.preview?.get(id),connection,catalog,{queries,geometry,owner}); + previous=next;value=result;return result; + }, + clear(){previous=undefined;value=undefined;geometry=createGeometryProjectionCache();}, + }); +} diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index a80a74b..94ba7af 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -108,6 +108,8 @@ try { 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')); + // Count actual scene rebuilds in the isolated fixture copy; production has no diagnostic global. + const sceneFile=join(directory,'src/lib/canvas-bindings/scene-projection.ts'),sceneSource=await readFile(sceneFile,'utf8');assert.ok(sceneSource.includes('const result=projectCanvas('));await writeFile(sceneFile,sceneSource.replace('const result=projectCanvas(',`const host=globalThis as typeof globalThis&{__fixtureSceneBuilds?:number};host.__fixtureSceneBuilds=(host.__fixtureSceneBuilds??0)+1;const result=projectCanvas(`)); // 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;__fixtureActualEngine?:ProjectEngine};const collect=()=>engine.collectImportSources({retentionMs:0});host.__headlessCollectSources=collect;host.__fixtureActualEngine=engine;return()=>{if(host.__headlessCollectSources===collect)delete host.__headlessCollectSources;};},[engine]); @@ -377,6 +379,15 @@ try { const extensionRoot=id(810),meter={...node('meter-one',extensionRoot,350),componentId:'fixture:meter',data:{metric:3,raw:{retained:true}}},meterTwo={...meter,id:'meter-two',geometry:{...meter.geometry,x:750},data:{metric:9}},meterThree={...meter,id:'meter-three',geometry:{...meter.geometry,x:1150},data:{metric:12}},meterEdge={id:'meter-edge',incarnation:1,structure:'connection',componentId:'fixture:metric-edge',dataVersion:1,ownerCanvasId:extensionRoot,source:{elementId:meter.id,portId:'custom-out'},target:{elementId:meterTwo.id,portId:'custom-in'},data:{metric:55},path:{type:'metric-curve',retained:true}}; documents.set(extensionRoot,createDocument(extensionRoot,[{id:extensionRoot,parent:null}],[meter,meterTwo,meterThree,meterEdge,{...meterEdge,id:'unrelated-meter-edge',source:{elementId:'meter-two',portId:'custom-out'},target:{elementId:'meter-three',portId:'custom-in'}},{...meterEdge,id:'meter-builtin-edge',componentId:'connection',data:{},path:null}]));versions.set(extensionRoot,1);boards.push({id:extensionRoot,workspace_id:workspace,kind:'board',version:1,title:'Extension board'});await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${extensionRoot}`});await until(`document.querySelectorAll('[data-renderer-fixture="meter"]').length===3&&document.querySelectorAll('[data-renderer-fixture="metric-edge"]').length===2`,'host-installed custom node/edge renderers did not mount');await until(`document.querySelectorAll('.react-flow__edge').length===3`,'built-in edge did not connect custom renderer ports');assert.equal(await evaluate(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length`),0);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension baseline did not settle');await pause(200);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersBefore=await evaluate('({...window.__fixtureRenders})');await click('Increase meter-one');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data,{metric:4,raw:{retained:true,edited:true}});assert.deepEqual(documents.get(extensionRoot).elements.get('meter-edge'),meterEdge);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension edit/ACK did not settle');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersAfter=await evaluate('({...window.__fixtureRenders})');assert.ok(rendersAfter['meter-one']>rendersBefore['meter-one'],'edited custom node did not render');assert.equal(rendersAfter['meter-two'],rendersBefore['meter-two'],'unrelated custom node rendered during edit/ACK');assert.equal(rendersAfter['unrelated-meter-edge'],rendersBefore['unrelated-meter-edge'],'unrelated custom edge rendered during edit/ACK');console.log('PASS actual registered renderer updates the edited node while unrelated node/edge render counts remain unchanged through ACK'); + await until(`window.__fixtureActualEngine?.projectId===${JSON.stringify(extensionRoot)}||window.__fixtureActualEngine?.getSnapshot().document.projectId===${JSON.stringify(extensionRoot)}`,'extension engine unavailable'); + const beforeScene=await evaluate('window.__fixtureSceneBuilds');assert.ok(beforeScene>0); + await evaluate(`(()=>{const e=window.__fixtureActualEngine,v=[...e.sessions.getSnapshot().views.values()][0];window.__fixtureViewBefore={id:v.id,viewport:v.viewport,tool:v.tool};e.sessions.viewport(v.id,{x:80,y:45,zoom:1.2});e.sessions.tool(v.id,'pan');e.sessions.mode(v.id,'meter-one','projection-check');})()`); + await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');await pause(100); + assert.equal(await evaluate('window.__fixtureSceneBuilds'),beforeScene,'viewport/tool/mode rebuilt the entire scene'); + await evaluate(`(()=>{const e=window.__fixtureActualEngine,v=window.__fixtureViewBefore;e.sessions.mode(v.id,'meter-one',null);e.sessions.tool(v.id,v.tool);e.sessions.viewport(v.id,v.viewport);delete window.__fixtureViewBefore;})()`); + await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');assert.equal(await evaluate('window.__fixtureSceneBuilds'),beforeScene); + console.log('PASS actual CanvasView viewport/tool/mode transitions reuse the scene without projection traversal'); + await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover'); console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload'); assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,before=engine.getSnapshot();window.__fixtureUnbound['meter-one'].rename('meter-one','unbound callback');return engine.getSnapshot()===before;})()`),true,'headless legacy editing without a rendered target was allowed'); diff --git a/tests/headless/scene-projection.test.mjs b/tests/headless/scene-projection.test.mjs new file mode 100644 index 0000000..dc549d7 --- /dev/null +++ b/tests/headless/scene-projection.test.mjs @@ -0,0 +1,13 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts'; +import {builtinRendererCatalog} from '../../src/lib/canvas-bindings/renderers.ts'; +import {createSceneProjection} from '../../src/lib/canvas-bindings/scene-projection.ts'; +const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}); +function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],[node('F','root','frame'),node('A','child'),node('B'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor),scene=createSceneProjection(),owner={};sessions.attach('v');sessions.attach('w');let reads=0;const catalog={...builtinRendererCatalog,resolve(record){reads++;return builtinRendererCatalog.resolve(record);}};return{editor,sessions,scene,owner,registry,catalog,get reads(){return reads;},project(snapshot=sessions.getSnapshot(),document=editor.getSnapshot().document,scope=owner,renderers=catalog){return scene.project(scope,document,snapshot.views.get('v'),snapshot,registry,renderers,editor.getQueries(document));}};} +test('viewport, tool, modes and other-view selection do no scene traversal; this-view selection does',()=>{const f=fixture(),first=f.project(),reads=f.reads;assert.ok(reads>0);f.sessions.viewport('v',{x:40,y:20,zoom:2});f.sessions.tool('v','pan');f.sessions.mode('v','B','editing');f.sessions.select('w',['A']);assert.strictEqual(f.project(),first);assert.equal(f.reads,reads);f.sessions.select('v',['B']);const selected=f.project();assert.equal(selected.nodes.find(n=>n.id==='B').selected,true);assert.notStrictEqual(selected,first);assert.ok(f.reads>reads);}); +test('captured geometry previews remain coherent even after live update, and are shared across views',()=>{const f=fixture(),first=f.project();f.sessions.begin('w',[{id:'F',incarnation:1}]);assert.strictEqual(f.project(),first);f.sessions.update('w',{id:'F',incarnation:1},{x:60});const captured=f.sessions.getSnapshot();f.sessions.update('w',{id:'F',incarnation:1},{x:120});const old=f.project(captured),live=f.project();assert.equal(old.nodes.find(n=>n.id==='F').position.x,60);assert.equal(live.nodes.find(n=>n.id==='F').position.x,120);assert.equal(live.nodes.find(n=>n.id==='A').position.x-old.nodes.find(n=>n.id==='A').position.x,60);f.sessions.cancel();assert.equal(f.project().nodes.find(n=>n.id==='F').position.x,0);}); +test('connection path preview and cancel invalidate, ownership-only phase does not',()=>{const f=fixture();f.sessions.beginConnection('w',{id:'edge',incarnation:1});const initial=f.project(),snapshot=f.sessions.getSnapshot();assert.strictEqual(f.project({...snapshot,connectionGesture:{...snapshot.connectionGesture,phase:'committing'}}),initial);const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:40},in:{x:-10,y:0},out:{x:10,y:0}}]};assert.equal(f.sessions.updateConnection('w',path).enabled,true);const preview=f.project();assert.deepEqual(preview.edges[0].data.path,path);f.sessions.cancel();assert.notStrictEqual(f.project(),preview);assert.equal(f.project().edges[0].data.path,undefined);}); +test('document and renderer/scope replacement and clearing cannot retain stale scene data',()=>{const f=fixture(),first=f.project();assert.equal(f.editor.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'edited'}}).status,'applied');const next=f.project();assert.equal(next.document,f.editor.getSnapshot().document);assert.equal(next.nodes.find(n=>n.id==='B').data.text,'edited');assert.notStrictEqual(next,first);assert.notStrictEqual(f.project(undefined,undefined,{}),next);const previous=f.project(),custom={...f.catalog,resolve(element){const value=f.catalog.resolve(element);return value&&{...value,rendererId:'replacement'};}};assert.equal(f.project(undefined,undefined,undefined,custom).nodes[0].type,'replacement');f.scene.clear();assert.notStrictEqual(f.project(),previous);}); + +test('a cached scene does not hide an invalid query provenance',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),document=f.editor.getSnapshot().document;f.project();const project=queries=>f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,f.catalog,queries);assert.throws(()=>project({document:{...document},registry:f.registry}),/stale_query_snapshot/);assert.throws(()=>project({document,registry:{}}),/query_registry_changed/);assert.equal(f.project().document,document);});