perf(canvas): stabilize renderer bindings and retain unchanged projections

This commit is contained in:
2026-11-08 17:10:00 +08:00
parent 8a58d9785c
commit 8a2b1e4bc7
6 changed files with 147 additions and 39 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
"test:headless": "node --test tests/headless/*.test.mjs",
"test:headless:browser": "node tests/headless/browser-storage.mjs",
"test:headless:proxy": "node tests/headless/proxy.integration.mjs",
"test:headless:renderer": "node tests/headless/renderer.integration.mjs"
"test:headless:renderer": "node tests/headless/renderer.integration.mjs",
"test:headless:performance": "node tests/headless/renderer.integration.mjs --performance"
},
"dependencies": {
"@radix-ui/react-dialog": "1.3.1",
+48 -36
View File
@@ -1,11 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection, type NodeProps } from "@xyflow/react";
import { Copy, Download, Frame, Hand, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
import type { Board } from "@/lib/workspace";
import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types";
import { equal } from "@/lib/canvas-core/values";
import { createProjectionCache } from "@/lib/canvas-bindings/projection-cache";
import { ancestors, commonCanvas } from "@/lib/canvas-core/document";
import { projectCanvas, toLocal, transformPath, type ProjectionNode } from "@/lib/canvas-bindings/projection";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
@@ -44,6 +45,8 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
const [error, setError] = useState(""), [sources, setSources] = useState(new Map<string, string>());
const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
projectionCache.current ??= createProjectionCache();
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id, Id>());
const projection = useMemo(() => {
@@ -51,7 +54,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
// Preserve renderer measurements across metadata/preview updates. Dropping them
// makes React Flow discard handle bounds and unmount active edge controls.
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height) node.measured = cached.measured; }
return result;
return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
}, [engine, root.document, sessions, view]);
const current = useRef({ root, projection, view, engine }); current.current = { root, projection, view, engine };
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
@@ -120,6 +123,46 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
const modes = view.modes;
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined;
// Runtime-only publications must not invalidate every card's editing context.
// Actions use current.current / engine snapshots instead of captured document projections.
const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null;
const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null;
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
const nodeEditing = useMemo<ContextType<typeof NodeEditing>>(() => ({ workspace: board.workspace_id, schema: 2,
begin: id => { if (id) { groups.current.set(id, crypto.randomUUID()); engine.sessions.mode(viewId, id, 'editing'); } },
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id, { text }, groups.current.get(id)), rename: (id, title) => data(id, { title }),
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
branch: (id, direction) => {
const node = current.current.projection.nodes.find(node => node.id === id); if (!node) return;
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
report(engine.editor.transaction(tx => { const created = add('text', undefined, tx, { x: node.position.x + dx, y: node.position.y + dy }); const source = tx.getDocument().elements.get(id)!, next = tx.getDocument().elements.get(created)!; tx.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(tx.getDocument(), source.ownerCanvasId, next.ownerCanvasId), source: { elementId: id, portId: direction }, target: { elementId: created }, data: {}, path: null } }); }, { label: 'Add branch' }));
},
}),[engine,viewId,board.workspace_id,flow,report,rotating,resizing]);
const edgeEditing = useMemo<ContextType<typeof EdgeEditing>>(() => ({ active: new Set(), begin: id => {
if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false;
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
return guardEditingLease(ownsGesture, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
},
finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
patch: (id, value, transient) => {
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
const t = current.current.projection.transforms.get(element.ownerCanvasId)!;
const path = transformPath((value.path ?? null) as Json, t, true);
const displayedPath = current.current.projection.edges.find(edge => edge.id === id)?.data?.path;
const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
const { path: ignored, ...rest } = value; void ignored;
report(engine.editor.transaction(tx => { tx.dispatch({ type: 'element.data', target: target(id), patch: json(rest) }); if (changedPath) tx.dispatch({ type: 'connection.path', target: target(id), path }); }, { label: 'Edit connection' }));
},
}),[engine,viewId,report]);
if (!liveView) return <p role="status">画布已关闭</p>;
return <div className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas tabIndex={-1}
onMouseDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); }}
@@ -135,40 +178,9 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
else if (event.key.toLowerCase() === 'h') engine.sessions.tool(viewId, 'pan');
else if (event.key.toLowerCase() === 'v') engine.sessions.tool(viewId, 'select');
}}>
<ImagePreviews.Provider value={{ sources, loaded: () => {} }}>
<NodeEditing.Provider value={{ workspace: board.workspace_id, schema: 2,
begin: id => { if (id) { groups.current.set(id, crypto.randomUUID()); engine.sessions.mode(viewId, id, 'editing'); } },
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id, { text }, groups.current.get(id)), rename: (id, title) => data(id, { title }),
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
rotating: [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null, rotate: id => { for (const [key, mode] of modes) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
resizing: [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null, resize: id => { for (const [key, mode] of modes) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
branch: (id, direction) => {
const node = projection.nodes.find(node => node.id === id); if (!node) return;
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
report(engine.editor.transaction(tx => { const created = add('text', undefined, tx, { x: node.position.x + dx, y: node.position.y + dy }); const source = tx.getDocument().elements.get(id)!, next = tx.getDocument().elements.get(created)!; tx.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(tx.getDocument(), source.ownerCanvasId, next.ownerCanvasId), source: { elementId: id, portId: direction }, target: { elementId: created }, data: {}, path: null } }); }, { label: 'Add branch' }));
},
}}>
<EdgeEditing.Provider value={{ active: new Set(), begin: id => {
if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false;
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
const transform = projection.transforms.get(element.ownerCanvasId)!;
const current = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
return guardEditingLease(current, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
},
finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
patch: (id, value, transient) => {
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
const t = projection.transforms.get(element.ownerCanvasId)!;
const path = transformPath((value.path ?? null) as Json, t, true);
const displayedPath = projection.edges.find(edge => edge.id === id)?.data?.path;
const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
const { path: ignored, ...rest } = value; void ignored;
report(engine.editor.transaction(tx => { tx.dispatch({ type: 'element.data', target: target(id), patch: json(rest) }); if (changedPath) tx.dispatch({ type: 'connection.path', target: target(id), path }); }, { label: 'Edit connection' }));
},
}}>
<ImagePreviews.Provider value={imagePreviews}>
<NodeEditing.Provider value={nodeEditing}>
<EdgeEditing.Provider value={edgeEditing}>
<ReactFlow<ProjectionNode> nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
nodesDraggable={view.tool === 'select'} panOnDrag={view.tool === 'pan' ? true : [1, 2]} selectionOnDrag={view.tool === 'select'} panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} defaultViewport={view.viewport}
@@ -0,0 +1,28 @@
import type {Document,Id} from '../canvas-core/types.ts';
import {equal,fault} from '../canvas-core/values.ts';
import type {projectCanvas} from './projection.ts';
type Projection=ReturnType<typeof projectCanvas>;
type Retained<T>={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 nodes=new Map<Id,Retained<Projection['nodes'][number]>>(),edges=new Map<Id,Retained<Projection['edges'][number]>>();
function retain<T extends{id:Id}>(document:Document,values:T[],previous:Map<Id,Retained<T>>){
const next=new Map<Id,Retained<T>>();
for(let index=0;index<values.length;index++){
const value=values[index],record=document.elements.get(value.id);if(!record)throw fault('projection_identity_missing',[value.id]);
const old=previous.get(value.id),retained=old&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&equal(old.value,value)?old.value:value;
values[index]=retained;next.set(value.id,{incarnation:record.incarnation,ownerCanvasId:record.ownerCanvasId,value:retained});
}
return next;
}
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;
},
clear(){nodes.clear();edges.clear();scope=undefined;projectId=undefined;canvasId=undefined;},
});
}
+22
View File
@@ -0,0 +1,22 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
import {createProjectionCache} from '../../src/lib/canvas-bindings/projection-cache.ts';
const registry=createRegistry(builtinComponents()),node=(id,owner='root')=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,data:{title:id,text:'source'},geometry:{x:0,y:0,width:100,height:100,rotation:0}}),edge={id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{title:''}},document=createDocument('project',[{id:'root',parent:null}],[node('A'),node('B'),edge]);
const project=(cache,scope,document,selection=new Set(),geometry=()=>undefined)=>cache.reuse(scope,document,'root',projectCanvas(document,'root',registry,selection,geometry));
test('unchanged projection objects survive unrelated additions and equivalent canonical record clones',()=>{
const cache=createProjectionCache(),scope={},first=project(cache,scope,document),added=applyChanges(document,[{kind:'element',id:'C',before:null,after:node('C')}]),second=project(cache,scope,added);assert.equal(first.nodes[0],second.nodes[0]);assert.equal(first.nodes[1],second.nodes[1]);assert.equal(first.edges[0],second.edges[0]);assert.notEqual(first.transforms,second.transforms);
const cloned=createDocument('project',[...added.canvases.values()],[...added.elements.values()]),third=project(cache,scope,cloned);assert.equal(third.nodes[0],second.nodes[0]);assert.equal(third.edges[0],second.edges[0]);
});
test('content, selection, preview geometry, measurements and edges refresh only affected projections',()=>{
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:'edited',beforePresent:true,afterPresent:true}]),second=project(cache,scope,changed);assert.notEqual(first.nodes[0],second.nodes[0]);assert.equal(first.nodes[1],second.nodes[1]);
const selected=project(cache,scope,changed,new Set(['B']));assert.equal(second.nodes[0],selected.nodes[0]);assert.notEqual(second.nodes[1],selected.nodes[1]);const preview=project(cache,scope,changed,new Set(['B']),id=>id==='A'?{...node('A').geometry,x:40}:undefined);assert.notEqual(preview.nodes[0],selected.nodes[0]);assert.equal(preview.nodes[1],selected.nodes[1]);
const measured=projectCanvas(changed,'root',registry,new Set(['B']),()=>undefined);measured.nodes[0].measured={width:101,height:100};const result=cache.reuse(scope,changed,'root',measured);assert.equal(result.nodes[0].measured.width,101);
const edgeChanged=applyChanges(changed,[{kind:'field',target:{id:'edge',incarnation:1},field:'data.title',before:'',after:'edge title',beforePresent:true,afterPresent:true}]),last=project(cache,scope,edgeChanged);assert.notEqual(last.edges[0],first.edges[0]);assert.equal(last.edges[0].data.title,'edge title');
});
test('lifecycle, ownership, view scope and deletion never reuse an obsolete projection',()=>{
const cache=createProjectionCache(),scope={},first=project(cache,scope,document),restored=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.notEqual(project(cache,scope,restored).nodes[0],first.nodes[0]);const prior=project(cache,scope,document);assert.notEqual(project(cache,{},document).nodes[0],prior.nodes[0]);
const canvases=[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],frame={...node('F'),componentId:'frame'},beforeMove=createDocument('project',canvases,[...document.elements.values(),frame]);const before=project(cache,scope,beforeMove).nodes.find(value=>value.id==='A');const moved=createDocument('project',canvases,[...beforeMove.elements.values()].map(record=>record.id==='A'?{...record,ownerCanvasId:'child',geometry:{...record.geometry,x:-24,y:-36}}:record)),after=project(cache,scope,moved).nodes.find(value=>value.id==='A');assert.deepEqual(before.position,after.position);assert.notEqual(before,after);
const current=project(cache,scope,document),empty=createDocument('project',[{id:'root',parent:null}]);project(cache,scope,empty);assert.notEqual(project(cache,scope,document).nodes[0],current.nodes[0]);cache.clear();assert.notEqual(project(cache,scope,document).nodes[0],current.nodes[0]);
});
+34
View File
@@ -0,0 +1,34 @@
/** Actual GraphPage/React Flow + IndexedDB probe; fixture HTTP server, not production service. */
import assert from 'node:assert/strict';
import {writeFile} from 'node:fs/promises';
import {setTimeout as pause} from 'node:timers/promises';
export async function measureRenderer({call,evaluate,documents,root,partition,count,exceptions,reportPath}){
const initial=count+2,target=initial+count+1,started=Date.now();
const wait=async(expression,label)=>{const deadline=Date.now()+240000;while(Date.now()<deadline){if(exceptions.length)throw Error(exceptions.join('\n'));if(await evaluate(expression))return;await pause(100);}throw Error(label);};
let report;
try{
await wait(`document.querySelectorAll('.react-flow__node').length===${initial}`,'initial large canvas did not render');
const initialReadyMs=await evaluate('performance.now()');
await evaluate(`document.querySelector('.react-flow__node[data-id="perf-frame"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);
await wait(`!!document.querySelector('[aria-label="深复制选中内容"]')&&!document.querySelector('[aria-label="深复制选中内容"]').disabled`,'partition selection did not enable deep copy');
await evaluate(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))`);
await evaluate(`(()=>{
const base=${initial},target=${target},start=performance.now(),tasks=[],gaps=[];
let raf,last=start,sawPreparing=false,firstDOM=null,firstFrame=null,allDOM=null,allFrame=null,prepared=null;
const supported=PerformanceObserver.supportedEntryTypes.includes('longtask'),observer=supported?new PerformanceObserver(list=>tasks.push(...list.getEntries().map(entry=>({start:entry.startTime-start,duration:entry.duration})))):null;
observer?.observe({type:'longtask'});
function tick(now){gaps.push(now-last);last=now;const size=document.querySelectorAll('.react-flow__node').length;if(size>base){if(firstDOM===null)firstDOM=now-start;else if(firstFrame===null)firstFrame=now-start;}if(size>=target){if(allDOM===null)allDOM=now-start;else if(allFrame===null)allFrame=now-start;}if(document.querySelector('[aria-label="正在准备复制"]'))sawPreparing=true;else if(sawPreparing&&prepared===null)prepared=now-start;raf=requestAnimationFrame(tick);}
raf=requestAnimationFrame(tick);
window.__canvasPerformance={read(){return{elapsedMs:performance.now()-start,firstDOMMs:firstDOM,firstPresentedFrameMs:firstFrame,allDOMMs:allDOM,allPresentedFrameMs:allFrame,prepareMs:prepared,nodeCount:document.querySelectorAll('.react-flow__node').length,longTasksSupported:supported,longTaskCount:tasks.length,maxLongTaskMs:Math.max(0,...tasks.map(task=>task.duration)),totalLongTaskMs:tasks.reduce((total,task)=>total+task.duration,0),maxFrameGapMs:Math.max(0,...gaps),longTasks:tasks};},stop(){cancelAnimationFrame(raf);if(observer){tasks.push(...observer.takeRecords().map(entry=>({start:entry.startTime-start,duration:entry.duration})));observer.disconnect();}return this.read();}};
document.querySelector('[aria-label="深复制选中内容"]').click();
})()`);
await wait(`document.querySelectorAll('.react-flow__node').length===${target}&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('已加入画布')&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'large copy did not finish rendering and synchronization');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
const measurements=await evaluate('window.__canvasPerformance.stop()'),document=documents.get(root),copiedCanvas=[...document.canvases.values()].find(canvas=>canvas.parent&&canvas.id!==partition);
assert.equal(document.elements.size,2*count+5);assert.ok(copiedCanvas);assert.ok([...document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId==='perf-source'&&element.target.elementId===copiedCanvas.parent.entryElementId));assert.ok(document.elements.has('perf-incoming'));assert.equal(measurements.nodeCount,target);assert.ok(measurements.firstPresentedFrameMs!==null&&measurements.allPresentedFrameMs!==null);
report={scenario:'actual GraphPage deep copy of expanded partition',mode:'Next development webpack; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks',count,initialReadyMs,measurements,browser:await call('Browser.getVersion'),serverElements:document.elements.size,serverCanvases:document.canvases.size,wallMs:Date.now()-started};
const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile(reportPath.endsWith('.json')?reportPath.slice(0,-5)+'.png':reportPath+'.png',Buffer.from(screenshot.data,'base64'));
}catch(error){let measurements;try{measurements=await evaluate('window.__canvasPerformance?.stop()');}catch{}report={scenario:'actual GraphPage deep copy of expanded partition',count,error:error.message,measurements,wallMs:Date.now()-started};await writeFile(reportPath,JSON.stringify(report,null,2));throw error;}
await writeFile(reportPath,JSON.stringify(report,null,2));console.log('MEASURE '+JSON.stringify({...report,measurements:{...report.measurements,longTasks:undefined}}));console.log('REPORT '+reportPath);
}
+13 -2
View File
@@ -1,3 +1,4 @@
import {measureRenderer} from './renderer-performance.mjs';
/** Actual dashboard, GraphPage, headless renderer, browser storage and Next proxy. */
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
@@ -7,12 +8,18 @@ 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);
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')}`;
const account = id(1), workspace = id(2), root = id(3), other = id(4), partition = id(5), calls = [], uploads = [], documents = new Map(), versions = new Map([[root, 1], [other, 1]]), assets = new Map(), aiRuns = new Map();
const node = (id, ownerCanvasId, x) => ({ id, ownerCanvasId, incarnation: 1, componentId: 'text', dataVersion: 1, structure: 'node', data: { title: 'Card ' + x, text: 'Editable content' }, geometry: { x, y: 150, width: 260, height: 200, rotation: 0 } });
documents.set(root, createDocument(root, [{ id: root, parent: null }], [node('a', root, 80), node('b', root, 450), { id: 'edge', incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: root, source: { elementId: 'a' }, target: { elementId: 'b' }, data: {}, path: { type: 'bezier-chain', knots: [{ id: 'k', mode: 'corner', position: { x: 400, y: 250 }, in: { x: -20, y: 0 }, out: { x: 20, y: 0 } }] } }]));
documents.set(other, createDocument(other, [{ id: other, parent: null }], []));
if(performanceMode){
const frame={...node('perf-frame',root,30),componentId:'frame',data:{title:'Performance partition',text:''},geometry:{x:30,y:50,width:1000,height:600,rotation:0}},children=Array.from({length:performanceCount},(_,index)=>({...node(`perf-${index}`,partition,(index%20)*280),geometry:{x:(index%20)*280,y:Math.floor(index/20)*220,width:260,height:200,rotation:0}})),incoming={id:'perf-incoming',incarnation:1,componentId:'connection',dataVersion:1,structure:'connection',ownerCanvasId:root,source:{elementId:'perf-source'},target:{elementId:'perf-frame'},data:{},path:null};
versions.set(partition,1);documents.set(root,createDocument(root,[{id:root,parent:null},{id:partition,parent:{canvasId:root,entryElementId:frame.id}}],[node('perf-source',root,-300),frame,...children,incoming]));
}
const boards = [root, other].map((id, i) => ({ id, workspace_id: workspace, kind: 'board', version: 1, title: i ? 'Other board' : 'Headless board' }));
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', 'base64');
let releaseImport;const importGate=new Promise(resolve=>{releaseImport=resolve;});let importWriteSeen=false;const editReceipts=new Map();
@@ -39,9 +46,9 @@ const upstream = createServer(async (request, response) => {
if(batch.operations.some(operation=>operation.changes.some(change=>change.kind==='element'&&change.after?.data?.title==='Imported NDJSON node'))){importWriteSeen=true;await importGate;}
assert.equal(request.headers['x-expected-account'], account); assert.equal(request.headers['idempotency-key'], batch.request_id);
if(rejectNextEdit){rejectNextEdit=false;rejectedEdit=batch;const before=previous.elements.get('b');documents.set(write[1],applyChanges(previous,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:before.data.text,after:'Remote reviewed',beforePresent:true,afterPresent:true}]));versions.set(root,versions.get(root)+1);return reply(409,{protocol_version:2,request_id:batch.request_id,status:'not-applied',terminal:true,error:'version_conflict'});}
for(const [id,version] of Object.entries(batch.expected_versions))assert.equal(version,versions.get(id));
for(const [id,version] of Object.entries(batch.expected_versions))assert.equal(version,versions.get(id)??0);
let next = previous; for (const operation of batch.operations) next = applyChanges(next, operation.changes);
documents.set(write[1], next);for(const id of Object.keys(batch.expected_versions))versions.set(id,versions.get(id)+1);
documents.set(write[1], next);for(const id of Object.keys(batch.expected_versions))versions.set(id,(versions.get(id)??0)+1);
const receipt={ protocol_version: 2, request_id: batch.request_id, status: 'applied', operation_ids: batch.operations.map(operation => operation.id), model: model(next, false,Object.keys(batch.expected_versions)) };editReceipts.set(batch.request_id,receipt);return reply(200,receipt);
}
const editQuery=path.match(/\/canvas\/model\/operations\/([^/]+)$/);if(editQuery)return editReceipts.has(editQuery[1])?reply(200,editReceipts.get(editQuery[1])):reply(404,{request_id:editQuery[1],status:'unknown'});
@@ -99,6 +106,9 @@ try {
await call('Runtime.enable'); await call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${root}`;
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{
await until('!!document.querySelector("[data-headless-canvas]") && document.querySelectorAll(".react-flow__node").length === 2', 'actual headless canvas did not open');
assert.equal(calls.some(call => call.path.endsWith('/canvas')), false, 'canonical project read the legacy snapshot');
assert.equal(await evaluate('!!document.querySelector("[aria-label=画布快捷操作]") && !document.querySelector("[aria-label=展开侧边栏]")'), true);
@@ -251,6 +261,7 @@ try {
await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`);
await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view');
console.log('PASS logout retires the runtime before dropping its view without a render error');
}
} finally {
releaseImport();releaseApply();releaseUpload(); socket?.close();
if (chrome) { const exit = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); }); chrome.kill(); await exit; }