From 7c30ba1a38a97357b3d79ac91c91fa1822e2d2aa Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Fri, 25 Sep 2026 14:42:26 +0200 Subject: [PATCH] Give the Rete plugins one rete in tests, and stop an abandoned editor load Five unhandled rejections came with every test run, from the pages that render a real graph. In tests, rete-angular-plugin was loaded by Node and got rete's CommonJS build while everything else got its ES module, so the renderer was not a Scope of the rete it was plugged into. Vitest now processes the rete packages itself, and all of them share one rete. With the graph actually built, a real bug showed: an editor still loading when its component was destroyed went on mounting nodes with the component's destroyed injector. A load now stops once its component is destroyed or a newer load begins - between nodes and connections, and at every render, which is dropped for an abandoned editor. Co-Authored-By: Claude Opus 5.5 (1M context) --- angular.json | 5 ++++- src/app/shared/rete-editor/rete-editor.ts | 9 +++++++- src/app/utilities/rete-editor.ts | 25 ++++++++++++++++++++--- vitest.config.ts | 11 ++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 vitest.config.ts diff --git a/angular.json b/angular.json index 983dea2..e6fd4db 100644 --- a/angular.json +++ b/angular.json @@ -93,7 +93,10 @@ "defaultConfiguration": "development" }, "test": { - "builder": "@angular/build:unit-test" + "builder": "@angular/build:unit-test", + "options": { + "runnerConfig": "vitest.config.ts" + } } } } diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index e276437..fdfe25c 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -64,6 +64,7 @@ export class ReteEditor implements OnChanges, OnDestroy { private rete?: ReteEditorInstance; private viewReady = false; private loadVersion = 0; + private destroyed = false; private suppressDirtyEvents = false; private typesLoadingPromise: Promise | null = null; creatingEmptyBlock = false; @@ -127,6 +128,10 @@ export class ReteEditor implements OnChanges, OnDestroy { } ngOnDestroy(): void { + // A load still under way would go on mounting nodes with this component's injector, which is + // about to be destroyed. Moving the version on makes that load stop at its next node. + this.loadVersion++; + this.destroyed = true; this.rete?.area?.destroy?.(); this.rete = undefined; this.graphSelection.clearConnectionSelection(); @@ -296,6 +301,7 @@ export class ReteEditor implements OnChanges, OnDestroy { if (!host) return; await this.ensureNodeTypesLoaded(); + if (this.destroyed) return; const currentVersion = ++this.loadVersion; this.editorMode.set('standard'); @@ -311,7 +317,8 @@ export class ReteEditor implements OnChanges, OnDestroy { const rete = await createEditor(host, this.injector, this.flowData(), { nodeView: this.nodeView(), - readonly: this.readonly() + readonly: this.readonly(), + isStale: () => currentVersion !== this.loadVersion }); if (currentVersion !== this.loadVersion) { rete.area.destroy(); diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 5fa19bd..b5b255b 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -85,7 +85,12 @@ export async function createEditor( container: HTMLElement, injector: Injector, flowData: FlowData, - options?: { nodeView?: "editor" | "execution"; readonly?: boolean } + options?: { + nodeView?: "editor" | "execution"; + readonly?: boolean; + /** True once the caller no longer wants this editor; loading stops at the next node. */ + isStale?: () => boolean; + } ): Promise { const editor = new NodeEditor(); @@ -193,6 +198,15 @@ export async function createEditor( AreaExtensions.simpleNodesOrder(area); + const isStale = options?.isStale ?? (() => false); + // Registered before the Angular renderer, so it sees each render first. An editor its caller has + // given up on - the component destroyed, or a newer load begun - must not mount anything more: + // the components would be created with an injector that is already gone. + area.addPipe((context: any) => { + if (context?.type === 'render' && isStale()) return; + return context; + }); + area.addPipe((context: any) => { if (readonly && nodeView !== "execution" && context?.type === 'nodetranslate') { const nodeId = String(context?.data?.id ?? ''); @@ -211,7 +225,8 @@ export async function createEditor( }); if (flowData) - await loadFlowData(editor, area, flowData, runtime); + await loadFlowData(editor, area, flowData, runtime, isStale); + if (isStale()) return { editor, area }; await refreshLoopMarkers(editor, area, runtime); AreaExtensions.zoomAt(area, editor.getNodes()); @@ -656,7 +671,8 @@ async function loadFlowData( editor: NodeEditor, area: AreaPlugin, flowData: FlowData, - runtime?: ReteRuntimeContext + runtime?: ReteRuntimeContext, + isStale: () => boolean = () => false ) { const topLevelNodes = [...(flowData.blocks ?? []), ...(flowData.containers ?? [])]; if (!topLevelNodes.length) return; @@ -664,6 +680,7 @@ async function loadFlowData( const nodeMapping = new Map(); for (const [index, block] of topLevelNodes.entries()) { + if (isStale()) return; const fallbackPosition = block.position ?? { x: 120 + (index % 3) * 340, y: 100 + Math.floor(index / 3) * 220 @@ -674,6 +691,7 @@ async function loadFlowData( await withRestoredConnections(runtime, async () => { for (const c of flowData.connections ?? []) { + if (isStale()) return; if (!nodeMapping.has(c.sourceId) || !nodeMapping.has(c.targetId)) continue; const sourceNode = editor.getNode(nodeMapping.get(c.sourceId)) as any; @@ -688,6 +706,7 @@ async function loadFlowData( } for (const dependency of flowData.dependencies ?? []) { + if (isStale()) return; if (!nodeMapping.has(dependency.sourceId) || !nodeMapping.has(dependency.targetId)) continue; const sourceNode = editor.getNode(nodeMapping.get(dependency.sourceId)) as any; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..49a632a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + server: { + deps: { + inline: [/node_modules\/rete/] + } + } + } +});