diff --git a/headless/README.md b/headless/README.md index 5a559ba..26e7710 100644 --- a/headless/README.md +++ b/headless/README.md @@ -694,3 +694,29 @@ are not an SLA or proof of overall speedup. The 10000-child baseline probe at 0ce3abb remained unresponsive with a CPU-active renderer and approximately 4.4 GiB sampled physical footprint, and was interrupted. Its completion, first frame and root cause remain unverified; 10000-node support is not claimed. + + +Node measurement requests now share a microtask batch per React Flow store. +`createNodeInternalsBatch` owns a Map from mount tokens to node IDs and a dirty +Set of tokens. A flush deduplicates live IDs and invokes the public bulk update +once. Releasing a mount removes its pending request without touching a replacement +mount for the same ID. Reentrant invalidation schedules another flush; separate +stores never share work. The React adapter keys its WeakMap by `getState`, because +`useStoreApi()` creates a separate wrapper per hook. Acquisition/release happens +in effects, and the invalidate callback stays stable across renders. The SDK +still schedules its own animation frame; the adapter adds no extra frame timer. + +Inactive node/frame toolbars no longer mount `NodeToolbar`. AnimatePresence owns +the conditional toolbar so selection, portal editing and exit animation remain +available. In React Flow 12.12.1 each mounted toolbar subscribes to a selector +that scans the whole node array; an empty child was not an inactive subscription. +Likewise each individual forced measurement updates all absolute positions, so +per-node mount calls multiplied work. Both paths are bounded by actual active +UI needs now. These are renderer resources; no model, history or durable queue +state changes. + +A 10000-node page reached its initial scene after these changes, but its copy +probe still exceeded the 240-second completion window. During the copy it accepted +read-only diagnostics and displayed incremental content; commit/persistence costs +remain unresolved at that scale. Do not interpret the initial-scene improvement +or the batching checks as a passed 10000-node copy budget. diff --git a/src/components/canvas/frame-node.tsx b/src/components/canvas/frame-node.tsx index 33a6dc7..5752c23 100644 --- a/src/components/canvas/frame-node.tsx +++ b/src/components/canvas/frame-node.tsx @@ -33,8 +33,8 @@ export function FrameNode(props: NodeProps & { directAnchors?: boole setTitle(name); } return <> - - {selected && {selected && + event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}> setTitle(event.target.value)} onBlur={() => { if (cancelBlur.current) cancelBlur.current = false; else saveTitle(); }} @@ -50,8 +50,8 @@ export function FrameNode(props: NodeProps & { directAnchors?: boole } - } - + + } {data.subgraph && data.presentation?.collapsed && event.stopPropagation()}>{data.title}打开子画布} {mode !== "hidden" &&
{data.title}
} diff --git a/src/components/canvas/node-internals.ts b/src/components/canvas/node-internals.ts new file mode 100644 index 0000000..4d0af3e --- /dev/null +++ b/src/components/canvas/node-internals.ts @@ -0,0 +1,19 @@ +"use client"; + +import {useCallback,useEffect,useRef} from 'react'; +import {useStoreApi,useUpdateNodeInternals} from '@xyflow/react'; +import {createNodeInternalsBatch} from '@/lib/canvas-bindings/node-internals'; + +// useStoreApi wraps the API per hook. Its shared getState function identifies the +// actual store, separating canvases even when they render the same project. +const batches=new WeakMap>(); +export function useNodeInternalsUpdate(id:string){ + const scope=useStoreApi().getState,update=useUpdateNodeInternals(); + const lease=useRef['acquire']>|undefined>(undefined); + useEffect(()=>{ + let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);} + const owner=batch.acquire(id);lease.current=owner; + return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;}; + },[scope,update,id]); + return useCallback(()=>lease.current?.invalidate(),[]); +} diff --git a/src/components/canvas/node-style-toolbar.tsx b/src/components/canvas/node-style-toolbar.tsx index 8cffeae..cf0bced 100644 --- a/src/components/canvas/node-style-toolbar.tsx +++ b/src/components/canvas/node-style-toolbar.tsx @@ -18,8 +18,8 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps) const style = data.textStyle || {}; const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text"; const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize); - return - {selected && {selected && + event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => { if(event.key!=="Escape"){event.stopPropagation();return;} @@ -39,6 +39,6 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps) {!(["image", "plain-image"].includes(data.kind)) && } {data.kind !== "icon" && } - } - ; + + }; } diff --git a/src/components/canvas/transformable-node.tsx b/src/components/canvas/transformable-node.tsx index 93d9d89..259a4d4 100644 --- a/src/components/canvas/transformable-node.tsx +++ b/src/components/canvas/transformable-node.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, type PointerEvent, type ReactNode } from "react"; -import { useReactFlow, useUpdateNodeInternals, useViewport, type NodeProps, type XYPosition } from "@xyflow/react"; +import { useReactFlow, useViewport, type NodeProps, type XYPosition } from "@xyflow/react"; import { Grip, Maximize2, RotateCw } from "lucide-react"; import { elementCapabilities } from "@/lib/canvas-elements"; import type { CanvasNode } from "@/lib/canvas"; @@ -13,6 +13,7 @@ import { ContentHandles } from "./content-handles"; import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture"; import {canvasKeyContext,routeCanvasKey} from "@/lib/canvas-bindings/input-router"; import styles from "./canvas.module.css"; +import {useNodeInternalsUpdate} from "./node-internals"; export {NodeEditing} from "./node-editing"; import {useScopedNodeEditing} from "./node-editing"; @@ -22,7 +23,7 @@ export function TransformableNode(props: NodeProps & { children: Rea const { id, data, selected, width, height, children, className = "" } = props; const editing = useScopedNodeEditing(id,data.__headlessTarget); const flow = useReactFlow(); - const update = useUpdateNodeInternals(); + const update = useNodeInternalsUpdate(id); const { zoom } = useViewport(); const frame = useRef(null); const gesture = useRef(null); @@ -33,7 +34,7 @@ export function TransformableNode(props: NodeProps & { children: Rea const capabilities = elementCapabilities(data.kind); const resizing = capabilities.resizable && editing.resizing === id && selected; const rotating = capabilities.rotatable && editing.rotating === id && selected; - useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]); + useEffect(() => { update(); }, [id, rotation, width, height, selected, update]); useEffect(() => { if (!rotating && !resizing) return; const outside = (event: globalThis.PointerEvent) => { diff --git a/src/lib/canvas-bindings/node-internals.ts b/src/lib/canvas-bindings/node-internals.ts new file mode 100644 index 0000000..37a138a --- /dev/null +++ b/src/lib/canvas-bindings/node-internals.ts @@ -0,0 +1,17 @@ +/** One renderer store's transient measurement requests. Not model or history state. */ +export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){ + const owners=new Map(),dirty=new Set();let queued=false; + function flush(){ + queued=false;const ids=new Set();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear(); + if(ids.size)update([...ids]); + } + return Object.freeze({ + acquire(id:string){ + const owner=Symbol(id);owners.set(owner,id); + return Object.freeze({ + invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}}, + dispose(){owners.delete(owner);dirty.delete(owner);}, + }); + }, + }); +} diff --git a/tests/headless/node-internals.test.mjs b/tests/headless/node-internals.test.mjs new file mode 100644 index 0000000..9a8eadc --- /dev/null +++ b/tests/headless/node-internals.test.mjs @@ -0,0 +1,12 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createNodeInternalsBatch} from '../../src/lib/canvas-bindings/node-internals.ts'; +test('10000 mounted nodes coalesce repeated measurement requests into one renderer update',()=>{ + const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),leases=Array.from({length:10000},(_,i)=>batch.acquire('n'+i));for(const lease of leases){lease.invalidate();lease.invalidate();}assert.equal(tasks.length,1);assert.equal(calls.length,0);tasks.shift()();assert.deepEqual(calls,[Array.from({length:10000},(_,i)=>'n'+i)]);for(const lease of leases)lease.dispose(); +}); +test('unmounted and replaced owners cannot retain or cancel another lease for the same node',()=>{ + const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),old=batch.acquire('A'),next=batch.acquire('A'),removed=batch.acquire('B');old.invalidate();next.invalidate();removed.invalidate();old.dispose();removed.dispose();old.invalidate();tasks.shift()();assert.deepEqual(calls,[['A']]);next.invalidate();next.dispose();tasks.shift()();assert.equal(calls.length,1); +}); +test('renderer stores and later updates remain independent, including reentrant invalidation',()=>{ + const tasks=[],calls=[];let again;const one=createNodeInternalsBatch(ids=>{calls.push(['one',ids]);again?.();},fn=>tasks.push(fn)),two=createNodeInternalsBatch(ids=>calls.push(['two',ids]),fn=>tasks.push(fn)),a=one.acquire('A'),b=two.acquire('A');let reentered=false;again=()=>{if(!reentered){reentered=true;a.invalidate();}};a.invalidate();b.invalidate();assert.equal(tasks.length,2);tasks.shift()();assert.equal(tasks.length,2);tasks.shift()();tasks.shift()();assert.deepEqual(calls,[['one',['A']],['two',['A']],['one',['A']]]);a.dispose();b.dispose(); +}); diff --git a/tests/headless/renderer-performance.mjs b/tests/headless/renderer-performance.mjs index 8dd1f3f..66649a9 100644 --- a/tests/headless/renderer-performance.mjs +++ b/tests/headless/renderer-performance.mjs @@ -10,10 +10,12 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co let report; try{ await wait(`!!document.querySelector('.react-flow__node[data-id="perf-frame"]')&&document.querySelectorAll('.react-flow__node').length>=2`,'initial large canvas did not render'); - const initialReadyMs=await evaluate('performance.now()'); + assert.equal(await evaluate('document.querySelectorAll(".react-flow__node-toolbar").length'),0,'unselected nodes retained empty toolbar subscriptions'); + const initialReadyMs=await evaluate('performance.now()');console.log('PERF initial scene ready '+Math.round(initialReadyMs)+' ms'); 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)))`); + assert.equal(await evaluate('document.querySelectorAll(".react-flow__node-toolbar").length'),1,'selected frame did not own exactly one toolbar'); await evaluate(`(()=>{ const originals=new Set(['perf-source','perf-frame',...Array.from({length:${count}},(_,i)=>'perf-'+i)]),start=performance.now(),tasks=[],gaps=[]; let raf,last=start,sawPreparing=false,firstDOM=null,firstFrame=null,localComplete=null,localCompleteFrame=null,prepared=null,maxNodes=0; @@ -24,8 +26,10 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co window.__canvasPerformance={read(){const sorted=[...gaps].sort((a,b)=>a-b),p95=sorted[Math.max(0,Math.ceil(sorted.length*.95)-1)]??0;return{frameGapP95Ms:p95,elapsedMs:performance.now()-start,firstDOMMs:firstDOM,firstPresentedFrameMs:firstFrame,localCompleteMs:localComplete,localCompleteFrameMs:localCompleteFrame,prepareMs:prepared,maxMountedNodes:maxNodes,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(); })()`); + console.log('PERF copy requested'); await wait(`document.querySelectorAll('.react-flow__node').length>0&&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 internalsBatches=await evaluate('window.__fixtureInternalsBatches??[]');assert.ok(internalsBatches.some(size=>size>1),'mounted nodes did not share a renderer measurement batch'); 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.ok(measurements.firstPresentedFrameMs!==null&&measurements.localCompleteFrameMs!==null);if(count>=100)assert.ok(measurements.nodeCount{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect(),matrix=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);pane.dispatchEvent(new WheelEvent('wheel',{deltaX:(${world.x}*matrix.a+matrix.e-rect.width/2)/.5,deltaY:(${world.y}*matrix.a+matrix.f-rect.height/2)/.5,bubbles:true,cancelable:true}));})()`); await wait(`!!document.querySelector('.react-flow__node[data-id="${copiedLast.id}"] textarea')`,'offscreen copied card did not mount after viewport pan'); const viewportCheck={visibleEdgesBeforePan:visibleEdges,copiedId:copiedLast.id,mountedAfterPan:await evaluate('document.querySelectorAll(".react-flow__node").length')}; - report={scenario:'actual GraphPage deep copy of expanded partition',mode:'Next development webpack; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks',count,initialReadyMs,measurements,viewportCheck,browser:await call('Browser.getVersion'),serverElements:document.elements.size,serverCanvases:document.canvases.size,wallMs:Date.now()-started}; + report={scenario:'actual GraphPage deep copy of expanded partition',mode:'Next development webpack; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks',count,initialReadyMs,internals:{batches:internalsBatches.length,maxBatch:Math.max(0,...internalsBatches),totalRequests:internalsBatches.reduce((sum,size)=>sum+size,0)},measurements,viewportCheck,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 cc04cc7..71f49bc 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -105,6 +105,9 @@ let next, chrome, socket, logs = ''; try { await cp(join(source, 'src'), join(directory, 'src'), { recursive: true }); await cp(join(source, 'public'), join(directory, 'public'), { recursive: true }); + // Count delivered measurement batches in the isolated fixture only. + const internalsFile=join(directory,'src/lib/canvas-bindings/node-internals.ts'),internals=await readFile(internalsFile,'utf8');assert.ok(internals.includes('if(ids.size)update([...ids]);'));await writeFile(internalsFile,internals.replace('if(ids.size)update([...ids]);',`if(ids.size){const host=globalThis as typeof globalThis&{__fixtureInternalsBatches?:number[]};(host.__fixtureInternalsBatches??=[]).push(ids.size);update([...ids]);}`)); + 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')); @@ -434,7 +437,8 @@ try { scopeStreamRoot=id(850);const scopedRoot=scopeStreamRoot,scopedChild=id(851),scopeFrame={...node('scope-frame',scopedRoot,450),componentId:'frame',geometry:{x:450,y:150,width:600,height:450,rotation:0}}; documents.set(scopedRoot,createDocument(scopedRoot,[{id:scopedRoot,parent:null},{id:scopedChild,parent:{canvasId:scopedRoot,entryElementId:scopeFrame.id}}],[node('scope-node',scopedRoot,80),scopeFrame,node('scope-child',scopedChild,20),node('scope-tail',scopedChild,320)]));versions.set(scopedRoot,1);versions.set(scopedChild,1);boards.push({id:scopedRoot,workspace_id:workspace,kind:'board',version:1,title:'Scope ACK board'}); await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${scopedRoot}`});await until(`!!document.querySelector('[data-headless-canvas][data-model-loading] .react-flow__node[data-id="scope-node"]')`,'scope fixture did not show its certified owner'); - const scopePoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="scope-node"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+50,y:r.y+25};})()`); + await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + await until(`(()=>{const layer=document.querySelector('.react-flow__node[data-id="scope-node"] [aria-label^="单击选中"]');if(!layer)return false;const r=layer.getBoundingClientRect();for(const x of [.25,.5,.75,.9])for(const y of [.25,.5,.75]){const point={x:r.x+r.width*x,y:r.y+r.height*y};if(layer.contains(document.elementFromPoint(point.x,point.y))){window.__scopeEditPoint=point;return true;}}return false;})()`,'scope text remained covered by another surface');const scopePoint=await evaluate('window.__scopeEditPoint'); await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="scope-node"] textarea')&&!document.activeElement.readOnly`,'certified scope text did not enter editing');await call('Input.insertText',{text:' ACK before tail'});const scopeText=await evaluate('document.activeElement.value'); for(let i=0;i<150&&documents.get(scopedRoot).elements.get('scope-node').data.text!==scopeText;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get('scope-node').data.text,scopeText,'certified scope edit waited for another owner tail');assert.equal(versions.get(scopedRoot),2);assert.equal(versions.get(scopedChild),1);assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`),false); assert.equal(await evaluate(`document.querySelector('button[aria-label="添加文本卡片"]').disabled`),false);await click('添加文本卡片');