From 6c4ab46413a92e62bc2973b2f693d145e8fb7b9a Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 18:50:30 +0800 Subject: [PATCH] Invalidate node paint geometry by declared inputs instead of record identity --- headless/README.md | 8 ++++++++ src/lib/canvas-bindings/projected-paint.ts | 10 ++++++---- tests/headless/paint-index.test.mjs | 1 + 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/headless/README.md b/headless/README.md index fd67f34..d35c200 100644 --- a/headless/README.md +++ b/headless/README.md @@ -423,3 +423,11 @@ on an already visible node no longer disables culling for every other node. Only an otherwise offscreen target that needs extra paint/overlay retention uses the view fallback. Focus/IME and gesture safeguards remain. This fixes a measured regression where selecting a frame mounted all 2003 nodes after a large copy. + +Node paint signatures now compare effective geometry, lifecycle, owner, component, +transform and declared geometry `dataDependencies`, rather than immutable record +object identity. An ACK that reconstructs equal records no longer recomputes all +node paint bounds. Unrelated titles do not invalidate a protocol that declares +only another data field. Omitting dependencies conservatively compares all data; +components must declare every data field their geometry reads. The scene still +gets scanned, and connection paint currently has its separate computation path. diff --git a/src/lib/canvas-bindings/projected-paint.ts b/src/lib/canvas-bindings/projected-paint.ts index db7474e..bdd32b8 100644 --- a/src/lib/canvas-bindings/projected-paint.ts +++ b/src/lib/canvas-bindings/projected-paint.ts @@ -1,3 +1,4 @@ +import {equal} from '../canvas-core/values.ts'; import {rectangleGeometry} from '../canvas-core/geometry.ts'; import {toLocal,toWorld} from '../canvas-core/coordinates.ts'; import type {Registry} from '../canvas-core/types.ts'; @@ -22,15 +23,16 @@ export function projectedPaintOverflow(projection:ReturnType; - type Entry={node:Projection['nodes'][number];element:unknown;transform:string;result:ReturnType}; + type Entry={signature:unknown;result:ReturnType}; const entries=new Map(),index=createPaintIndex(); return Object.freeze({ sync(projection:Projection,registry:Registry){ if(registryOwner!==registry||canvas!==projection.canvasId||project!==projection.document.projectId){entries.clear();index.clear();registryOwner=registry;canvas=projection.canvasId;project=projection.document.projectId;} const seen=new Set(),regions:PaintRegion[]=[],unknown:string[]=[]; - for(const node of projection.nodes){seen.add(node.id);const element=projection.document.elements.get(node.id),t=element&&projection.transforms.get(element.ownerCanvasId),transform=t?`${t.x},${t.y},${t.scale},${t.rotation}`:'missing',old=entries.get(node.id); - const result=old&&old.node===node&&old.element===element&&old.transform===transform?old.result:projectedPaintOverflow({...projection,nodes:[node]},registry); - if(result!==old?.result)computed++;entries.set(node.id,{node,element,transform,result});regions.push(...result.regions);unknown.push(...result.unknown); + for(const node of projection.nodes){seen.add(node.id);const element=projection.document.elements.get(node.id),t=element&&projection.transforms.get(element.ownerCanvasId),old=entries.get(node.id),declared=element&&!node.data.__headlessFallback&®istry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=declared?.kind==='node'?declared:rectangleGeometry; + const dependencies=protocol.dataDependencies,signature={id:node.id,incarnation:element?.incarnation,owner:element?.ownerCanvasId,component:element?.componentId,version:element?.dataVersion,geometry:element?.structure==='node'?element.geometry:undefined,transform:t?{...t}:undefined,position:{...node.position},width:node.width,height:node.height,rotation:node.data.rotation,data:dependencies===undefined?element?.data:dependencies.map(key=>[key,element?.data[key]])}; + const result=old&&equal(old.signature,signature)?old.result:projectedPaintOverflow({...projection,nodes:[node]},registry); + if(result!==old?.result)computed++;entries.set(node.id,{signature,result});regions.push(...result.regions);unknown.push(...result.unknown); } for(const id of entries.keys())if(!seen.has(id))entries.delete(id);index.sync(regions);return Object.freeze({query:index.query,unknown:Object.freeze(unknown)}); }, diff --git a/tests/headless/paint-index.test.mjs b/tests/headless/paint-index.test.mjs index fe22eae..18661e9 100644 --- a/tests/headless/paint-index.test.mjs +++ b/tests/headless/paint-index.test.mjs @@ -8,3 +8,4 @@ import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts'; test('projected paint uses paint role independently of logical bounds and conservatively retains failed handlers',()=>{const text=builtinComponents().find(c=>c.id==='text'),roles=[],geometry={...text.geometry,bounds(context){roles.push(context.role);if(context.element.data.title==='broken')throw Error('broken');return context.role==='paint'?{x:-100,y:-50,width:400,height:200}:{x:0,y:0,width:100,height:100};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),node=(id,title)=>({id,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title,text:''}}),document=createDocument('p',[{id:'p',parent:null}],[node('a','okay'),node('b','broken')]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),result=projectedPaintOverflow(projection,registry);assert.ok(roles.includes('paint'));assert.deepEqual(result.regions,[{id:'a',bounds:{x:-100,y:-50,width:400,height:200}}]);assert.deepEqual(result.unknown,['b']);assert.equal(document.elements.get('a').geometry.width,100);}); import {createProjectedPaintIndex} from '../../src/lib/canvas-bindings/projected-paint.ts'; 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();}); +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);});