Reuse existing scene projections when inserting ordinary canvas nodes
This commit is contained in:
@@ -670,3 +670,27 @@ Byte I/O/capacity/integrity failures throw instead of becoming a terminal malfor
|
||||
record. In particular a document reader preserves its ordinal for retry; the
|
||||
engine pauses the import, retains its editable prefix, and resumes after the I/O
|
||||
recovers. Ordinary malformed JSON/UTF-8 still uses the existing record diagnostics.
|
||||
|
||||
|
||||
Ordinary node insertion now keeps existing node/edge projections and transforms.
|
||||
A continuous change hint records element IDs rather than treating every element
|
||||
creation as a canvas topology change. A new visible known node uses the shared
|
||||
single-node builder; ordered member IDs restore the original depth-first canvas
|
||||
order. Hidden additions update the document without rebuilding visible objects.
|
||||
The layout index is rebuilt when visible node membership changes, so subsequent
|
||||
field edits address the new indices. Canvas changes, removal, reparenting,
|
||||
connection insertion/endpoints and unsupported placeholders retain full traversal.
|
||||
|
||||
This reduces projection construction, not the whole path to O(k). Reordering
|
||||
node references and rebuilding layout indices still takes linear work; missing
|
||||
membership services require a record scan. The 1000-node insertion test resolves
|
||||
only the new renderer, keeps all 1000 prior node objects and forbids full document
|
||||
record enumeration when a membership index is available. Actual toolbar insertion
|
||||
and ACK are checked separately in the page integration harness.
|
||||
|
||||
A local Chrome157/Next-development 1000-child copy probe completed with a first
|
||||
presented frame around 348 ms and frame-gap P95 around 33 ms. These observations
|
||||
are not an SLA or proof of overall speedup. The 10000-child baseline probe at
|
||||
0ce3abb remained unresponsive with a CPU-active renderer and approximately
|
||||
4.4 GiB sampled physical footprint, and was interrupted. Its completion, first
|
||||
frame and root cause remain unverified; 10000-node support is not claimed.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type {DocumentQueries} from '../canvas-core/queries.ts';
|
||||
import type {Document,Id,Registry} from '../canvas-core/types.ts';
|
||||
import type {SessionSnapshot,ViewState} from '../canvas-core/gestures.ts';
|
||||
import {equal} from '../canvas-core/values.ts';
|
||||
@@ -14,12 +15,12 @@ export function indexScene(projection:Projection){
|
||||
for(const id of projection.transforms.keys())if(id!==projection.canvasId){const parent=projection.document.canvases.get(id)?.parent;if(parent&&depths.has(parent.canvasId))depths.set(id,depths.get(parent.canvasId)!+1);}
|
||||
return{nodes,edges,entries,depths,unsupported:projection.nodes.some(node=>!!node.data.__headlessFallback)||projection.edges.some(edge=>edge.type==='unsupported')};
|
||||
}
|
||||
/** A checked fixed-layout update. Structural/visibility changes keep the complete traversal. */
|
||||
export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,next:SceneInputs,previous:SceneInputs,connection:SessionSnapshot['connectionGesture'],hint?:SceneChangeHint):Projection|undefined{
|
||||
/** Reuse existing projections for checked field edits and ordinary node insertions. */
|
||||
export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,next:SceneInputs,previous:SceneInputs,connection:SessionSnapshot['connectionGesture'],hint?:SceneChangeHint,queries?:DocumentQueries):Projection|undefined{
|
||||
const {document,selection,preview,registry,catalog,canvasId}=next;
|
||||
const {selection:oldSelection,preview:oldPreview,edgeId:oldEdgeId,path:oldPath}=previous;
|
||||
if(layout.unsupported||document.projectId!==base.document.projectId)return;
|
||||
const dirty=new Set<Id>();
|
||||
const dirty=new Set<Id>(),created=new Set<Id>();
|
||||
if(document!==base.document){
|
||||
if(hint?.from===base.document&&hint.to===document){if(hint.topology)return;for(const id of hint.ids)dirty.add(id);}
|
||||
else{
|
||||
@@ -37,22 +38,33 @@ export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,
|
||||
if(connection?.target.id!==oldEdgeId||connection?.path!==oldPath){if(oldEdgeId)dirty.add(oldEdgeId);if(connection)dirty.add(connection.target.id);}
|
||||
// Validate the whole dirty footprint before constructing or publishing any replacement.
|
||||
for(const id of dirty){const before=base.document.elements.get(id),after=document.elements.get(id);
|
||||
if(!before&&after?.structure==='node'){created.add(id);continue;}
|
||||
if(!before||!after||before.incarnation!==after.incarnation||before.structure!==after.structure||before.componentId!==after.componentId||before.dataVersion!==after.dataVersion||before.ownerCanvasId!==after.ownerCanvasId)return;
|
||||
if(before.structure==='connection'&&after.structure==='connection'&&(!equal(before.source,after.source)||!equal(before.target,after.target)))return;
|
||||
if(before.structure==='node'&&after.structure==='node'&&layout.entries.has(id)&&(!equal(before.geometry,after.geometry)||!equal(before.data.presentation,after.data.presentation)||!equal(oldPreview?.get(id),preview?.get(id))))return;
|
||||
}
|
||||
let nodes=base.nodes,edges=base.edges;
|
||||
let nodes=base.nodes,edges=base.edges;const inserted=new Map<Id,Projection['nodes'][number]>();
|
||||
for(const id of dirty){const element=document.elements.get(id)!;
|
||||
if(element.structure==='node'){
|
||||
const index=layout.nodes.get(id);if(index===undefined)continue;
|
||||
const index=layout.nodes.get(id);if(index===undefined&&!created.has(id))continue;
|
||||
if(!base.transforms.has(element.ownerCanvasId))continue;
|
||||
const transform=base.transforms.get(element.ownerCanvasId)!,depth=layout.depths.get(element.ownerCanvasId)!;
|
||||
const node=projectNode(element,canvasId,registry,selection,preview?.get(id),transform,depth,layout.entries.get(id),catalog);
|
||||
if(nodes===base.nodes)nodes=[...nodes];nodes[index]=node;
|
||||
if(created.has(id)){if(node.data.__headlessFallback)return;inserted.set(id,node);}
|
||||
else{if(nodes===base.nodes)nodes=[...nodes];nodes[index!]=node;}
|
||||
}else{
|
||||
const index=layout.edges.get(id);if(index===undefined)continue;const prior=base.edges[index];
|
||||
const edge=projectEdge(element,prior.source,prior.target,registry,selection,base.transforms.get(element.ownerCanvasId)!,connection,catalog);
|
||||
if(edges===base.edges)edges=[...edges];edges[index]=edge;
|
||||
}
|
||||
}
|
||||
if(inserted.size){
|
||||
// Membership order is depth-first, not global insertion order. Reorder references
|
||||
// without reconstructing unaffected nodes, transforms or edge projections.
|
||||
const members=new Map<Id,Id[]>();if(!queries)for(const element of document.elements.values()){const list=members.get(element.ownerCanvasId)??[];list.push(element.id);members.set(element.ownerCanvasId,list);}
|
||||
const ordered:Projection['nodes']=[];let complete=true;
|
||||
const visit=(owner:Id)=>{for(const id of queries?queries.members(owner):members.get(owner)??[]){const element=document.elements.get(id)!;if(element.structure!=='node')continue;const projected=inserted.get(id)??nodes[layout.nodes.get(id)!];if(!projected){complete=false;return;}ordered.push(projected);const child=layout.entries.get(id);if(child&&base.transforms.has(child))visit(child);}};
|
||||
visit(canvasId);if(!complete)return;nodes=ordered;
|
||||
}
|
||||
return nodes===base.nodes&&edges===base.edges&&document===base.document?base:{...base,document,nodes,edges};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function createSceneProjection(){
|
||||
if(pending?.to!==before){pending=previous.document===before?{from:before,to:after,ids:new Set(),topology:false}:undefined;}
|
||||
if(!pending)return;pending.to=after;
|
||||
if(!changes.length){pending=undefined;return;}
|
||||
for(const change of changes){if(change.kind==='canvas'||change.kind==='element'){pending.topology=true;break;}pending.ids.add(change.target.id);if(pending.ids.size>16384){pending.topology=true;break;}}
|
||||
for(const change of changes){if(change.kind==='canvas'){pending.topology=true;break;}pending.ids.add(change.kind==='element'?change.id:change.target.id);if(pending.ids.size>16384){pending.topology=true;break;}}
|
||||
if(pending.topology)pending.ids.clear();
|
||||
},
|
||||
project(owner:object,document:Document,view:ViewState,sessions:SessionSnapshot,registry:Registry,catalog:RendererCatalog,queries?:DocumentQueries){
|
||||
@@ -30,9 +30,9 @@ export function createSceneProjection(){
|
||||
if(previous&&Object.keys(next).every(key=>Object.is(next[key as keyof SceneInputs],previous![key as keyof SceneInputs])))return value!;
|
||||
// Read the captured session, not a newer live gesture during an older React render.
|
||||
const compatible=previous&&value&&layout&&previous.owner===owner&&previous.registry===registry&&previous.catalog===catalog&&previous.canvasId===view.canvasId;
|
||||
const patched=compatible?patchScene(value!,layout!,next,previous!,connection,pending):undefined;
|
||||
const patched=compatible?patchScene(value!,layout!,next,previous!,connection,pending,queries):undefined;
|
||||
const result=patched??projectCanvas(document,view.canvasId,registry,view.selection,id=>next.preview?.get(id),connection,catalog,{queries,geometry,owner});
|
||||
if(!patched)layout=indexScene(result);
|
||||
if(!patched||result.nodes.length!==value!.nodes.length)layout=indexScene(result);
|
||||
previous=next;value=result;pending=undefined;return result;
|
||||
},
|
||||
clear(){previous=undefined;value=undefined;layout=undefined;pending=undefined;geometry=createGeometryProjectionCache();},
|
||||
|
||||
@@ -415,6 +415,8 @@ try {
|
||||
console.log('PASS old rendered edit callbacks cannot modify a restored incarnation');
|
||||
await until(`window.__fixtureLegacy['meter-one']!==window.__fixtureStaleLegacy&&window.__fixtureEngine.getSnapshot().document.elements.get('meter-one').incarnation===2`,'restored renderer did not bind a fresh edit target');await evaluate(`window.__fixtureLegacy['meter-one'].rename('meter-one','fresh callback')`);for(let i=0;i<200&&documents.get(extensionRoot).elements.get('meter-one')?.data.title!=='fresh callback';i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.title,'fresh callback');
|
||||
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],other=window.__fixtureLegacy['meter-two'];old.resize('meter-one');other.resize('meter-two');old.resize(null);const view=[...engine.sessions.getSnapshot().views.values()][0],retained=view.modes.get('meter-two')==='resize';other.resize(null);return retained;})()`),true,'old target mode cleanup canceled another target');
|
||||
await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension state did not settle before insertion');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const insertionBefore=await evaluate('({projects:{...window.__fixtureProjectedNodes},scenes:window.__fixtureSceneBuilds})'),extensionSize=documents.get(extensionRoot).elements.size;await click('添加文本卡片');
|
||||
for(let i=0;i<200&&documents.get(extensionRoot).elements.size!==extensionSize+1;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.size,extensionSize+1);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'inserted card did not synchronize');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const insertionAfter=await evaluate('({projects:{...window.__fixtureProjectedNodes},scenes:window.__fixtureSceneBuilds})');for(const id of ['meter-one','meter-two','meter-three'])assert.equal(insertionAfter.projects[id],insertionBefore.projects[id],'ordinary card insertion rebuilt '+id);assert.equal(insertionAfter.scenes,insertionBefore.scenes,'ordinary card insertion rebuilt the whole scene');console.log('PASS actual toolbar card insertion and ACK retain every unrelated node projection and avoid full scene traversal');
|
||||
await evaluate(`(()=>{window.__detachedEngine=window.__fixtureEngine;window.__detachedEditing=window.__fixtureLegacy['meter-one'];[...document.querySelectorAll('a')].find(link=>link.pathname.endsWith('/${other}')).click();})()`);await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'renderer view did not detach on navigation');assert.equal(await evaluate(`(()=>{const before=window.__detachedEngine.getSnapshot();window.__detachedEditing.rename('meter-one','detached callback');return window.__detachedEngine.getSnapshot()===before;})()`),true,'detached renderer wrote into its retained project');
|
||||
console.log('PASS restored renderers can edit with fresh bindings and detached views cannot write through old callbacks');
|
||||
|
||||
|
||||
@@ -49,3 +49,27 @@ test('replacement without changes compares records and detects reordered members
|
||||
test('a failed local renderer never mutates or publishes a partially updated scene',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),document=f.editor.getSnapshot().document;let fail=false;const catalog={...f.catalog,resolve(record){if(fail&&record.id==='B')throw Error('renderer failed');return f.catalog.resolve(record);}},first=f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,catalog);f.sessions.select('v',['A','B']);const selected=f.sessions.getSnapshot();fail=true;assert.throws(()=>f.scene.project(f.owner,document,selected.views.get('v'),selected,f.registry,catalog),/renderer failed/);assert.equal(first.nodes.find(node=>node.id==='A').selected,false);assert.equal(first.nodes.find(node=>node.id==='B').selected,false);fail=false;const result=f.scene.project(f.owner,document,selected.views.get('v'),selected,f.registry,catalog);assert.equal(result.nodes.find(node=>node.id==='A').selected,true);assert.equal(result.nodes.find(node=>node.id==='B').selected,true);});
|
||||
|
||||
test('unknown placeholders and redirected handles keep the complete projection rules during a neighbour edit',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),base=f.editor.getSnapshot().document,records=[...base.elements.values()].map(record=>record.id==='F'?{...record,dataVersion:99,data:{unreadable:{preserve:true}}}:record.id==='edge'?{...record,source:{elementId:'A',portId:'unknown-custom'},target:{elementId:'B'}}:record),document=createDocument('p',[...base.canvases.values()],records),first=f.scene.project(f.owner,document,view,snapshot,f.registry,f.catalog),changed=createDocument('p',[...document.canvases.values()],records.map(record=>record.id==='B'?{...record,data:{...record.data,text:'still editable'}}:record));assert.equal(first.nodes.find(node=>node.id==='F').type,'unsupported');assert.equal(first.edges[0].source,'F');f.scene.advance(document,changed,[{kind:'field',target:{id:'B',incarnation:1},field:'data.text',before:'',after:'still editable'}]);const result=f.scene.project(f.owner,changed,view,snapshot,f.registry,f.catalog);assert.deepEqual(result,projectCanvas(changed,'root',f.registry,view.selection,()=>undefined,null,f.catalog));assert.deepEqual(result.document.elements.get('F').data,{unreadable:{preserve:true}});});
|
||||
|
||||
test('new ordinary cards reuse existing projections and preserve depth-first nested membership order',()=>{
|
||||
const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const first=f.project(),reads=f.reads;
|
||||
assert.equal(f.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('new-root')});tx.dispatch({type:'element.create',element:node('new-child','child')});tx.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'same batch'}});}).status,'applied');
|
||||
const next=f.project();assert.equal(f.reads-reads,3,'unrelated nodes/edges were reconstructed during insertion');assert.deepEqual(next.nodes.map(n=>n.id),['F','A','new-child','B','new-root']);assert.strictEqual(next.nodes[0],first.nodes[0]);assert.strictEqual(next.nodes[1],first.nodes[1]);assert.strictEqual(next.edges,first.edges);
|
||||
const snapshot=f.sessions.getSnapshot();assert.deepEqual(next,projectCanvas(next.document,'root',f.registry,snapshot.views.get('v').selection,()=>undefined,null,f.catalog,{queries:f.editor.getQueries(next.document)}));
|
||||
const beforeEdit=f.reads;f.editor.dispatch({type:'element.data',target:{id:'new-child',incarnation:1},patch:{text:'later edit'}});const edited=f.project();assert.equal(f.reads-beforeEdit,1);assert.equal(edited.nodes.find(n=>n.id==='new-child').data.text,'later edit');stop();
|
||||
});
|
||||
|
||||
test('new hidden cards do not reconstruct visible objects and appear correctly after expanding their entry',()=>{
|
||||
const f=fixture();f.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:true}}});let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const first=f.project(),reads=f.reads;
|
||||
f.editor.dispatch({type:'element.create',element:node('hidden-new','child')});const next=f.project();assert.equal(f.reads,reads);assert.strictEqual(next.nodes,first.nodes);assert.strictEqual(next.edges,first.edges);assert.ok(next.document.elements.has('hidden-new'));
|
||||
f.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:false}}});const expanded=f.project();assert.ok(expanded.nodes.some(n=>n.id==='hidden-new'));const snapshot=f.sessions.getSnapshot();assert.deepEqual(expanded,projectCanvas(expanded.document,'root',f.registry,snapshot.views.get('v').selection,()=>undefined,null,f.catalog));stop();
|
||||
});
|
||||
|
||||
test('inserting one card into 1000 nodes resolves only that renderer and never enumerates document records with a membership index',()=>{
|
||||
const registry=createRegistry(builtinComponents()),original=createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node(String(i)))),scene=createSceneProjection(),owner={},selection=new Set(),view={id:'v',canvasId:'root',selection},snapshot={views:new Map([['v',view]]),gesture:null,connectionGesture:null};let resolves=0;
|
||||
const catalog={...builtinRendererCatalog,resolve(record){resolves++;return builtinRendererCatalog.resolve(record);}},first=scene.project(owner,original,view,snapshot,registry,catalog),added=node('added'),raw=createDocument('p',[...original.canvases.values()],[...original.elements.values(),added]),document={...raw,elements:{size:raw.elements.size,get:id=>raw.elements.get(id),has:id=>raw.elements.has(id),values(){throw Error('whole-document enumeration');},[Symbol.iterator](){throw Error('whole-document enumeration');}}},queries={document,registry,members:()=>[...raw.elements.keys()]};
|
||||
scene.advance(original,document,[{kind:'element',id:'added',before:null,after:added}]);resolves=0;const next=scene.project(owner,document,view,snapshot,registry,catalog,queries);assert.equal(resolves,1);assert.equal(next.nodes.length,1001);assert.equal(next.nodes.at(-1).id,'added');for(let i=0;i<1000;i++)assert.strictEqual(next.nodes[i],first.nodes[i]);assert.strictEqual(next.edges,first.edges);
|
||||
});
|
||||
|
||||
test('mixed edge insertion and unknown new cards retain full endpoint and placeholder projection rules',()=>{
|
||||
for(const unknown of [false,true]){const f=fixture(),before=f.editor.getSnapshot().document;f.project();const added=unknown?{...node('added'),componentId:'future',dataVersion:99,data:{opaque:{keep:true}}}:node('added'),edge={id:'new-edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'added',...(unknown?{portId:'future-port'}:{})},data:{},path:null},after=createDocument('p',[...before.canvases.values()],[...before.elements.values(),added,edge]);f.scene.advance(before,after,[{kind:'element',id:added.id,before:null,after:added},{kind:'element',id:edge.id,before:null,after:edge}]);const snapshot=f.sessions.getSnapshot(),result=f.scene.project(f.owner,after,snapshot.views.get('v'),snapshot,f.registry,f.catalog);assert.deepEqual(result,projectCanvas(after,'root',f.registry,snapshot.views.get('v').selection,()=>undefined,null,f.catalog));}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user