From 8a2b1e4bc7988ba170a980f851bfb403505f125e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 17:10:00 +0800 Subject: [PATCH] perf(canvas): stabilize renderer bindings and retain unchanged projections --- package.json | 3 +- .../canvas/headless-graph-canvas.tsx | 84 +++++++++++-------- src/lib/canvas-bindings/projection-cache.ts | 28 +++++++ tests/headless/projection-cache.test.mjs | 22 +++++ tests/headless/renderer-performance.mjs | 34 ++++++++ tests/headless/renderer.integration.mjs | 15 +++- 6 files changed, 147 insertions(+), 39 deletions(-) create mode 100644 src/lib/canvas-bindings/projection-cache.ts create mode 100644 tests/headless/projection-cache.test.mjs create mode 100644 tests/headless/renderer-performance.mjs diff --git a/package.json b/package.json index 9f7f7ef..e52ce7d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 600cb21..9777cd8 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -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()); const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map } | null>(null); + const projectionCache = useRef | null>(null); + projectionCache.current ??= createProjectionCache(); const measurements = useRef(new Map()); const imageInput = useRef(null), groups = useRef(new Map()); 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>(() => ({sources,loaded:()=>{}}),[sources]); + const nodeEditing = useMemo>(() => ({ 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>(() => ({ 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) => 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

画布已关闭

; return
{ 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'); }}> - {} }}> - { 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' })); - }, - }}> - { - 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) => 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' })); - }, - }}> + + + 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} diff --git a/src/lib/canvas-bindings/projection-cache.ts b/src/lib/canvas-bindings/projection-cache.ts new file mode 100644 index 0000000..f1343af --- /dev/null +++ b/src/lib/canvas-bindings/projection-cache.ts @@ -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; +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 nodes=new Map>(),edges=new Map>(); + function retain(document:Document,values:T[],previous:Map>){ + const next=new Map>(); + for(let index=0;index({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]); +}); diff --git a/tests/headless/renderer-performance.mjs b/tests/headless/renderer-performance.mjs new file mode 100644 index 0000000..714c85b --- /dev/null +++ b/tests/headless/renderer-performance.mjs @@ -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()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); +} diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index eaf6f54..7615d3f 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -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; }