From 19bfbc772dfae0824f9b091e593707b71ba9800c Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 17:30:41 +0800 Subject: [PATCH] docs(tests): explain projection cache invalidation and frame notifications --- tests/headless/connection-paint.test.mjs | 2 ++ tests/headless/frame-queue.test.mjs | 2 ++ tests/headless/geometry-cache.test.mjs | 2 ++ tests/headless/gesture-placement.test.mjs | 1 + tests/headless/paint-index.test.mjs | 2 ++ tests/headless/projection-cache.test.mjs | 2 ++ tests/headless/retained-projection.test.mjs | 2 ++ 7 files changed, 13 insertions(+) diff --git a/tests/headless/connection-paint.test.mjs b/tests/headless/connection-paint.test.mjs index 237b61b..cbc460f 100644 --- a/tests/headless/connection-paint.test.mjs +++ b/tests/headless/connection-paint.test.mjs @@ -12,6 +12,7 @@ const registry=createRegistry(builtinComponents()),node=(id,x)=>({id,incarnation /** 每次新建等价文档/投影,检验缓存不能只靠输入引用相同判断可复用。 */ const draw=(edges=[edge()])=>projectCanvas(createDocument('p',[{id:'p',parent:null}],[node('a',0),node('b',500),...edges]),'p',registry,new Set(),()=>undefined); // 控制点到y=-500,10描边使保守范围扩5;查询端点之间屏幕外弯曲区域仍能找到edge,节点y保持500。 +// bounds 是控制多边形加半线宽的保守范围,并非曲线实际极值矩形;命中此索引只表明需要保留绘制候选。 test('bowed cubic paint is indexed outside endpoint rectangles including stroke while document bounds remain unchanged',()=>{const projection=draw(),result=projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:100,y:-500},{x:500,y:-500},{x:500,y:550}]],strokeWidth:()=>10});assert.deepEqual(result.unknown,[]);assert.deepEqual(result.regions[0].bounds,{x:95,y:-505,width:410,height:1060});const index=createPaintIndex();index.sync(result.regions);assert.ok(index.query({x:250,y:0,width:10,height:10}).has('edge'));assert.equal(projection.document.elements.get('a').geometry.y,500);}); // 直线全被端点范围覆盖时无额外区域;缺/NaN锚点、非法段和无限线宽不能被当成无需绘制。 test('straight contained paint needs no overflow; missing anchors and invalid geometry stay unknown',()=>{const projection=draw();assert.equal(projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:200,y:550},{x:400,y:550},{x:500,y:550}]],strokeWidth:()=>2}).regions.length,0);for(const services of [{anchor:()=>undefined},{anchor:()=>({point:{x:NaN,y:0},normal:{x:1,y:0}})},{edgeGeometry:()=>[[{x:NaN,y:0}]],strokeWidth:()=>1},{strokeWidth:()=>Infinity}])assert.deepEqual(projectedConnectionPaintOverflow(projection,registry,services).unknown,['edge']);}); @@ -33,6 +34,7 @@ test('an empty connection projection does not enumerate unrelated nodes',()=>{ // 重复ID按后者解析锚点,原地替换投影行也读新位置;缺b则两边unknown,较小边预算仍正确报告。 test('endpoint filtering preserves duplicate IDs, mutable replacement, missing targets and edge budgets',()=>{ const projection=draw([edge('one'),edge('two')]),last={...projection.nodes[0],position:{x:900,y:500}},positions=[]; + // 同 ID 的投影行故意重复,检查端点查找采用最后一行;这不是构造允许重复身份的持久文档。 projection.nodes.push(last);const services={anchor:(node,_handle,end)=>{positions.push(node.position.x);return{point:{x:node.position.x,y:550},normal:{x:end==='source'?1:-1,y:0}};}}; assert.deepEqual(projectedConnectionPaintOverflow(projection,registry,services,1).unknown,['paint_budget']);assert.deepEqual(positions,[900,500]); projection.nodes[projection.nodes.length-1]={...last,position:{x:1200,y:500}};positions.length=0;projectedConnectionPaintOverflow(projection,registry,services,1);assert.deepEqual(positions,[1200,500]); diff --git a/tests/headless/frame-queue.test.mjs b/tests/headless/frame-queue.test.mjs index 82b47c0..cf266a8 100644 --- a/tests/headless/frame-queue.test.mjs +++ b/tests/headless/frame-queue.test.mjs @@ -18,6 +18,7 @@ test('session presentation coalesces preview bursts but live reads and final can test('root presentation batches a burst while commands, history and authoritative reads advance immediately',async()=>{ const {createDocument,createEditor,createRegistry,builtinComponents}=await import('../../src/lib/canvas-core/index.ts');let id=0; const editor=createEditor({document:createDocument('p',[{id:'canvas',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'canvas',componentId:'text',dataVersion:1,structure:'node',data:{title:'before',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}]),registry:createRegistry(builtinComponents()),createId:()=>`op-${++id}`}),c=clock(),commits=[],source={getSnapshot:editor.getSnapshot,subscribe:notify=>editor.subscribe((commit,next)=>{commits.push(next.rootRevision);notify();})},framed=createFrameSource(source,c),painted=[],stop=framed.subscribe(()=>painted.push(framed.getSnapshot())); + // 三次命令同步推进权威根与历史;c.step 前没有呈现回调,但 getSnapshot 仍必须立即读到 third。 for(const title of ['first','second','third'])assert.equal(editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title}}).status,'applied'); assert.equal(commits.length,3);assert.equal(painted.length,0);assert.equal(c.size,1);assert.strictEqual(framed.getSnapshot(),editor.getSnapshot());assert.equal(framed.getSnapshot().document.elements.get('a').data.title,'third');assert.equal(editor.getSnapshot().history.undo.length,3);c.step();assert.equal(painted.length,1);assert.strictEqual(painted[0],editor.getSnapshot());stop();editor.dispose(); }); @@ -33,6 +34,7 @@ test('eager failed subscriptions cancel presentation work and reentrant changes }); // 后台值1尚待帧时输入值2以immediate通知,直接发布2并取消旧帧;随后值3仍按普通帧更新。 +// listener(true) 只让订阅通知立即执行,不另建文档提交;待帧取消后 c.step 不能再次通知旧值。 test('an immediate input invalidation publishes the newest Root and consumes any pending background frame',()=>{ const c=clock();let value=0,listener;const source={getSnapshot:()=>value,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>seen.push(framed.getSnapshot()));value=1;listener();assert.equal(c.size,1);value=2;listener(true);assert.deepEqual(seen,[2]);assert.equal(c.size,0);c.step();assert.deepEqual(seen,[2]);value=3;listener();c.step();assert.deepEqual(seen,[2,3]);stop(); }); diff --git a/tests/headless/geometry-cache.test.mjs b/tests/headless/geometry-cache.test.mjs index 9e3ba8e..55d11dc 100644 --- a/tests/headless/geometry-cache.test.mjs +++ b/tests/headless/geometry-cache.test.mjs @@ -15,8 +15,10 @@ const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{ca // 每次draw与直接投影深比较;标题、选择与元数据不增计算,A局部变化只重算自身,F移动再影响后代及路径。 test('indexed renderer projection preserves legacy output and avoids unrelated geometry work on title, selection and ACK',()=>{ let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`,runtime:0}),cache=createGeometryProjectionCache(),owner={},selected=new Set(),draw=()=>{const doc=editor.getSnapshot().document,actual=projectCanvas(doc,'P',registry,selected,()=>undefined,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});assert.deepEqual(actual,projectCanvas(doc,'P',registry,selected,()=>undefined));return actual;}; + // 深比较先证明投影内容一致,再用累计 computed 计数观察额外工作;缓存条目数与累计计算数是不同指标。 draw();const before=cache.stats();assert.deepEqual({nodes:before.nodesComputed,children:before.childrenComputed,edges:before.edgesComputed},{nodes:4,children:1,edges:2});editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'only title'}});draw();assert.deepEqual(cache.stats(),before);selected.add('B');draw();assert.deepEqual(cache.stats(),before);editor.updateRuntime(()=>1);draw();assert.deepEqual(cache.stats(),before); editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:300,rotation:25}});draw();assert.equal(cache.stats().nodesComputed,before.nodesComputed+1);assert.equal(cache.stats().childrenComputed,before.childrenComputed);assert.equal(cache.stats().edgesComputed,before.edgesComputed); + // 移动 F 会改变自身及 A/B 的投影坐标,也改变子图变换和归上级的 in 曲线路径。 editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});draw();assert.equal(cache.stats().nodesComputed,before.nodesComputed+4);assert.equal(cache.stats().childrenComputed,before.childrenComputed+1);assert.equal(cache.stats().edgesComputed,before.edgesComputed+1); }); diff --git a/tests/headless/gesture-placement.test.mjs b/tests/headless/gesture-placement.test.mjs index 039352c..d4dd4aa 100644 --- a/tests/headless/gesture-placement.test.mjs +++ b/tests/headless/gesture-placement.test.mjs @@ -22,6 +22,7 @@ test('moving a path owner ancestor cancels its connection preview',()=>{const{ed // 分别改contentScale/unitScale/collapsed要求坐标失效;仅标签/标题或无关C几何改变则保留同一gesture对象。 test('scale and collapse cancel active descendant gestures but presentation labels do not',()=>{for(const presentation of [{contentScale:2},{unitScale:2},{collapsed:true}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);sessions.update('v',{id:'B',incarnation:1},{x:50});editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation}});assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'coordinate_context_changed');}const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'another title',presentation:{labelMode:'hidden',contentScale:1,unitScale:1}}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);editor.dispatch({type:'element.geometry',target:{id:'C',incarnation:1},patch:{x:80}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);}); // child-view以child为坐标根,上方F移动不影响本地B预览,commit仍可合法写x=50。 +// 与根视图用例使用同一树,区别只有打开的视图根;由此隔离“上级元素变化”与“当前坐标上下文变化”。 test('ancestor changes above an independently opened view do not alter its local coordinate space',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);sessions.update('child-view',{id:'B',incarnation:1},{x:50});const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);assert.equal(sessions.commit('child-view').status,'applied');assert.equal(editor.getSnapshot().document.elements.get('B').geometry.x,50);}); // 祖先生命周期、未来版本或同几何换owner都改变上下文;即使像素位置看似一致,也必须取消旧手势。 test('ancestor restore, future version and equal-geometry reparent invalidate the old context',()=>{for(const patch of [{incarnation:2},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='F'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);}const{editor,sessions}=fixture();editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('H','root','frame'),geometry:{...node('H').geometry,x:-24,y:-36}}});tx.dispatch({type:'canvas.create',canvas:{id:'sibling',parent:{canvasId:'root',entryElementId:'H'}}});});sessions.begin('v',[{id:'B',incarnation:1}]);assert.equal(editor.dispatch({type:'element.move',target:{id:'F',incarnation:1},canvasId:'sibling',geometry:node('F').geometry}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);}); diff --git a/tests/headless/paint-index.test.mjs b/tests/headless/paint-index.test.mjs index 24d0375..6af876b 100644 --- a/tests/headless/paint-index.test.mjs +++ b/tests/headless/paint-index.test.mjs @@ -18,6 +18,7 @@ import {createProjectedPaintIndex} from '../../src/lib/canvas-bindings/projected // 同投影复用不增computed,坐标变换变化重算,回原变换仍需重算;节点移除清条目。 test('view paint cache reuses unchanged records, invalidates transform/identity and drops removed rows',()=>{const registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:45},data:{title:'a',text:''}},document=createDocument('p',[{id:'p',parent:null}],[node]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();cache.sync(projection,registry);assert.equal(cache.stats().computed,1);cache.sync({...projection},registry);assert.equal(cache.stats().computed,1);const transforms=new Map(projection.transforms),t=transforms.get('p');transforms.set('p',{...t,x:t.x+100});cache.sync({...projection,transforms},registry);assert.equal(cache.stats().computed,2);cache.sync(projection,registry);assert.equal(cache.stats().computed,3);const cleared=cache.sync({...projection,nodes:[]},registry);assert.equal(cache.stats().entries,0);assert.equal(cleared.query({x:-100,y:-100,width:500,height:500}).size,0);cache.clear();}); // 仅声明radius依赖,深相等ACK/改title不重算;radius或incarnation改变才失效,计数同时覆盖上一轮差异还原。 +// 最后一次以原 nodes 为底表:既把 n0.radius 从 25 还原为 10,又升级 n1.incarnation,所以计数从 41 增至 43。 test('paint invalidation follows declared data dependencies instead of ACK record identities or unrelated titles',()=>{let calls=0;const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,dataDependencies:['radius'],bounds({element,geometry:g}){calls++;return{x:g.x-element.data.radius,y:g.y,width:g.width+2*element.data.radius,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),nodes=Array.from({length:40},(_,i)=>({id:'n'+i,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:i*200,y:0,width:100,height:100,rotation:0},data:{title:'n'+i,text:'',radius:10}})),cache=createProjectedPaintIndex(),sync=records=>cache.sync(projectCanvas(createDocument('p',[{id:'p',parent:null}],records),'p',registry,new Set(),()=>undefined),registry);sync(nodes);assert.equal(calls,40);sync(structuredClone(nodes));assert.equal(calls,40);sync(nodes.map((n,i)=>i===0?{...n,data:{...n.data,title:'only title'}}:n));assert.equal(calls,40);sync(nodes.map((n,i)=>i===0?{...n,data:{...n.data,radius:25}}:n));assert.equal(calls,41);sync(nodes.map((n,i)=>i===1?{...n,incarnation:2}:n));assert.equal(calls,43);}); // 1000节点反复同步不重复捕获依赖;单节点位置改动捕获1项,全局变换改动捕获全部。 @@ -26,6 +27,7 @@ test('unchanged paint rows retain their captured dependencies across metadata an projection.nodes[0].position.x+=500;cache.sync(projection,registry);assert.equal(cache.stats().captured,1001);projection.transforms.get('p').x+=100;cache.sync(projection,registry);assert.equal(cache.stats().captured,2001); }); // 分别测试未声明与声明paint依赖;原地改嵌套radius/旋转仍可发现,先前捕获快照不能别名到调用方对象。 +// 故意注入可变记录包络来测试缓存的防御性捕获,不代表正常 editor 允许原地修改已发布文档。 test('mutable nested paint data and geometry are compared against detached dependency snapshots',()=>{ for(const declared of [undefined,['paint']]){ const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,dataDependencies:declared,bounds({element,geometry:g}){return{x:g.x-element.data.paint.radius,y:g.y,width:g.width+2*element.data.paint.radius,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),record={id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:'a',text:'',paint:{radius:10}}},document=createDocument('p',[{id:'p',parent:null}],[record]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),mutable={...projection,document:{...document,elements:new Map([['a',record]])}},cache=createProjectedPaintIndex();assert.equal(cache.sync(mutable,registry).query({x:-25,y:0,width:1,height:1}).has('a'),false);record.data.paint.radius=30;assert.equal(cache.sync(mutable,registry).query({x:-25,y:0,width:1,height:1}).has('a'),true);assert.equal(cache.stats().captured,2);record.geometry.rotation=45;cache.sync(mutable,registry);assert.equal(cache.stats().captured,3);assert.equal(Object.isFrozen(record.data.paint),false); diff --git a/tests/headless/projection-cache.test.mjs b/tests/headless/projection-cache.test.mjs index a41834a..b13799b 100644 --- a/tests/headless/projection-cache.test.mjs +++ b/tests/headless/projection-cache.test.mjs @@ -20,6 +20,7 @@ test('unchanged projection objects survive unrelated additions and equivalent ca test('content, selection, preview geometry, measurements and edges refresh only affected projections',()=>{ const cache=createProjectionCache(),scope={},first=project(cache,scope,document),changed=applyChanges(document,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'edited',beforePresent:true,afterPresent:true}]),second=project(cache,scope,changed);assert.notEqual(first.nodes[0],second.nodes[0]);assert.equal(first.nodes[1],second.nodes[1]); const selected=project(cache,scope,changed,new Set(['B']));assert.equal(second.nodes[0],selected.nodes[0]);assert.notEqual(second.nodes[1],selected.nodes[1]);const preview=project(cache,scope,changed,new Set(['B']),id=>id==='A'?{...node('A').geometry,x:40}:undefined);assert.notEqual(preview.nodes[0],selected.nodes[0]);assert.equal(preview.nodes[1],selected.nodes[1]); + // 手工补入 measured 模拟 renderer 的测量结果;不是实际 DOM 测量,检查 reuse 不丢弃新字段。 const measured=projectCanvas(changed,'root',registry,new Set(['B']),()=>undefined);measured.nodes[0].measured={width:101,height:100};const result=cache.reuse(scope,changed,'root',measured);assert.equal(result.nodes[0].measured.width,101); const edgeChanged=applyChanges(changed,[{kind:'field',target:{id:'edge',incarnation:1},field:'data.title',before:'',after:'edge title',beforePresent:true,afterPresent:true}]),last=project(cache,scope,edgeChanged);assert.notEqual(last.edges[0],first.edges[0]);assert.equal(last.edges[0].data.title,'edge title'); }); @@ -42,6 +43,7 @@ test('unchanged projection arrays retain identity while fresh document/query pro // 节点顺序反转必须换数组并按新顺序返回;scope替换与clear也不共享旧数组身份。 test('array reuse respects order, scope replacement and explicit clearing',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),raw=projectCanvas(document,'root',registry,new Set(),()=>undefined);raw.nodes.reverse();const reverse=cache.reuse(scope,document,'root',raw);assert.notStrictEqual(reverse.nodes,first.nodes);assert.deepEqual(reverse.nodes.map(node=>node.id),first.nodes.map(node=>node.id).reverse());const replacement=project(cache,{},document);assert.notStrictEqual(replacement.nodes,first.nodes);cache.clear();assert.notStrictEqual(project(cache,scope,document).nodes,first.nodes);}); // 新投影边ID不在文档时整个reuse失败,缓存A仍是旧对象;随后合法输入再采用新正文。 +// A 的新投影在坏边之前已经准备好,因此 getNode 仍等于旧对象可检查失败时没有部分发布节点缓存。 test('an invalid edge cannot partially replace the retained node table',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),changed=applyChanges(document,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'prepared only',beforePresent:true,afterPresent:true}]),raw=projectCanvas(changed,'root',registry,new Set(),()=>undefined);raw.edges.push({...raw.edges[0],id:'missing-record'});assert.throws(()=>cache.reuse(scope,changed,'root',raw),/projection_identity_missing/);assert.strictEqual(cache.getNode('A'),first.nodes.find(node=>node.id==='A'));assert.equal(project(cache,scope,changed).nodes.find(node=>node.id==='A').data.text,'prepared only');}); // 新节点包络只多measured时保留相等嵌套props引用,换数组以暴露测量变化,冻结原输入不应被修改。 test('measurement-only updates retain equal data/style/position props without mutating frozen source values',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),raw=projectCanvas(document,'root',registry,new Set(),()=>undefined);raw.nodes=raw.nodes.map(node=>Object.freeze({...node,measured:{width:node.width,height:node.height}}));const result=cache.reuse(scope,document,'root',raw);for(let i=0;i{ 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; // 仅执行修改后的节点selector,getNodesInside固定返回原生可见节点,便于验证额外保留集合。 const select=runInNewContext(code.slice(start,end)+'; selector$c', {getNodesInside:()=>[{id:'visible'}]}); @@ -47,6 +48,7 @@ test('native selection scanning stays dormant without listeners and still serves 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}; + // 生成器每次开始遍历都计一次;nodes/edges 各扫描一遍会增加 2,不能把无监听场景混入这个成本。 const lookup=value=>({*[Symbol.iterator](){scans++;yield[value.id,value];}}),state={nodeLookup:lookup(node),edgeLookup:lookup(edge),onSelectionChangeHandlers:[]}; // 把React Hook替为同步桩并计数扫描/副作用;jsx直接调用组件,仍使用依赖源码中的实际选择逻辑。 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)});