diff --git a/headless/README.md b/headless/README.md index 5b04c97..12e871a 100644 --- a/headless/README.md +++ b/headless/README.md @@ -1018,13 +1018,23 @@ layer and its endpoints, while unrelated nodes remain culled. This can still retain many endpoints/edges; it is not a constant-cost or complete geometry proof. 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 +`scripts/loaders/xyflow-retained.cjs` extends its two ESM visibility selectors and +fixes the empty selection-listener guard 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 pinned dependency treats an empty `onSelectionChangeHandlers` array as +truthy. The app guard checks its length, so an unused SelectionListenerInner no +longer scans both native lookups on every store update. A callback prop or a real +`useOnSelectionChange` subscriber still mounts the original listener. This does +not add a second selection cache. The isolated page fixture checks notification, +unsubscribe/re-subscribe and scan counts; counters and the fixture observer are +excluded from performance builds and the production app. The VM regression also +checks callback-prop delivery against both installed ESM entry files. + 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 diff --git a/scripts/loaders/xyflow-retained.cjs b/scripts/loaders/xyflow-retained.cjs index 10ad5a6..7c35710 100644 --- a/scripts/loaders/xyflow-retained.cjs +++ b/scripts/loaders/xyflow-retained.cjs @@ -5,8 +5,9 @@ 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 selection = 'const changeSelector = (s) => !!s.onSelectionChangeHandlers;'; 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) { + if (source.includes('__endoleapRetain') || source.split(nodes).length !== 2 || source.split(edges).length !== 2 || source.split(close).length !== 2 || source.split(selection).length !== 2) { throw new Error('Unsupported xyflow visibility implementation; retained editors must not silently lose virtualization'); } return source @@ -15,5 +16,8 @@ module.exports = function retainVisibility(source) { ...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 }))) {'); + .replace(close, 'transform: s.transform,\n }))) {') + // An empty handler array is truthy; do not mount the full-graph selector + // when neither the prop nor a useOnSelectionChange subscriber needs it. + .replace(selection, 'const changeSelector = (s) => !!s.onSelectionChangeHandlers?.length;'); }; diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 69c6747..a3f4039 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -126,6 +126,10 @@ try { if(!performanceMode){ await copyFile(join(source,'tests/headless/renderer-extension.tsx'),join(directory,'src/components/canvas/renderer-fixture.tsx')); await copyFile(join(source,'tests/headless/react-hooks-fixture.tsx'),join(directory,'src/components/canvas/hooks-fixture.tsx')); + await copyFile(join(source,'tests/headless/selection-listener-fixture.tsx'),join(directory,'src/components/canvas/selection-listener-fixture.tsx')); + const canvasFile=join(directory,'src/components/canvas/headless-graph-canvas.tsx'),canvasSource=await readFile(canvasFile,'utf8'); + await writeFile(canvasFile,canvasSource.replace('"use client";','"use client";\nimport {NativeSelectionFixture} from "./selection-listener-fixture";').replace('};host.__fixtureProjectedNodes??={};host.__fixtureProjectedNodes[element.id]=(host.__fixtureProjectedNodes[element.id]??0)+1;`)); @@ -140,6 +144,15 @@ try { 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}); + if(!performanceMode){ + const loaderFile=join(directory,'scripts/loaders/xyflow-retained.cjs'); + const instrument=` +const nativeTransform=module.exports; +module.exports=function(source){return nativeTransform.call(this,source).replace('const selector$l = (s) => {','const selector$l = (s) => { globalThis.__fixtureSelectionScans=(globalThis.__fixtureSelectionScans??0)+1;');}; +`; + await writeFile(loaderFile,(await readFile(loaderFile,'utf8'))+instrument); + } + 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']}); @@ -192,6 +205,20 @@ try { 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 evaluate(`document.querySelector('[aria-label="撤销"]').click()`);for(let i=0;i<100&&documents.get(root).elements.get('a').data.text!=='Editable content';i++)await pause(20);assert.equal(documents.get(root).elements.get('a').data.text,'Editable content'); console.log('PASS actual GraphPage edits the first prefix before the gated tail, keeps its active textarea/history/viewport and sends only after durable completion'); + await until('!!window.__fixtureNativeSelection','native selection fixture did not mount'); + await evaluate('window.__priorNativeSelection=window.__fixtureNativeSelection.selected();window.__fixtureNativeSelection.enable(true);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + await evaluate('window.__fixtureNativeSelection.select(["b"]);true');await until('window.__fixtureNativeSelectionEvents.at(-1)?.nodes.join() === "b"','native selection hook missed its selected node'); + await evaluate('window.__fixtureNativeSelection.enable(false);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + const unsubscribedSelectionEvents=await evaluate('window.__fixtureNativeSelectionEvents.length'); + const subscribedSelectionScans=await evaluate('window.__fixtureSelectionScans??0');assert.ok(subscribedSelectionScans>0,'native scan instrumentation did not observe the active hook'); + await evaluate('window.__fixtureNativeSelection.select(["a"]);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');assert.equal(await evaluate('window.__fixtureNativeSelectionEvents.length'),unsubscribedSelectionEvents,'native selection hook fired after unsubscription');assert.equal(await evaluate('window.__fixtureSelectionScans??0'),subscribedSelectionScans,'unsubscribed native selection still scanned the graph'); + await evaluate('window.__fixtureNativeSelection.enable(true);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + await evaluate('window.__fixtureNativeSelection.select(["b"]);true');await until('window.__fixtureNativeSelectionEvents.length>'+unsubscribedSelectionEvents+'&&window.__fixtureNativeSelectionEvents.at(-1)?.nodes.join() === "b"','native selection hook did not resume after re-subscription'); + await evaluate('window.__fixtureNativeSelection.enable(false);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + await evaluate('window.__fixtureNativeSelection.select(window.__priorNativeSelection);delete window.__priorNativeSelection;new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + assert.ok(await evaluate('window.__fixtureSelectionScans')>subscribedSelectionScans,'re-subscribed native selection stopped scanning'); + console.log('PASS native selection hooks receive selected nodes, stop after unsubscription and resume after re-subscription'); + assert.equal(calls.some(call => call.path.endsWith('/canvas')), false, 'canonical project read the legacy snapshot'); assert.equal(await evaluate('!!document.querySelector("[aria-label=画布快捷操作]") && !document.querySelector("[aria-label=展开侧边栏]")'), true); const click = label => evaluate(`document.querySelector('[aria-label=${JSON.stringify(label)}]').click()`); diff --git a/tests/headless/retained-projection.test.mjs b/tests/headless/retained-projection.test.mjs index 894134c..32f9583 100644 --- a/tests/headless/retained-projection.test.mjs +++ b/tests/headless/retained-projection.test.mjs @@ -30,3 +30,18 @@ test('pinned xyflow compiler extension retains only explicit offscreen owners an assert.throws(()=>transform(source.replace('const selector$c =','const changedSelector =').replace('getNodesInside(s.nodeLookup,','changedVisibility(s.nodeLookup,')),/Unsupported xyflow/); } }); + + +test('native selection scanning stays dormant without listeners and still serves hooks and the callback prop',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$l = '),end=code.indexOf('\n}',code.indexOf('function SelectionListener({',start))+2; + let scans=0,effects=0;const callbacks=[],userNode={id:'selected',position:{x:0,y:0},data:{title:'original payload'},selected:true},node={...userNode,internals:{userNode}},edge={id:'edge',source:'selected',target:'other',selected:true}; + const lookup=value=>({*[Symbol.iterator](){scans++;yield[value.id,value];}}),state={nodeLookup:lookup(node),edgeLookup:lookup(edge),onSelectionChangeHandlers:[]}; + const render=runInNewContext(code.slice(start,end)+'; SelectionListener',{useStore:selector=>selector(state),useStoreApi:()=>({getState:()=>state}),useEffect:callback=>{effects++;callback();},jsx:(Component,props)=>Component(props),shallow:(a,b)=>JSON.stringify(a)===JSON.stringify(b)}); + assert.equal(render({}),null);assert.equal(scans,0);assert.equal(effects,0); + state.onSelectionChangeHandlers=[value=>callbacks.push(value)];render({});assert.equal(scans,2);assert.deepEqual([...callbacks[0].nodes].map(node=>node.id),['selected']);assert.deepEqual([...callbacks[0].edges].map(edge=>edge.id),['edge']);assert.equal(callbacks[0].nodes[0],userNode);assert.equal(callbacks[0].edges[0],edge); + state.onSelectionChangeHandlers=[];render({});assert.equal(scans,2,'unsubscribed hook kept scanning'); + let prop;render({onSelectionChange:value=>{prop=value;}});assert.equal(scans,4);assert.deepEqual([...prop.nodes].map(node=>node.id),['selected']);assert.equal(prop.nodes[0],userNode); + } +}); diff --git a/tests/headless/selection-listener-fixture.tsx b/tests/headless/selection-listener-fixture.tsx new file mode 100644 index 0000000..5ba6ed0 --- /dev/null +++ b/tests/headless/selection-listener-fixture.tsx @@ -0,0 +1,20 @@ +"use client"; +import {useCallback,useEffect,useState} from 'react'; +import {useOnSelectionChange,useStoreApi} from '@xyflow/react'; +type Event={nodes:string[];edges:string[]}; +type Control={enable(value:boolean):void;select(ids:string[]):void;selected():string[]}; +declare global{interface Window{__fixtureNativeSelection?:Control;__fixtureNativeSelectionEvents?:Event[];}} +function Listener(){ + const onChange=useCallback((value:{nodes:{id:string}[];edges:{id:string}[]})=>{window.__fixtureNativeSelectionEvents?.push({nodes:value.nodes.map(node=>node.id),edges:value.edges.map(edge=>edge.id)});},[]); + useOnSelectionChange({onChange});return null; +} +/** Isolated page fixture only; no observer is installed in performance builds. */ +export function NativeSelectionFixture(){ + const store=useStoreApi(),[enabled,setEnabled]=useState(false); + useEffect(()=>{ + const control:Control={enable:setEnabled,select(ids){store.getState().resetSelectedElements();store.getState().addSelectedNodes(ids);},selected(){return [...store.getState().nodeLookup.values()].filter(node=>node.selected).map(node=>node.id);}}; + window.__fixtureNativeSelection=control;window.__fixtureNativeSelectionEvents??=[]; + return()=>{if(window.__fixtureNativeSelection===control)delete window.__fixtureNativeSelection;}; + },[store]); + return enabled?:null; +}