Retain explicit canvas owners without disabling viewport culling
This commit is contained in:
+26
-10
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 }))) {');
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user