Retain explicit canvas owners without disabling viewport culling

This commit is contained in:
2026-11-11 02:21:57 +08:00
parent 72a8e01114
commit 680aea0aa7
8 changed files with 181 additions and 35 deletions
+26 -10
View File
@@ -1000,21 +1000,37 @@ elements or requests. The low-level helper defaults to its prior per-slice
checkpoint policy unless a cadence is requested. Checkpoint/source formats and
resume IDs stay unchanged; a crash/pause can redo only unsaved private planning.
Native culling now checks active interaction owners instead of disabling itself
for every focused input or editing mode. Visible native nodes (including nodes
React Flow itself retains while dragging/initially measuring) keep culling on.
An offscreen owner, connection/custom owner without a native node, or focused
portal input without a native node still uses the conservative full-graph
fallback so viewport changes cannot discard IME, drafts or gesture leases.
Focus tracking stores the current input element and keeps the deferred blur
fence, including direct transfers between inputs. This is not selective pinning:
offscreen/custom interaction fallbacks can still mount the complete graph.
The app renderer retains explicit view owners without turning off native culling.
Adapter-local `__endoleapRetain` flags cover node modes/gestures, focused native
inputs, registered Portal overlays, known node paint overflow and curved-edge
paint. Retained connections also retain their endpoint owners. The complete
nodes/edges stay in React Flow's lookup; geometry, dragging, model data and hidden
state are not fabricated. A view-local weak cache preserves tagged row identity,
and ending an owner returns the original row. Detached focus is retired even
when removing a renderer emits no blur. Flags never enter a Root or file.
Unscoped focused inputs/portals and exhausted/unknown paint-budget sentinels retain
the conservative whole-graph fallback; their missing ownership is not guessed.
React Flow 12.12.1 does not expose a retained-ID selector. The app-only
`scripts/loaders/xyflow-retained.cjs` extends its two ESM visibility selectors at
compile time, under both webpack and Turbopack in `next.config.ts`. It requires
the pinned version and exact source anchors, rejecting mismatches before silently
losing editor retention. Dependency upgrades require reviewing this extension.
Shared node_modules are not edited. This is an app renderer build requirement;
the portable headless SDK/optional bindings do not include the Next loader.
The actual-page performance probe double-clicks a visible text card with native
Chrome input, checks mounted-node growth, inserts text and checks DOM identity,
focus and content before measuring deep copy. It then clears selection with a
native empty-pane click. Reports include `inputCheck.beforeEditing` and
`inputCheck.duringEditing`; this preflight is outside the copy timing window.
`inputCheck.duringEditing`, `inputCheck.offscreenEditing` and
`inputCheck.offscreenPortal`; this preflight is outside the copy timing window.
The screen-pan probes preserve native IME and an unsaved Portal title, bound
mounted owners, check release after blur, and remount saved content. Add
`--turbopack` to the page/performance runner to use the app's default compiler.
Turbopack fixtures live temporarily under the physical dependency workspace so
its filesystem fence can resolve Next; the exact unique directory is removed in
finally. Webpack fixtures continue to use the OS temporary directory.
Set `HEADLESS_PERFORMANCE_EDIT_DURING_COPY=1` with `--performance` and at least
1000 nodes to add a mixed-workload probe. After the first copied frame, it finds
+15
View File
@@ -1,6 +1,21 @@
import type { NextConfig } from "next";
const config: NextConfig = {
reactStrictMode: true, poweredByHeader: false,
webpack(config) {
config.module.rules.push({
test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/,
use: [{ loader: require.resolve("./scripts/loaders/xyflow-retained.cjs") }],
});
return config;
},
turbopack: {
rules: {
"*": {
condition: { path: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/ },
loaders: ["./scripts/loaders/xyflow-retained.cjs"],
},
},
},
async headers() {
return [{
source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)",
+19
View File
@@ -0,0 +1,19 @@
// App-only extension for the pinned @xyflow/react 12.12.1 ESM renderer. This
// transforms compiler input; shared node_modules and the headless SDK are untouched.
const version = require('@xyflow/react/package.json').version;
module.exports = function retainVisibility(source) {
if (version !== '12.12.1') throw new Error('Review xyflow retained visibility before upgrading @xyflow/react');
const nodes = 'getNodesInside(s.nodeLookup, { x: 0, y: 0, width: s.width, height: s.height }, s.transform, true).map((node) => node.id)';
const edges = 'isEdgeVisible({\n sourceNode,';
const close = 'transform: s.transform,\n })) {';
if (source.includes('__endoleapRetain') || source.split(nodes).length !== 2 || source.split(edges).length !== 2 || source.split(close).length !== 2) {
throw new Error('Unsupported xyflow visibility implementation; retained editors must not silently lose virtualization');
}
return source
.replace(nodes, `Array.from(new Set([
...${nodes},
...s.nodes.filter((node) => node.__endoleapRetain === true && !node.hidden).map((node) => node.id),
]))`)
.replace(edges, '(edge.__endoleapRetain === true || isEdgeVisible({\n sourceNode,')
.replace(close, 'transform: s.transform,\n }))) {');
};
+25 -15
View File
@@ -11,7 +11,7 @@ import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canv
import { equal } from "@/lib/canvas-core/values";
import {createSceneProjection} from "@/lib/canvas-bindings/scene-projection";
import {createConnectionPaintIndex} from "@/lib/canvas-bindings/connection-paint";
import {nativeRectVisible} from "@/lib/canvas-bindings/visibility";
import {createRetainedProjection} from "./retained-projection";
import {createOverlayIndex} from "@/lib/canvas-bindings/overlays";
import {CanvasOverlays} from "./canvas-overlays";
import {createProjectedPaintIndex} from "@/lib/canvas-bindings/projected-paint";
@@ -73,6 +73,9 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
const [focusedEditor, setFocusedEditor] = useState<HTMLElement | null>(null), focusEpoch = useRef(0);
useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]);
// Removing an active renderer need not dispatch a native blur. Retire only
// that detached input, without clearing a newer focus acquired in the meantime.
useEffect(()=>{if(focusedEditor&&!focusedEditor.isConnected)setFocusedEditor(current=>current===focusedEditor?null:current);});
const [copying, setCopying] = useState(false);
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null);
const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]);
@@ -106,13 +109,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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 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 visibleOverlays=overlaySnapshot.length?(surfaceBounds?overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation):overlaySnapshot.filter(entry=>root.document.elements.get(entry.target.id)?.incarnation===entry.target.incarnation)):[];
const paintedNodes=[...paint.unknown,...paint.query(sceneViewport)];
const nativeNodes=useStore(state=>state.nodes);
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]);
const connectionPaint=useMemo(()=>connectionPaintCache.sync(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');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]);
const connectionPaintVisible=connectionPaint.unknown.length>0||!surfaceWidth||!surfaceHeight||connectionPaint.query({x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}).size>0;
const paintedEdges=[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)];
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
@@ -202,15 +204,23 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
return () => { window.removeEventListener('blur', cancel); window.removeEventListener('keydown', escape); };
}, [engine, viewId]);
const modes = view.modes;
// Visible native owners already survive React Flow culling. Fall back only if
// culling could discard an active editor/lease (including unknown custom owners).
const interactionOwners = new Set(modes.keys());
if (sessions.gesture?.viewId === viewId) for (const target of sessions.gesture.targets) interactionOwners.add(target.id);
if (sessions.connectionGesture?.viewId === viewId) interactionOwners.add(sessions.connectionGesture.target.id);
const focusedNode = focusedEditor?.closest('.react-flow__node')?.getAttribute('data-id');
if (focusedNode) interactionOwners.add(focusedNode);
const retainInteraction = !!focusedEditor && !focusedNode || [...interactionOwners].some(id => !flow.getInternalNode(id) || !nativeVisible(id));
const cullVisible = !overlayVisible && !connectionPaintVisible && !paintVisible && !retainInteraction;
// Retain explicit owners in the native renderer, leaving the full graph in its
// lookup for edges/queries. No synthetic dragging or altered geometry is used.
const retained=new Set<Id>([...modes.keys(),...paintedNodes,...paintedEdges]);
for(const entry of visibleOverlays)retained.add(entry.target.id);
if(sessions.gesture?.viewId===viewId)for(const target of sessions.gesture.targets)retained.add(target.id);
if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id);
const activeEditor=focusedEditor?.isConnected?focusedEditor:null;
const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]');
const focusedNative=activeEditor?.closest<HTMLElement>('.react-flow__node,.react-flow__edge');
const focusedId=focusedOverlay?.dataset.canvasOverlayView===viewId?focusedOverlay.dataset.canvasOverlayTarget:focusedNative?.closest('[data-headless-canvas]')===surface.current?focusedNative?.dataset.id:undefined;
if(focusedId)retained.add(focusedId);
// Retained connections also keep their measured endpoint owners alive.
for(const id of [...retained]){const element=root.document.elements.get(id);if(element?.structure==='connection'){retained.add(element.source.elementId);retained.add(element.target.elementId);}}
const scoped=!(activeEditor&&!focusedId)&&[...retained].every(id=>root.document.elements.has(id));
const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped;
const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]);
const rendered=retainedProjection(projection.nodes,projection.edges,retained);
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
@@ -276,7 +286,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
<CanvasRendererCommandsContext.Provider value={rendererCommands}>
<NodeEditing.Provider value={nodeEditing}>
<EdgeEditing.Provider value={edgeEditing}>
<ReactFlow<ProjectionNode> elevateEdgesOnSelect onlyRenderVisibleElements={cullVisible} nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
<ReactFlow<ProjectionNode> elevateEdgesOnSelect onlyRenderVisibleElements={cullVisible} nodes={rendered.nodes} edges={rendered.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
nodesConnectable={view.tool === 'select'} 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}
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
@@ -0,0 +1,16 @@
/** Adapter-only flags consumed by the app's pinned React Flow compiler extension.
* They never enter the document, history, persistence or renderer business data. */
export function createRetainedProjection(){
const tagged=new WeakMap<object,object>();
let previous:{nodes:readonly object[];edges:readonly object[];ids:Set<string>;result:{nodes:object[];edges:object[]}}|undefined;
return <N extends {id:string},E extends {id:string}>(nodes:N[],edges:E[],ids:ReadonlySet<string>):{nodes:N[];edges:E[]}=>{
if(!ids.size){previous=undefined;return{nodes,edges};}
if(previous?.nodes===nodes&&previous.edges===edges&&previous.ids.size===ids.size&&[...ids].every(id=>previous!.ids.has(id)))return previous.result as {nodes:N[];edges:E[]};
const retain=<T extends {id:string}>(row:T):T=>{
if(!ids.has(row.id))return row;
let value=tagged.get(row);if(!value){value={...row,__endoleapRetain:true};tagged.set(row,value);}return value as T;
};
const result={nodes:nodes.map(retain),edges:edges.map(retain)};
previous={nodes,edges,ids:new Set(ids),result};return result;
};
}
+26 -3
View File
@@ -5,7 +5,7 @@ import {writeFile} from 'node:fs/promises';
import {setTimeout as pause} from 'node:timers/promises';
import {canvasTransform,toWorld} from '../../src/lib/canvas-core/coordinates.ts';
export async function measureRenderer({call,evaluate,documents,root,partition,count,exceptions,reportPath,browserLogs=()=>'',baseline,buildMode='development',buildMs}){
export async function measureRenderer({call,evaluate,documents,root,partition,count,exceptions,reportPath,browserLogs=()=>'',baseline,buildMode='development',bundler='webpack',buildMs}){
const target=2*count+3,started=Date.now(),heapSamples=[];let copying=false,nextHeap=0;
const wait=async(expression,label)=>{const deadline=Date.now()+240000;while(Date.now()<deadline){if(exceptions.length)throw Error(exceptions.join('\n'));if(copying&&process.env.HEADLESS_PERFORMANCE_HEAP==='1'&&Date.now()>=nextHeap){const heap=await call('Runtime.getHeapUsage'),retention=await evaluate('window.__fixtureReceiptRetention');heapSamples.push({wallMs:Date.now()-started,serverElements:documents.get(root)?.elements.size,heap,retention});nextHeap=Date.now()+10000;await writeFile(reportPath+'.heap.json',JSON.stringify({baseline,heapSamples},null,2));}if(await evaluate(expression))return;await pause(100);}throw Error(label);};
let report,duringCopyInput;
@@ -26,9 +26,20 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co
if(count>=100)assert.ok(duringEditing<=beforeEditing+10,'editing one visible card mounted the offscreen canvas');
await call('Input.insertText',{text:'Native editing remains responsive'});
assert.equal(await evaluate('window.__performanceEditor.isConnected&&document.activeElement===window.__performanceEditor&&window.__performanceEditor.value.includes("Native editing remains responsive")'),true,'native input lost its DOM, focus or value');
await evaluate('window.__editViewport=new DOMMatrix(getComputedStyle(document.querySelector(".react-flow__viewport")).transform);true');
await call('Input.imeSetComposition',{text:'保',selectionStart:1,selectionEnd:1});
await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`);
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
assert.equal(await evaluate('window.__performanceEditor.isConnected&&document.activeElement===window.__performanceEditor&&window.__performanceEditor.value.includes("保")'),true,'offscreen pan discarded native IME');
assert.equal(await evaluate('(()=>{const r=window.__performanceEditor.getBoundingClientRect();return r.right<0||r.bottom<0||r.left>innerWidth||r.top>innerHeight;})()'),true,'IME probe did not move its owner offscreen');
const offscreenEditing=await evaluate('document.querySelectorAll(".react-flow__node").length');
if(count>=100)assert.ok(offscreenEditing<=10,'retaining one offscreen editor mounted the complete canvas');
await call('Input.insertText',{text:'保留输入'});
assert.equal(await evaluate('window.__performanceEditor.value.endsWith("保留输入")'),true,'offscreen IME commit lost content');
await evaluate(`(()=>{const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__editViewport;})()`);
await evaluate('document.activeElement.blur();delete window.__performanceEditor;new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
await wait(`[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'native input did not synchronize before copy measurement');
const inputCheck={beforeEditing,duringEditing};
const inputCheck={beforeEditing,duringEditing,offscreenEditing};
console.log('PERF native editing '+JSON.stringify(inputCheck));
const panePoint=await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect();for(let y=rect.y+5;y<rect.bottom;y+=20)for(let x=rect.x+5;x<rect.right;x+=20)if(document.elementFromPoint(x,y)===pane)return{x,y};throw Error('no empty native pane point');})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...panePoint,button:'left',buttons:1,clickCount:1});
@@ -39,6 +50,18 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co
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 wait(`!!document.querySelector('[aria-label="分区名称"]')`,'selected frame did not expose its portal title');
await evaluate(`(()=>{window.__performancePortal=document.querySelector('[aria-label="分区名称"]');window.__performancePortal.focus();window.__performancePortal.select();window.__editViewport=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);})()`);
await call('Input.insertText',{text:'Retained portal title'});
await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`);
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
assert.equal(await evaluate('window.__performancePortal.isConnected&&document.activeElement===window.__performancePortal&&window.__performancePortal.value==="Retained portal title"'),true,'portal title draft was lost during offscreen pan');
inputCheck.offscreenPortal=await evaluate('document.querySelectorAll(".react-flow__node").length');
if(count>=100)assert.ok(inputCheck.offscreenPortal<=10,'one portal draft mounted the complete canvas');
await evaluate('window.__performancePortal.blur();true');
await wait('document.querySelectorAll(".react-flow__node").length===0','released portal retained offscreen owners');
await evaluate(`(()=>{const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__editViewport;delete window.__performancePortal;})()`);
await wait(`document.querySelector('[aria-label="分区名称"]')?.value==='Retained portal title'&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'portal title did not save and remount');
const sourceText=documents.get(root).elements.get('perf-0').data.text;
await evaluate(`(()=>{
const originals=new Set(['perf-source','perf-frame',...Array.from({length:${count}},(_,i)=>'perf-'+i)]),start=performance.now(),tasks=[],gaps=[];
@@ -69,7 +92,7 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co
await evaluate(`(()=>{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={inputCheck,duringCopyInput,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',mode:`Next ${buildMode} webpack; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks`,count,initialReadyMs,buildMs,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};
report={inputCheck,duringCopyInput,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',mode:`Next ${buildMode} ${bundler}; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks`,count,initialReadyMs,buildMs,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={duringCopyInput,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',count,buildMode,buildMs,error:error.message,measurements,serverElements:documents.get(root)?.elements.size,serverCanvases:documents.get(root)?.canvases.size,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);
+22 -7
View File
@@ -4,18 +4,24 @@ 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';
import { mkdtemp, cp, copyFile, symlink, readFile, writeFile, rm } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { mkdtemp, cp, copyFile, symlink, readFile, writeFile, rm, realpath } from 'node:fs/promises';
import { join, resolve, dirname } from 'node:path';
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 bundler=process.argv.includes('--turbopack')?'turbopack':'webpack';
const productionMode=process.argv.includes('--production');if(productionMode&&!process.argv.includes('--performance'))throw Error('--production requires --performance');
const hooksOnly=process.argv.includes('--hooks-only'),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');
if(process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1'&&(!performanceMode||performanceCount<1000))throw Error('concurrent input requires --performance and at least 1000 nodes');
const baseline=process.env.HEADLESS_PERFORMANCE_BASELINE;if(baseline&&!/^[a-f0-9]{7,40}$/.test(baseline))throw Error('invalid diagnostic baseline');
const source = resolve(import.meta.dirname, '../..'), directory = await mkdtemp(join(tmpdir(), 'endoleap-renderer-')), profile = await mkdtemp(join(tmpdir(), 'endoleap-renderer-browser-'));
const source = resolve(import.meta.dirname, '../..');
// Turbopack fences real dependency paths to its workspace root. Keep its isolated
// fixture under the physical dependency workspace, then remove this exact path
// in finally. Webpack fixtures retain their ordinary OS-temp location.
const fixtureRoot=bundler==='turbopack'?dirname(await realpath(join(source,'node_modules'))):tmpdir();
const directory = await mkdtemp(join(fixtureRoot, bundler==='turbopack'?'.headless-renderer-':'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(), assetBytes=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 } });
@@ -132,7 +138,8 @@ try {
const hostFile=join(directory,'src/components/workspace/canvas-runtime-host.tsx'),host=await readFile(hostFile,'utf8');assert.ok(host.includes('extensions=[]'),'host extension installation point changed');await writeFile(hostFile,host.replace('"use client";','"use client";\nimport {fixtureExtension} from "@/components/canvas/renderer-fixture";\nimport {HookChecks} from "@/components/canvas/hooks-fixture";').replace('extensions=[]','extensions=[fixtureExtension]').replace('{children}</Renderers.Provider>','{children}<HookChecks/></Renderers.Provider>'));
}
for (const file of ['package.json', 'tsconfig.json', 'postcss.config.mjs']) await copyFile(join(source, file), join(directory, file));
for (const file of ['package.json', 'tsconfig.json', 'postcss.config.mjs', 'next.config.ts']) await copyFile(join(source, file), join(directory, file));
await cp(join(source,'scripts/loaders'),join(directory,'scripts/loaders'),{recursive:true});
await symlink(join(source, 'node_modules'), join(directory, 'node_modules'));
const launchNext=args=>{
const child=spawn(process.execPath,[join(source,'node_modules/next/dist/bin/next'),...args],{cwd:directory,detached:true,env:{...process.env,NEXT_TELEMETRY_DISABLED:'1',FOUNDRY_API_ORIGIN:`http://127.0.0.1:${upstream.address().port}`,NEXT_PUBLIC_FOUNDRY_PROJECT:'endoleap'},stdio:['ignore','pipe','pipe']});
@@ -140,11 +147,11 @@ try {
};
let buildMs;
if(productionMode){
console.log('BUILD production fixture');const started=Date.now();next=launchNext(['build','--webpack']);
console.log('BUILD production fixture');const started=Date.now();next=launchNext(['build',`--${bundler}`]);
await new Promise((resolve,reject)=>{next.once('error',reject);next.once('exit',(code,signal)=>code===0?resolve():reject(Error(`production fixture build failed (${code??signal})\n${logs}`)));});
buildMs=Date.now()-started;console.log(`BUILD complete ${buildMs} ms`);
}
next=launchNext([productionMode?'start':'dev',...productionMode?[]:['--webpack'],'--hostname','127.0.0.1','--port',String(port)]);
next=launchNext([productionMode?'start':'dev',...productionMode?[]:[`--${bundler}`],'--hostname','127.0.0.1','--port',String(port)]);
for (let i = 0; i < 120; i++) { if (next.exitCode !== null) throw new Error(logs); try { const r = await fetch(`http://127.0.0.1:${port}/api/foundry/profile`, { signal: AbortSignal.timeout(1000) }); if (r.ok) break; } catch {} await pause(250); }
let browserLogs='';
chrome = spawn(process.env.CHROME_BIN ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ['--headless=new', '--no-first-run', '--disable-background-networking', '--disable-component-update', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore','ignore','pipe'] });
@@ -162,7 +169,7 @@ try {
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,browserLogs:()=>browserLogs,baseline,buildMode:productionMode?'production':'development',buildMs,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')});
await measureRenderer({call,evaluate,documents,root,partition,count:performanceCount,exceptions,browserLogs:()=>browserLogs,baseline,buildMode:productionMode?'production':'development',bundler,buildMs,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')});
}else if(hooksOnly){await until('typeof window.__runCanvasHookChecks==="function"','hook fixture did not mount');const reports=await evaluate('window.__runCanvasHookChecks()');for(const report of reports)console.log('PASS React hooks: '+report);
}else{
await until('!!document.querySelector("[data-headless-canvas][data-model-loading] .react-flow__node[data-id=a]")','first editable model prefix did not open before the gated tail');
@@ -403,6 +410,14 @@ try {
assert.equal(await evaluate(`window.pinnedText.isConnected&&document.activeElement===window.pinnedText&&window.pinnedText.value.includes('保')`),true,'viewport culling interrupted active IME');await call('Input.insertText',{text:'保留输入'});for(let i=0;i<100&&!documents.get(root).elements.get('b').data.text.endsWith('保留输入');i++)await pause(20);assert.ok(documents.get(root).elements.get('b').data.text.endsWith('保留输入'));
await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await until(`document.querySelectorAll('.react-flow__node').length===0`,'viewport culling did not resume after IME editing');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'edited node did not remount');assert.ok(await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value.endsWith('保留输入')`));
console.log('PASS native viewport culling retains unsaved portalled frame titles and active IME across pan, then resumes and remounts saved content');
await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'retiring editor did not focus');
assert.equal(await evaluate(`(()=>{window.__retiredEditor=document.activeElement;const engine=window.__fixtureActualEngine,element=engine.getSnapshot().document.elements.get('b');return engine.editor.dispatch({type:'element.remove',target:{id:element.id,incarnation:element.incarnation}}).status;})()`),'applied');
await until(`!document.querySelector('.react-flow__node[data-id=b]')`,'focused node did not retire');assert.equal(await evaluate('window.__retiredEditor.isConnected'),false);
await evaluate(panFar);await until(`document.querySelectorAll('.react-flow__node').length===0`,'detached focus left the graph permanently retained');
await click('撤销');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'retired editor did not restore through history');assert.ok(await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value.endsWith('保留输入')`));
console.log('PASS deleting a focused renderer releases detached ownership without requiring blur and history restores its content');
// Read a fresh project whose known envelope contains opaque component/version payloads.
const opaqueRoot=id(800),opaqueChild=id(801),opaqueU={...node('opaque-u',opaqueRoot,50),componentId:'plugin:future',dataVersion:9,data:{title:'Opaque source',ports:{find:'future field'},presentation:{unitScale:'future'},assetId:'opaque-asset',__headlessFallback:{handles:'untrusted'},opaque:{preserve:true}}},opaqueV={...node('opaque-v',opaqueRoot,400),dataVersion:99,data:{title:'Future card',text:{preserve:true},ports:'future field'}},opaqueFrame={...node('opaque-frame',opaqueRoot,100),componentId:'frame',dataVersion:99,geometry:{x:100,y:450,width:350,height:220,rotation:0},data:{title:'Future partition',ports:{preserve:true},presentation:{unitScale:'future'}}},opaqueKnown=node('opaque-known',opaqueRoot,800),opaqueMember=node('opaque-member',opaqueChild,20),opaqueEdge={id:'opaque-edge',incarnation:1,structure:'connection',componentId:'plugin:connection',dataVersion:9,ownerCanvasId:opaqueRoot,source:{elementId:opaqueU.id,portId:'custom-out'},target:{elementId:opaqueV.id,portId:'custom-in'},path:{type:'future',preserve:true},data:{appearance:{color:{preserve:true}}}},regularEdge={...opaqueEdge,id:'opaque-regular',componentId:'connection',dataVersion:1,target:{elementId:opaqueKnown.id,portId:'left'},path:null,data:{}},partitionEdge={...regularEdge,id:'opaque-partition',source:{elementId:opaqueKnown.id,portId:'bottom'},target:{elementId:opaqueMember.id}};
@@ -0,0 +1,32 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {createRequire} from 'node:module';
import {runInNewContext} from 'node:vm';
import {createRetainedProjection} from '../../src/components/canvas/retained-projection.ts';
const require=createRequire(import.meta.url),transform=require('../../scripts/loaders/xyflow-retained.cjs');
test('retention keeps the complete native graph, preserves business data and releases flags after the owner ends',()=>{
const project=createRetainedProjection(),a=Object.freeze({id:'a',dragging:false,data:Object.freeze({text:'draft'}),position:{x:2000,y:0}}),b={id:'b'},edge={id:'edge',source:'a',target:'b'},nodes=[a,b],edges=[edge];
const retained=project(nodes,edges,new Set(['a','edge']));
assert.equal(retained.nodes.length,2);assert.equal(retained.edges.length,1);assert.equal(retained.nodes[0].__endoleapRetain,true);assert.equal(retained.edges[0].__endoleapRetain,true);
assert.equal(retained.nodes[1],b);assert.equal(retained.nodes[0].data,a.data);assert.equal(retained.nodes[0].position,a.position);assert.equal(retained.nodes[0].dragging,false);assert.equal(a.__endoleapRetain,undefined);
assert.equal(project(nodes,edges,new Set(['a','edge'])),retained);
const released=project(nodes,edges,new Set());assert.equal(released.nodes,nodes);assert.equal(released.edges,edges);
const restored={...a,data:{text:'new incarnation'}},next=project([restored,b],edges,new Set(['a']));assert.notEqual(next.nodes[0],retained.nodes[0]);assert.equal(next.nodes[0].data,restored.data);
});
test('pinned xyflow compiler extension retains only explicit offscreen owners and keeps native visibility',async()=>{
for(const file of ['index.js','index.mjs']){
const source=await readFile(new URL('../../node_modules/@xyflow/react/dist/esm/'+file,import.meta.url),'utf8'),code=transform(source);
const start=code.indexOf('const selector$c = '),end=code.indexOf('\n};',start)+3;
const select=runInNewContext(code.slice(start,end)+'; selector$c', {getNodesInside:()=>[{id:'visible'}]});
const nodes=[{id:'visible'},{id:'pinned',__endoleapRetain:true},{id:'other'},{id:'hidden',__endoleapRetain:true,hidden:true}],state={nodes,nodeLookup:new Map(nodes.map(node=>[node.id,node])),width:100,height:100,transform:[0,0,1]};
assert.deepEqual([...select(true)(state)],['visible','pinned']);assert.deepEqual([...select(false)(state)],nodes.map(node=>node.id));assert.equal(state.nodeLookup.size,4);
const edgeStart=code.indexOf('function useVisibleEdgeIds('),edgeEnd=code.indexOf('\n}',edgeStart)+2;
const edges=[{id:'near',source:'visible',target:'other'},{id:'editing',source:'pinned',target:'other',__endoleapRetain:true},{id:'offscreen',source:'other',target:'pinned'}];
const result=runInNewContext(code.slice(edgeStart,edgeEnd)+'; useVisibleEdgeIds(true)',{useCallback:fn=>fn,useStore:fn=>fn({...state,edges}),shallow:()=>{},isEdgeVisible:({sourceNode})=>sourceNode.id==='visible'});
assert.deepEqual([...result],['near','editing']);
assert.throws(()=>transform(source.replace('const selector$c =','const changedSelector =').replace('getNodesInside(s.nodeLookup,','changedVisibility(s.nodeLookup,')),/Unsupported xyflow/);
}
});