Skip empty native selection listener scans

This commit is contained in:
2026-11-11 03:37:03 +08:00
parent 0ce73a6854
commit 57dab91a9c
5 changed files with 80 additions and 4 deletions
+12 -2
View File
@@ -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
+6 -2
View File
@@ -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;');
};
+27
View File
@@ -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('<Background variant=','<NativeSelectionFixture/><Background variant='));
// Count actual scene rebuilds in the isolated fixture copy; production has no diagnostic global.
const sceneFile=join(directory,'src/lib/canvas-bindings/scene-projection.ts'),sceneSource=await readFile(sceneFile,'utf8');assert.ok(sceneSource.includes('const result=patched??projectCanvas('));await writeFile(sceneFile,sceneSource.replace('const result=patched??projectCanvas(',`const host=globalThis as typeof globalThis&{__fixtureSceneBuilds?:number};if(!patched)host.__fixtureSceneBuilds=(host.__fixtureSceneBuilds??0)+1;const result=patched??projectCanvas(`));
const projectionFile=join(directory,'src/lib/canvas-bindings/projection.ts'),projectionSource=await readFile(projectionFile,'utf8');assert.ok(projectionSource.includes(':ProjectionNode{'));await writeFile(projectionFile,projectionSource.replace(':ProjectionNode{',`:ProjectionNode{const host=globalThis as typeof globalThis&{__fixtureProjectedNodes?:Record<string,number>};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()`);
@@ -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);
}
});
@@ -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?<Listener/>:null;
}