From a9bc6e7d29ed84b9a2902fb4e5ded851f407c4ba Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 18:45:57 +0800 Subject: [PATCH] Reuse unchanged projection arrays and limit extra viewport retention --- headless/README.md | 15 +++++++++++++++ src/components/canvas/headless-graph-canvas.tsx | 8 ++++++-- src/lib/canvas-bindings/projection-cache.ts | 11 +++++++---- src/lib/canvas-bindings/visibility.ts | 3 +++ tests/headless/projection-cache.test.mjs | 3 +++ tests/headless/renderer.integration.mjs | 6 +++--- tests/headless/visibility.test.mjs | 2 ++ 7 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 src/lib/canvas-bindings/visibility.ts create mode 100644 tests/headless/visibility.test.mjs diff --git a/headless/README.md b/headless/README.md index e7a36a2..fd67f34 100644 --- a/headless/README.md +++ b/headless/README.md @@ -408,3 +408,18 @@ While a control owns an active capture, foreign pointer events reaching that control are consumed without changing the lease; they do not fall through into pane navigation/connection handlers. Outside an active control, unclaimed input continues normally. Node drag cancellation only addresses its own pointer/token. + +The view projection cache now retains node/edge array identities when all retained +items and their order are unchanged, so metadata-only roots do not hand React Flow +new collections. A changed node replaces its node array while an unchanged edge +array remains stable. Document and transform provenance still come from the fresh +projection; lifecycle/scope changes and `clear()` invalidate the retained arrays. +Projection calculation still scans the scene. This is not selective mounting or +complete dirty-region rendering. + +Additional node/overlay retention now checks whether the target is already kept +by native rectangle visibility (including initial measurement/dragging). A toolbar +on an already visible node no longer disables culling for every other node. Only +an otherwise offscreen target that needs extra paint/overlay retention uses the +view fallback. Focus/IME and gesture safeguards remain. This fixes a measured +regression where selecting a frame mounted all 2003 nodes after a large copy. diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index c019048..240f3b3 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -12,6 +12,7 @@ import { equal } from "@/lib/canvas-core/values"; import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache"; import {createPaintIndex} from "@/lib/canvas-bindings/paint-index"; import {projectedConnectionPaintOverflow} from "@/lib/canvas-bindings/connection-paint"; +import {nativeRectVisible} from "@/lib/canvas-bindings/visibility"; import {createOverlayIndex} from "@/lib/canvas-bindings/overlays"; import {CanvasOverlays} from "./canvas-overlays"; import {createProjectedPaintIndex} from "@/lib/canvas-bindings/projected-paint"; @@ -82,11 +83,14 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]); const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot); const surfaceBounds=surface.current?.getBoundingClientRect(); - const overlayVisible=!!overlaySnapshot.length&&(!surfaceBounds||overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation).length>0); + const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]); const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]); const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height); - const paintVisible=paint.unknown.length>0||!surfaceWidth||!surfaceHeight||paint.query({x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}).size>0; + const sceneViewport={x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}; + const nativeVisible=(id:Id)=>{const node=flow.getInternalNode(id);if(!node||!node.internals.handleBounds||(node as typeof node&{dragging?:boolean}).dragging)return true;return nativeRectVisible({x:node.internals.positionAbsolute.x,y:node.internals.positionAbsolute.y,width:node.measured.width??node.width??0,height:node.measured.height??node.height??0},sceneViewport);}; + const overlayVisible=!!overlaySnapshot.length&&(!surfaceBounds||overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation).some(entry=>!nativeVisible(entry.target.id))); + const paintVisible=!surfaceWidth||!surfaceHeight||paint.unknown.some(id=>!nativeVisible(id))||[...paint.query(sceneViewport)].some(id=>!nativeVisible(id)); const nativeNodes=useStore(state=>state.nodes); const connectionPaint=useMemo(()=>{const result=projectedConnectionPaintOverflow(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),index=createPaintIndex();index.sync(result.regions);return{index,unknown:result.unknown};},[projection,engine,flow,nativeNodes]); const connectionPaintVisible=connectionPaint.unknown.length>0||!surfaceWidth||!surfaceHeight||connectionPaint.index.query({x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}).size>0; diff --git a/src/lib/canvas-bindings/projection-cache.ts b/src/lib/canvas-bindings/projection-cache.ts index f1e15ce..59c332c 100644 --- a/src/lib/canvas-bindings/projection-cache.ts +++ b/src/lib/canvas-bindings/projection-cache.ts @@ -7,6 +7,8 @@ type Retained={incarnation:number;ownerCanvasId:Id;value:T}; /** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */ export function createProjectionCache(){ let scope:object|undefined,projectId:Id|undefined,canvasId:Id|undefined; + let nodeArray:Projection['nodes']|undefined,edgeArray:Projection['edges']|undefined; + const stable=(previous:T[]|undefined,next:T[])=>previous?.length===next.length&&next.every((value,index)=>value===previous[index])?previous:next; let nodes=new Map>(),edges=new Map>(); function retain(document:Document,values:T[],previous:Map>){ const next=new Map>(); @@ -19,15 +21,16 @@ export function createProjectionCache(){ } return Object.freeze({ reuse(owner:object,document:Document,viewCanvasId:Id,projection:Projection):Projection{ - if(scope!==owner||projectId!==document.projectId||canvasId!==viewCanvasId){nodes.clear();edges.clear();scope=owner;projectId=document.projectId;canvasId=viewCanvasId;} - nodes=retain(document,projection.nodes,nodes);edges=retain(document,projection.edges,edges); - return projection; + if(scope!==owner||projectId!==document.projectId||canvasId!==viewCanvasId){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=owner;projectId=document.projectId;canvasId=viewCanvasId;} + const nextNodes=retain(document,projection.nodes,nodes),nextEdges=retain(document,projection.edges,edges); + nodes=nextNodes;edges=nextEdges;nodeArray=stable(nodeArray,projection.nodes);edgeArray=stable(edgeArray,projection.edges); + return{...projection,nodes:nodeArray,edges:edgeArray}; }, getNode(id:Id){return nodes.get(id)?.value;}, canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){ const old=nodes.get(value.id),record=document.elements.get(value.id); return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data); }, - clear(){nodes.clear();edges.clear();scope=undefined;projectId=undefined;canvasId=undefined;}, + clear(){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=undefined;projectId=undefined;canvasId=undefined;}, }); } diff --git a/src/lib/canvas-bindings/visibility.ts b/src/lib/canvas-bindings/visibility.ts new file mode 100644 index 0000000..2d12c21 --- /dev/null +++ b/src/lib/canvas-bindings/visibility.ts @@ -0,0 +1,3 @@ +import type {Rect} from '../canvas-core/geometry.ts'; +/** Positive-area native node visibility. Degenerate nodes are retained for measurement. */ +export function nativeRectVisible(node:Rect,viewport:Rect){if(node.width<=0||node.height<=0)return true;return Math.min(node.x+node.width,viewport.x+viewport.width)>Math.max(node.x,viewport.x)&&Math.min(node.y+node.height,viewport.y+viewport.height)>Math.max(node.y,viewport.y);} diff --git a/tests/headless/projection-cache.test.mjs b/tests/headless/projection-cache.test.mjs index 5580bd4..5dfec14 100644 --- a/tests/headless/projection-cache.test.mjs +++ b/tests/headless/projection-cache.test.mjs @@ -27,3 +27,6 @@ test('measurement reuse preserves translated bounds but invalidates changed port const ports=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'data.ports',before:[],after:[{id:'input',direction:'input',childNodeId:'A'}],beforePresent:true,afterPresent:true}]),portProjection=projectCanvas(ports,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,ports,'root',portProjection.nodes.find(value=>value.id==='F')),false); const scaled=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'data.presentation',before:null,after:{contentScale:2},beforePresent:false,afterPresent:true}]),scaleProjection=projectCanvas(scaled,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,scaled,'root',scaleProjection.nodes.find(value=>value.id==='A')),false);assert.equal(cache.canRetainMeasurement({},original,'root',projectCanvas(original,'root',registry,new Set(),()=>undefined).nodes[0]),false);cache.clear();assert.equal(cache.getNode('A'),undefined); }); +test('unchanged projection arrays retain identity while fresh document/query provenance remains current',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),clone=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(record=>structuredClone(record))),second=project(cache,scope,clone);assert.strictEqual(second.nodes,first.nodes);assert.strictEqual(second.edges,first.edges);assert.strictEqual(second.document,clone);assert.notStrictEqual(second.document,first.document);const changed=applyChanges(clone,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'array edit',beforePresent:true,afterPresent:true}]),third=project(cache,scope,changed);assert.notStrictEqual(third.nodes,second.nodes);assert.strictEqual(third.edges,second.edges);assert.strictEqual(third.nodes[1],second.nodes[1]);}); +test('array reuse respects order, scope replacement and explicit clearing',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),raw=projectCanvas(document,'root',registry,new Set(),()=>undefined);raw.nodes.reverse();const reverse=cache.reuse(scope,document,'root',raw);assert.notStrictEqual(reverse.nodes,first.nodes);assert.deepEqual(reverse.nodes.map(node=>node.id),first.nodes.map(node=>node.id).reverse());const replacement=project(cache,{},document);assert.notStrictEqual(replacement.nodes,first.nodes);cache.clear();assert.notStrictEqual(project(cache,scope,document).nodes,first.nodes);}); +test('an invalid edge cannot partially replace the retained node table',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),changed=applyChanges(document,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'prepared only',beforePresent:true,afterPresent:true}]),raw=projectCanvas(changed,'root',registry,new Set(),()=>undefined);raw.edges.push({...raw.edges[0],id:'missing-record'});assert.throws(()=>cache.reuse(scope,changed,'root',raw),/projection_identity_missing/);assert.strictEqual(cache.getNode('A'),first.nodes.find(node=>node.id==='A'));assert.equal(project(cache,scope,changed).nodes.find(node=>node.id==='A').data.text,'prepared only');}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 45ec8f4..999b0ba 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -419,15 +419,15 @@ try { await call('Page.reload');await until(`[...document.querySelectorAll('[data-headless-canvas] textarea')].some(input=>input.value==='JSON array UI text')&&document.querySelector('[aria-label="画布导入"]').textContent.includes('records.json')`,'JSON array task/content did not recover after reload'); console.log('PASS JSON array file picker imports and synchronizes native records then recovers its original task and content after reload'); await evaluate(`(()=>{const original=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){if(this.download){window.__exportedDocument=fetch(this.href).then(response=>response.text());return;}return original.call(this);};document.querySelector('[aria-label="导出图谱"]').click();HTMLAnchorElement.prototype.click=original;})()`); - const exported=await evaluate('window.__exportedDocument');assert.equal(JSON.parse(exported).documentFormatVersion,1);const beforeDocumentImport=documents.get(other).elements.size; + const exported=await evaluate('window.__exportedDocument');assert.equal(JSON.parse(exported).documentFormatVersion,1);const beforeDocumentImport=documents.get(other).elements.size,roundTripElementCount=JSON.parse(exported).elements.length; await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(exported)}],'round-trip.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`); await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('round-trip.json')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].filter(input=>input.value==='JSON array UI text').length===2`,'exported document did not reimport'); - for(let i=0;i<200&&documents.get(other).elements.size!==beforeDocumentImport+1;i++)await pause(20);assert.equal(documents.get(other).elements.size,beforeDocumentImport+1);await call('Page.reload');await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('round-trip.json')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].filter(input=>input.value==='JSON array UI text').length===2`,'document round trip task/content did not recover'); + for(let i=0;i<200&&documents.get(other).elements.size!==beforeDocumentImport+roundTripElementCount;i++)await pause(20);assert.equal(documents.get(other).elements.size,beforeDocumentImport+roundTripElementCount);await call('Page.reload');await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('round-trip.json')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].filter(input=>input.value==='JSON array UI text').length===2`,'document round trip task/content did not recover'); console.log('PASS actual toolbar JSON export reimports through file picker with fresh elements and recovers after reload'); const importedAssetId=[...assets.keys()][0],uploadsBeforeDocumentImage=uploads.length,imageImportDocument={documentFormatVersion:1,projectId:'image-source',canvases:[{id:'image-source',parent:null}],elements:[{id:'image-from-file',incarnation:1,ownerCanvasId:'image-source',componentId:'image',dataVersion:1,structure:'node',geometry:{x:400,y:100,width:200,height:100,rotation:0},data:{title:'Document imported image',text:'',assetId:importedAssetId,src:'https://untrusted.test/stale'}}],tombstones:{}}; const beforeImageImport=documents.get(other).elements.size; await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(JSON.stringify(imageImportDocument))}],'image-document.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`); - for(let i=0;i<200&&documents.get(other).elements.size!==beforeImageImport+1;i++)await pause(20);assert.equal(documents.get(other).elements.size,beforeImageImport+1);const importedImage=[...documents.get(other).elements.values()].find(e=>e.data.title==='Document imported image');assert.equal(importedImage.data.assetId,importedAssetId);assert.notEqual(importedImage.data.src,'https://untrusted.test/stale');assert.equal(uploads.length,uploadsBeforeDocumentImage); + for(let i=0;i<200&&![...documents.get(other).elements.values()].some(e=>e.data.title==='Document imported image');i++)await pause(20);assert.equal(documents.get(other).elements.size,beforeImageImport+1);const importedImage=[...documents.get(other).elements.values()].find(e=>e.data.title==='Document imported image');assert.equal(importedImage.data.assetId,importedAssetId);assert.notEqual(importedImage.data.src,'https://untrusted.test/stale');assert.equal(uploads.length,uploadsBeforeDocumentImage); await call('Page.reload');await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('image-document.json')&&!!document.querySelector('.react-flow__node[data-id="${importedImage.id}"] img')`,'authorized document image did not recover after reload');assert.equal(uploads.length,uploadsBeforeDocumentImage); console.log('PASS document image import reauthorizes existing resource, ignores stale URL and recovers without reuploading'); diff --git a/tests/headless/visibility.test.mjs b/tests/headless/visibility.test.mjs new file mode 100644 index 0000000..8a7151a --- /dev/null +++ b/tests/headless/visibility.test.mjs @@ -0,0 +1,2 @@ +import {test} from 'node:test';import assert from 'node:assert/strict';import {nativeRectVisible} from '../../src/lib/canvas-bindings/visibility.ts'; +test('additional retention is unnecessary for native-visible nodes and required for offscreen targets',()=>{const viewport={x:0,y:0,width:100,height:100};assert.equal(nativeRectVisible({x:10,y:10,width:20,height:20},viewport),true);assert.equal(nativeRectVisible({x:99,y:99,width:20,height:20},viewport),true);assert.equal(nativeRectVisible({x:100,y:0,width:10,height:10},viewport),false);assert.equal(nativeRectVisible({x:-20,y:-20,width:10,height:10},viewport),false);assert.equal(nativeRectVisible({x:1000,y:1000,width:0,height:10},viewport),true);});