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) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-25 14:42:26 +02:00
parent 7477027319
commit 7c30ba1a38
4 changed files with 45 additions and 5 deletions

View File

@ -93,7 +93,10 @@
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
"builder": "@angular/build:unit-test",
"options": {
"runnerConfig": "vitest.config.ts"
}
}
}
}

View File

@ -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<void> | 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();

View File

@ -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<ReteEditorInstance> {
const editor = new NodeEditor<HFSchemes>();
@ -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<HFSchemes>,
area: AreaPlugin<HFSchemes, AreaExtra>,
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<string, any>();
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;

11
vitest.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
server: {
deps: {
inline: [/node_modules\/rete/]
}
}
}
});