diff --git a/headless/README.md b/headless/README.md index eb697ba..f46ac73 100644 --- a/headless/README.md +++ b/headless/README.md @@ -786,3 +786,14 @@ limits, atomic reference cycles, parser slices and durability backpressure keep their existing roles. Rejected attempts do not train the success-cost estimate. The limit is a scheduling hint, not another persistent queue or an assurance that an indivisible transaction or subsequent renderer work fits one frame. + +Node paint entries retain a detached dependency signature until an input changes. +Unchanged rows compare scalar geometry/transform/preview values and declared data +fields directly, without allocating replacement signature objects or dependency +arrays. Unspecified data dependencies still compare the complete data snapshot. +Nested mutable data is copied on capture, and renderer fallback changes the +selected protocol, so neither same object identity nor equal geometry alone +permits reuse. `stats().captured` counts actual dependency captures. Projection +retention also keeps the existing retained row when the incoming node is the same +object in the same lifetime. Both caches remain derived view-owned state; the +full membership scan and paint-grid reconciliation remain present. diff --git a/src/lib/canvas-bindings/projected-paint.ts b/src/lib/canvas-bindings/projected-paint.ts index bdd32b8..65abe69 100644 --- a/src/lib/canvas-bindings/projected-paint.ts +++ b/src/lib/canvas-bindings/projected-paint.ts @@ -1,8 +1,8 @@ -import {equal} from '../canvas-core/values.ts'; +import {clone,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'; -import type {projectCanvas} from './projection.ts'; +import {toLocal,toWorld,type Transform} from '../canvas-core/coordinates.ts'; +import type {ElementRecord,Geometry,GeometryProtocol,Registry} from '../canvas-core/types.ts'; +import type {projectCanvas,ProjectionNode} from './projection.ts'; import {createPaintIndex,type PaintRegion} from './paint-index.ts'; /** Extra node paint extents beyond native unrotated React Flow bounds. */ export function projectedPaintOverflow(projection:ReturnType,registry:Registry){ @@ -19,24 +19,42 @@ export function projectedPaintOverflow(projection:ReturnType; +type PaintSignature={incarnation?:number;owner?:string;component?:string;version?:number;geometry?:Geometry;transform?:Transform;x:number;y:number;width?:number;height?:number;rotation:unknown;protocol:NodeProtocol;data:unknown}; +const sameGeometry=(a:Geometry|undefined,b:Geometry|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.width===b.width&&a.height===b.height&&a.rotation===b.rotation; +const sameTransform=(a:Transform|undefined,b:Transform|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation; +function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol){ + if(old.incarnation!==element?.incarnation||old.owner!==element?.ownerCanvasId||old.component!==element?.componentId||old.version!==element?.dataVersion||old.protocol!==protocol||!sameGeometry(old.geometry,element?.structure==='node'?element.geometry:undefined)||!sameTransform(old.transform,t)||old.x!==node.position.x||old.y!==node.position.y||old.width!==node.width||old.height!==node.height||old.rotation!==node.data.rotation)return false; + const dependencies=protocol.dataDependencies; + if(dependencies===undefined)return equal(old.data,element?.data); + const values=old.data as unknown[]; + for(let i=0;iclone(element?.data[key]))}; +} +/** View-owned incremental paint grid; unchanged rows retain their captured dependencies. */ export function createProjectedPaintIndex(){ - let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0; + let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0; type Projection=ReturnType; - type Entry={signature:unknown;result:ReturnType}; + type Entry={signature:PaintSignature;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),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); + let entry=old; + if(!entry||!sameSignature(entry.signature,node,element,t,protocol)){ + const signature=captureSignature(node,element,t,protocol),result=projectedPaintOverflow({...projection,nodes:[node]},registry);captured++;computed++;entry={signature,result};entries.set(node.id,entry); + } + regions.push(...entry.result.regions);unknown.push(...entry.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)}); }, - stats:()=>Object.freeze({computed,entries:entries.size}), + stats:()=>Object.freeze({computed,captured,entries:entries.size}), clear(){entries.clear();index.clear();registryOwner=undefined;canvas=undefined;project=undefined;}, }); } diff --git a/src/lib/canvas-bindings/projection-cache.ts b/src/lib/canvas-bindings/projection-cache.ts index 594c3fb..d13f698 100644 --- a/src/lib/canvas-bindings/projection-cache.ts +++ b/src/lib/canvas-bindings/projection-cache.ts @@ -15,6 +15,7 @@ export function createProjectionCache(){ for(let index=0;index)[key],before=(old.value as Record)[key];if(Object.hasOwn(shared,key)&&Object.hasOwn(old.value,key)&&!Object.is(incoming,before)&&equal(incoming,before))shared={...shared,[key]:before};} const retained=sameLifetime&&equal(old.value,shared)?old.value:shared; diff --git a/tests/headless/paint-index.test.mjs b/tests/headless/paint-index.test.mjs index 18661e9..373993e 100644 --- a/tests/headless/paint-index.test.mjs +++ b/tests/headless/paint-index.test.mjs @@ -9,3 +9,16 @@ test('projected paint uses paint role independently of logical bounds and conser 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);}); + +test('unchanged paint rows retain their captured dependencies across metadata and ACK copies',()=>{ + const registry=createRegistry(builtinComponents()),records=Array.from({length:1000},(_,i)=>({id:'stable-'+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:'stable',text:''}})),document=createDocument('p',[{id:'p',parent:null}],records),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();projection.transforms.set('p',{...projection.transforms.get('p')});cache.sync(projection,registry);assert.equal(cache.stats().captured,1000);for(let i=0;i<10;i++)cache.sync({...projection},registry);assert.equal(cache.stats().captured,1000);cache.sync(projectCanvas(createDocument('p',[{id:'p',parent:null}],records),'p',registry,new Set(),()=>undefined),registry);assert.equal(cache.stats().captured,1000); + 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); +}); +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); + } +}); +test('renderer fallback switches paint protocols even when data and projected geometry stay equal',()=>{ + const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,bounds({geometry:g}){return{x:g.x-100,y:g.y,width:g.width+200,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),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:''}}]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();assert.ok(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).has('a'));projection.nodes[0].data.__headlessFallback={reason:'renderer-unavailable',componentId:'text',dataVersion:1,handles:[]};assert.equal(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).size,0);assert.equal(cache.stats().captured,2);delete projection.nodes[0].data.__headlessFallback;assert.ok(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).has('a')); +});