diff --git a/headless/README.md b/headless/README.md index 39be290..25717ec 100644 --- a/headless/README.md +++ b/headless/README.md @@ -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 diff --git a/next.config.ts b/next.config.ts index e69da33..45f15d6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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)", diff --git a/scripts/loaders/xyflow-retained.cjs b/scripts/loaders/xyflow-retained.cjs new file mode 100644 index 0000000..10ad5a6 --- /dev/null +++ b/scripts/loaders/xyflow-retained.cjs @@ -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 }))) {'); +}; diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 1f09779..b6fe168 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -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(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([...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('[data-canvas-overlay-target]'); + const focusedNative=activeEditor?.closest('.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 - elevateEdgesOnSelect onlyRenderVisibleElements={cullVisible} nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose} + 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)} diff --git a/src/components/canvas/retained-projection.ts b/src/components/canvas/retained-projection.ts new file mode 100644 index 0000000..115772a --- /dev/null +++ b/src/components/canvas/retained-projection.ts @@ -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(); + let previous:{nodes:readonly object[];edges:readonly object[];ids:Set;result:{nodes:object[];edges:object[]}}|undefined; + return (nodes:N[],edges:E[],ids:ReadonlySet):{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=(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; + }; +} diff --git a/tests/headless/renderer-performance.mjs b/tests/headless/renderer-performance.mjs index 912609e..d4d6d65 100644 --- a/tests/headless/renderer-performance.mjs +++ b/tests/headless/renderer-performance.mjs @@ -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()=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;yrequestAnimationFrame(()=>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); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 8c2a133..564133d 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -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}','{children}')); } - 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}}; diff --git a/tests/headless/retained-projection.test.mjs b/tests/headless/retained-projection.test.mjs new file mode 100644 index 0000000..894134c --- /dev/null +++ b/tests/headless/retained-projection.test.mjs @@ -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/); + } +});