diff --git a/src/lib/canvas-core/queries.ts b/src/lib/canvas-core/queries.ts index b60e73d..9615fa6 100644 --- a/src/lib/canvas-core/queries.ts +++ b/src/lib/canvas-core/queries.ts @@ -47,10 +47,19 @@ export type DocumentQueries={ readonly spatial:ReadonlyMap>;readonly entries:ReadonlyMap;readonly oversized:ReadonlyValueSet}>; members(canvas:Id):readonly Id[];childCanvases(canvas:Id):readonly Id[];incident(element:Id):readonly Id[];orderedElements(ids:Iterable):readonly Id[];orderedCanvases(ids:Iterable):readonly Id[];candidateNodes(canvas:Id,rect:Bounds):readonly Id[];hitNodeBounds(canvas:Id,point:Point,tolerance?:number):{readonly ids:readonly Id[];readonly complete:boolean};hitNodes(canvas:Id,point:Point,tolerance?:number):{readonly ids:readonly Id[];readonly complete:boolean;readonly unavailable:readonly Id[]}; }; +// No mapping callback captures the previous query: retained methods must not +// accidentally keep its context alive, including after consumer bundling. +function spatialViews(tables:Tables,previous?:DocumentQueries){ + const old=previous?privateTables.get(previous)!:undefined; + if(previous&&tables.spatial===old!.spatial)return previous.spatial; + const result=new Map>(); + for(const[id,value]of tables.spatial)result.set(id,previous&&old!.spatial.get(id)===value?previous.spatial.get(id)!:protectSpatial(value)); + return readMap(result); +} function expose(document:Document,revision:number,tables:Tables,previous?:DocumentQueries):DocumentQueries{ const sorted=(ids:Iterable,ranks=tables.order)=>Object.freeze([...ids].filter(id=>ranks.has(id)).sort((a,b)=>ranks.get(a)!-ranks.get(b)!)); const relation=Object.freeze({ownerMembers:previous&&tables.members===privateTables.get(previous)!.members?previous.relations.ownerMembers:readMap(tables.members),canvasChildren:previous&&tables.children===privateTables.get(previous)!.children?previous.relations.canvasChildren:readMap(tables.children),childCanvasByEntry:previous&&tables.childByEntry===privateTables.get(previous)!.childByEntry?previous.relations.childCanvasByEntry:readMap(tables.childByEntry),incidentEdges:previous&&tables.incident===privateTables.get(previous)!.incident?previous.relations.incidentEdges:readMap(tables.incident),elementReferrers:previous&&tables.elementRefs===privateTables.get(previous)!.elementRefs?previous.relations.elementReferrers:readMap(tables.elementRefs),canvasReferrers:previous&&tables.canvasRefs===privateTables.get(previous)!.canvasRefs?previous.relations.canvasReferrers:readMap(tables.canvasRefs),opaqueReferences:tables.opaque,referenceComplete:tables.opaque.size===0}); - const spatial=previous&&tables.spatial===privateTables.get(previous)!.spatial?previous.spatial:readMap(new Map([...tables.spatial].map(([id,value])=>[id,previous&&privateTables.get(previous)!.spatial.get(id)===value?previous.spatial.get(id)!:protectSpatial(value)]))); + const spatial=spatialViews(tables,previous); function candidates(canvas:Id,rect:Bounds){ if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);if(![rect.x,rect.y,rect.width,rect.height].every(Number.isFinite)||rect.width<0||rect.height<0)throw fault('invalid_query_bounds'); const table=tables.spatial.get(canvas);if(!table)return Object.freeze([]) as readonly Id[];const box={minX:rect.x,minY:rect.y,maxX:rect.x+rect.width,maxY:rect.y+rect.height},keys=cells(box,tables.budget.maxQueryCells,tables.budget.cellSize),ids=new Set(keys?table.oversized:table.entries.keys());if(keys)for(const key of keys)for(const id of table.cells.get(key)??[])ids.add(id); diff --git a/tests/headless/query-retention.test.mjs b/tests/headless/query-retention.test.mjs new file mode 100644 index 0000000..53f7e12 --- /dev/null +++ b/tests/headless/query-retention.test.mjs @@ -0,0 +1,16 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +test('latest query methods do not keep discarded query/document generations alive; explicitly held prefixes still work',()=>{ + const core=new URL('../../src/lib/canvas-core/index.ts',import.meta.url).href,queries=new URL('../../src/lib/canvas-core/queries.ts',import.meta.url).href; + const script=` + import {createDocument,createEditor,applyChanges,createRegistry,builtinComponents} from ${JSON.stringify(core)}; + import {createDocumentQueries,advanceDocumentQueries} from ${JSON.stringify(queries)}; + import {setImmediate} from 'node:timers/promises'; + const registry=createRegistry(builtinComponents()); + function chain(keep){let document=createDocument('p',[{id:'p',parent:null}],[{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:''}}]),query=createDocumentQueries(document,registry);const weakDocument=new WeakRef(document),weakQuery=new WeakRef(query),held=keep?query:undefined;for(let i=1;i<=30;i++){const changes=[{kind:'field',target:{id:'a',incarnation:1},field:'geometry',before:document.elements.get('a').geometry,after:{x:i,y:0,width:100,height:100,rotation:0}}];document=applyChanges(document,changes);query=advanceDocumentQueries(query,document,registry,i,changes);}return{last:query,weakDocument,weakQuery,held};} + function editorChain(){const editor=createEditor({registry,document:createDocument('p',[{id:'p',parent:null}],[{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:''}}]),queryCoverage:()=>({projectComplete:true,topologyComplete:true,completeCanvasIds:['p']})}),weakDocument=new WeakRef(editor.getSnapshot().document),weakQuery=new WeakRef(editor.getQueries());for(let i=1;i<=30;i++)editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{x:i}});return{editor,weakDocument,weakQuery};} + const dropped=chain(false),kept=chain(true),owned=editorChain();for(let i=0;i<12;i++){await setImmediate();global.gc();} + console.log(JSON.stringify({droppedDocument:!!dropped.weakDocument.deref(),droppedQuery:!!dropped.weakQuery.deref(),keptDocument:!!kept.weakDocument.deref(),keptQuery:!!kept.weakQuery.deref(),oldX:kept.held.document.elements.get('a').geometry.x,newX:dropped.last.document.elements.get('a').geometry.x,members:dropped.last.members('p'),editorDocument:!!owned.weakDocument.deref(),editorQuery:!!owned.weakQuery.deref(),editorX:owned.editor.getSnapshot().document.elements.get('a').geometry.x}));`; + const child=spawnSync(process.execPath,['--expose-gc','--input-type=module','-e',script],{encoding:'utf8',timeout:15000});assert.equal(child.status,0,child.stderr||String(child.error));assert.deepEqual(JSON.parse(child.stdout),{droppedDocument:false,droppedQuery:false,keptDocument:true,keptQuery:true,oldX:0,newX:30,members:['a'],editorDocument:false,editorQuery:false,editorX:30}); +});