diff --git a/headless/README.md b/headless/README.md index 658af05..d61261a 100644 --- a/headless/README.md +++ b/headless/README.md @@ -442,3 +442,14 @@ geometry services capture changing external configuration must pass a changed `geometryRevision` to `sync`. CanvasView now uses this index with native handles. The edge list/signature comparison still gets scanned; this is not full dirty-region drawing or selective offscreen mounting. + +React renderer assembly now memoizes installed node/edge/preview components using +stable wrappers shared by original component identity. Explicit React.memo inputs +are preserved. `definitions[].render/preview` retain the original implementations; +`nodeTypes`/`edgeTypes` tables contain the compiled wrappers. Context and hook updates +still propagate normally. The projection cache separately shares equal data/style/ +position fields across measurement-only updates without mutating input objects. +A real three-node fixture verifies that editing one node through ACK leaves an +unrelated node and nonincident edge's render function counts unchanged. Connected +edges may legitimately remeasure when a custom renderer's data changes; this does +not assume arbitrary custom Handle layouts stay fixed. diff --git a/src/lib/canvas-bindings/projection-cache.ts b/src/lib/canvas-bindings/projection-cache.ts index 59c332c..594c3fb 100644 --- a/src/lib/canvas-bindings/projection-cache.ts +++ b/src/lib/canvas-bindings/projection-cache.ts @@ -14,7 +14,10 @@ export function createProjectionCache(){ const next=new Map>(); 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; values[index]=retained;next.set(value.id,{incarnation:record.incarnation,ownerCanvasId:record.ownerCanvasId,value:retained}); } return next; diff --git a/src/lib/canvas-bindings/react-renderers.ts b/src/lib/canvas-bindings/react-renderers.ts index 8f4744a..bd80de7 100644 --- a/src/lib/canvas-bindings/react-renderers.ts +++ b/src/lib/canvas-bindings/react-renderers.ts @@ -1,3 +1,4 @@ +import {memo} from 'react'; import type { NodeTypes, EdgeTypes } from '@xyflow/react'; import type { ComponentDefinition, Registry } from '../canvas-core/types.ts'; import { fault } from '../canvas-core/values.ts'; @@ -5,6 +6,12 @@ import { createRendererCatalog, type RendererDescriptor, type RendererCatalog } export type ReactRendererDefinition=RendererDescriptor&({readonly structure:'node';readonly render:NodeTypes[string];readonly preview?:NodeTypes[string]}|{readonly structure:'connection';readonly render:EdgeTypes[string];readonly preview?:EdgeTypes[string]}); export type ReactRenderers={readonly catalog:RendererCatalog;readonly definitions:readonly ReactRendererDefinition[];readonly nodeTypes:NodeTypes;readonly edgeTypes:EdgeTypes;readonly previewNodeTypes:NodeTypes;readonly previewEdgeTypes:EdgeTypes}; export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]}; +const memoizedRenderers=new WeakMap(); +function compiledRenderer(render:NodeTypes[string]|EdgeTypes[string]):NodeTypes[string]|EdgeTypes[string]{ + if((render as {$$typeof?:symbol}).$$typeof===Symbol.for('react.memo'))return render; + const key=render as object,previous=memoizedRenderers.get(key);if(previous)return previous; + const compiled=memo(render as NodeTypes[string]);memoizedRenderers.set(key,compiled);return compiled; +} export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{ const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),previewNodes:NodeTypes=Object.create(null),previewEdges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[]; for(let index=0;index{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),clone=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(record=>structuredClone(record))),second=project(cache,scope,clone);assert.strictEqual(second.nodes,first.nodes);assert.strictEqual(second.edges,first.edges);assert.strictEqual(second.document,clone);assert.notStrictEqual(second.document,first.document);const changed=applyChanges(clone,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'array edit',beforePresent:true,afterPresent:true}]),third=project(cache,scope,changed);assert.notStrictEqual(third.nodes,second.nodes);assert.strictEqual(third.edges,second.edges);assert.strictEqual(third.nodes[1],second.nodes[1]);}); 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);}); 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');}); +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};const counts=host.__fixtureRenders??={};counts[id]=(counts[id]??0)+1;} function Meter({data}:NodeProps){ - const commands=useCanvasRendererCommands(),lookup=useElement(data.__headlessRecord!),record=lookup.status==='present'?lookup.element:data.__headlessRecord!; + const commands=useCanvasRendererCommands(),lookup=useElement(data.__headlessRecord!),record=lookup.status==='present'?lookup.element:data.__headlessRecord!;rendered(record.id); return
Metric {String(record.data.metric)}
; } -function MeterEdge(props:EdgeProps){const [path]=getBezierPath(props);return Metric {String(props.data?.__headlessRecord?.data.metric)};} +function MeterEdge(props:EdgeProps){rendered(props.id);const [path]=getBezierPath(props);return Metric {String(props.data?.__headlessRecord?.data.metric)};} export const fixtureExtension:CanvasRuntimeExtension={ components:[defineComponent({id:'fixture:meter',dataVersion:1,structure:'node',validate:data=>Number.isSafeInteger(data.metric),features:[editable(),movable()],copy:{references:[],omit:[]}}),defineComponent({id:'fixture:metric-edge',dataVersion:1,structure:'connection',validate:data=>typeof data.metric==='number',features:[],copy:{references:[],omit:[]}})], renderers:[{componentId:'fixture:meter',dataVersion:1,structure:'node',rendererId:'fixture-meter',render:Meter},{componentId:'fixture:metric-edge',dataVersion:1,structure:'connection',rendererId:'fixture-metric-edge',render:MeterEdge}], diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 999b0ba..120fe41 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -370,8 +370,8 @@ try { await writeFile(join(tmpdir(),'headless-opaque-renderer.png'),Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64')); console.log('PASS unknown types and future versions render safe read-only placeholders with custom/partition connections, preserve opaque payloads through edits and recovery, and never load opaque asset fields'); - const extensionRoot=id(810),meter={...node('meter-one',extensionRoot,350),componentId:'fixture:meter',data:{metric:3,raw:{retained:true}}},meterTwo={...meter,id:'meter-two',geometry:{...meter.geometry,x:750},data:{metric:9}},meterEdge={id:'meter-edge',incarnation:1,structure:'connection',componentId:'fixture:metric-edge',dataVersion:1,ownerCanvasId:extensionRoot,source:{elementId:meter.id,portId:'custom-out'},target:{elementId:meterTwo.id,portId:'custom-in'},data:{metric:55},path:{type:'metric-curve',retained:true}}; - documents.set(extensionRoot,createDocument(extensionRoot,[{id:extensionRoot,parent:null}],[meter,meterTwo,meterEdge,{...meterEdge,id:'meter-builtin-edge',componentId:'connection',data:{},path:null}]));versions.set(extensionRoot,1);boards.push({id:extensionRoot,workspace_id:workspace,kind:'board',version:1,title:'Extension board'});await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${extensionRoot}`});await until(`document.querySelectorAll('[data-renderer-fixture="meter"]').length===2&&document.querySelectorAll('[data-renderer-fixture="metric-edge"]').length===1`,'host-installed custom node/edge renderers did not mount');await until(`document.querySelectorAll('.react-flow__edge').length===2`,'built-in edge did not connect custom renderer ports');assert.equal(await evaluate(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length`),0);await click('Increase meter-one');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data,{metric:4,raw:{retained:true,edited:true}});assert.deepEqual(documents.get(extensionRoot).elements.get('meter-edge'),meterEdge); + const extensionRoot=id(810),meter={...node('meter-one',extensionRoot,350),componentId:'fixture:meter',data:{metric:3,raw:{retained:true}}},meterTwo={...meter,id:'meter-two',geometry:{...meter.geometry,x:750},data:{metric:9}},meterThree={...meter,id:'meter-three',geometry:{...meter.geometry,x:1150},data:{metric:12}},meterEdge={id:'meter-edge',incarnation:1,structure:'connection',componentId:'fixture:metric-edge',dataVersion:1,ownerCanvasId:extensionRoot,source:{elementId:meter.id,portId:'custom-out'},target:{elementId:meterTwo.id,portId:'custom-in'},data:{metric:55},path:{type:'metric-curve',retained:true}}; + documents.set(extensionRoot,createDocument(extensionRoot,[{id:extensionRoot,parent:null}],[meter,meterTwo,meterThree,meterEdge,{...meterEdge,id:'unrelated-meter-edge',source:{elementId:'meter-two',portId:'custom-out'},target:{elementId:'meter-three',portId:'custom-in'}},{...meterEdge,id:'meter-builtin-edge',componentId:'connection',data:{},path:null}]));versions.set(extensionRoot,1);boards.push({id:extensionRoot,workspace_id:workspace,kind:'board',version:1,title:'Extension board'});await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${extensionRoot}`});await until(`document.querySelectorAll('[data-renderer-fixture="meter"]').length===3&&document.querySelectorAll('[data-renderer-fixture="metric-edge"]').length===2`,'host-installed custom node/edge renderers did not mount');await until(`document.querySelectorAll('.react-flow__edge').length===3`,'built-in edge did not connect custom renderer ports');assert.equal(await evaluate(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length`),0);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension baseline did not settle');await pause(200);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersBefore=await evaluate('({...window.__fixtureRenders})');await click('Increase meter-one');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data,{metric:4,raw:{retained:true,edited:true}});assert.deepEqual(documents.get(extensionRoot).elements.get('meter-edge'),meterEdge);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension edit/ACK did not settle');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersAfter=await evaluate('({...window.__fixtureRenders})');assert.ok(rendersAfter['meter-one']>rendersBefore['meter-one'],'edited custom node did not render');assert.equal(rendersAfter['meter-two'],rendersBefore['meter-two'],'unrelated custom node rendered during edit/ACK');assert.equal(rendersAfter['unrelated-meter-edge'],rendersBefore['unrelated-meter-edge'],'unrelated custom edge rendered during edit/ACK');console.log('PASS actual registered renderer updates the edited node while unrelated node/edge render counts remain unchanged through ACK'); await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover'); console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload'); diff --git a/tests/headless/renderers.test.mjs b/tests/headless/renderers.test.mjs index 3873ca4..61994ca 100644 --- a/tests/headless/renderers.test.mjs +++ b/tests/headless/renderers.test.mjs @@ -11,10 +11,10 @@ test('renderer catalog captures exact version/structure mappings without React v const input={...descriptor,render(){}};const catalog=createRendererCatalog([input]);input.rendererId='changed';assert.equal(catalog.resolve(node).rendererId,'meter');assert.equal(catalog.resolve({...node,dataVersion:2}),undefined);assert.equal(catalog.resolve({...node,structure:'connection'}),undefined);assert.equal(catalog.definitions[0].render,undefined);assert.ok(Object.isFrozen(catalog.definitions[0]));assert.throws(()=>createRendererCatalog([descriptor,descriptor]),/duplicate_renderer_definition/);assert.throws(()=>createRendererCatalog([{...descriptor,rendererId:'unsupported'}]),/invalid_renderer_definition/); }); test('React assembly preserves aliases only for the same component and rejects invalid renderers or model contracts',()=>{ - const render=()=>null,assembly=createReactRenderers([{...descriptor,render}],registry);assert.equal(assembly.nodeTypes.meter,render);assert.ok(Object.isFrozen(assembly.nodeTypes));assert.equal(assembly.edgeTypes.meter,undefined); + const render=()=>null,assembly=createReactRenderers([{...descriptor,render}],registry);assert.equal(assembly.nodeTypes.meter.type,render);assert.ok(Object.isFrozen(assembly.nodeTypes));assert.equal(assembly.edgeTypes.meter,undefined); assert.throws(()=>createReactRenderers([{...descriptor,render:null}]),/invalid_react_renderer/);assert.throws(()=>createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render:()=>null}]),/renderer_id_conflict/);assert.throws(()=>createReactRenderers([{...descriptor,dataVersion:2,render}],registry),/renderer_model_mismatch/); - let reads=0;const getter=createReactRenderers([{...descriptor,get render(){reads++;return render;}}]);assert.equal(reads,1);assert.equal(getter.definitions[0].render,getter.nodeTypes.meter); - const alias=createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render}]);assert.equal(alias.nodeTypes.meter,render);const memo={$$typeof:Symbol.for('react.memo'),type:render};assert.equal(createReactRenderers([{...descriptor,render:memo}]).nodeTypes.meter,memo); + let reads=0;const getter=createReactRenderers([{...descriptor,get render(){reads++;return render;}}]);assert.equal(reads,1);assert.equal(getter.definitions[0].render,render);assert.equal(getter.nodeTypes.meter,assembly.nodeTypes.meter); + const alias=createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render}]);assert.equal(alias.nodeTypes.meter,assembly.nodeTypes.meter);const memo={$$typeof:Symbol.for('react.memo'),type:render};assert.equal(createReactRenderers([{...descriptor,render:memo}]).nodeTypes.meter,memo); }); test('installed node and edge renderers receive original records and transforms while absent schemas still fall back',()=>{ const customEdge=defineComponent({id:'plugin:edge',dataVersion:1,structure:'connection',validate:()=>true,features:[]}),r=createRegistry([...builtinComponents(),definition,customEdge]),other={...node,id:'second',geometry:{...node.geometry,x:300}},edge={id:'edge',incarnation:1,structure:'connection',componentId:'plugin:edge',dataVersion:1,ownerCanvasId:'root',source:{elementId:'meter',portId:'custom-out'},target:{elementId:'second',portId:'custom-in'},path:{type:'custom',retained:true},data:{path:{own:true},metric:99}},source=createDocument('project',[{id:'root',parent:null}],[node,other,edge]),catalog=createRendererCatalog([...builtinRendererDefinitions,descriptor,{...descriptor,componentId:'plugin:edge',structure:'connection',rendererId:'meter-edge'}]),p=projectCanvas(source,'root',r,new Set(),()=>undefined,null,catalog); @@ -22,5 +22,5 @@ test('installed node and edge renderers receive original records and transforms const missing=projectCanvas(source,'root',r,new Set(),()=>undefined);assert.equal(missing.nodes[0].type,'unsupported');assert.equal(missing.edges[0].type,'unsupported');const future=createDocument('project',[{id:'root',parent:null}],[{...node,dataVersion:2}]);assert.equal(projectCanvas(future,'root',r,new Set(),()=>undefined,null,catalog).nodes[0].type,'unsupported'); }); test('pre-engine preview renderers are explicit and captured independently from engine-bound renderers',()=>{ - const render=()=>null,preview=()=>null,assembly=createReactRenderers([{...descriptor,render,preview}]);assert.equal(assembly.nodeTypes.meter,render);assert.equal(assembly.previewNodeTypes.meter,preview);assert.equal(assembly.definitions[0].preview,preview);assert.ok(Object.isFrozen(assembly.previewNodeTypes));assert.equal(createReactRenderers([{...descriptor,render}]).previewNodeTypes.meter,undefined);assert.throws(()=>createReactRenderers([{...descriptor,render,preview:0}]),/invalid_react_preview/);assert.throws(()=>createReactRenderers([{...descriptor,render,preview},{...descriptor,componentId:'other',render}]),/renderer_id_conflict/); + const render=()=>null,preview=()=>null,assembly=createReactRenderers([{...descriptor,render,preview}]);assert.equal(assembly.nodeTypes.meter.type,render);assert.equal(assembly.previewNodeTypes.meter.type,preview);assert.equal(assembly.definitions[0].preview,preview);assert.ok(Object.isFrozen(assembly.previewNodeTypes));assert.equal(createReactRenderers([{...descriptor,render}]).previewNodeTypes.meter,undefined);assert.throws(()=>createReactRenderers([{...descriptor,render,preview:0}]),/invalid_react_preview/);assert.throws(()=>createReactRenderers([{...descriptor,render,preview},{...descriptor,componentId:'other',render}]),/renderer_id_conflict/); });