Skip to content

Commit 2b30d39

Browse files
fix(app-shell): the flow designer's Remove node takes its edges, and a new node never inherits a removed one's (objectui#11772) (#11825)
Fixes #11772 Clause-②: no The objectui half of objectstack#22088 (the server half lands independently). ## What was wrong, measured on `main` (`9990f9e`) The card's reproduction, driven through the real `FlowPreview` canvas and the real `FlowInspector` over a host that merges each patch the way the Studio Automations pillar does (`{ ...d, ...patch }`), on the draft Studio creates for a new flow (`start → end`, edge `e1`): 1. the edge's "Insert node here" adds `node_1` (`e1: start → node_1`, `edge_1: node_1 → end`); 2. the inspector's "Remove node" left both edges in the draft, naming a node that no longer existed. The saved draft was exactly the card's second reproduction: nodes `[start, end]`, edges `[start → node_1, node_1 → end]`; 3. "Add connected node" on `start` minted `node_1` again, so both stale edges re-attached to the new node. On `main` the new pin file went red on exactly that: 6 failed, 4 passed (the 4 were the controls and the dangling-edge rows, which already worked; see Hypothesis 4). ## What changed - **Remove node removes the node's edges in the same patch.** One function, `edgesAfterNodeRemoval`, serves both removal gestures: the inspector's "Remove node" and the canvas's Delete key. The inspector can see `draft.edges`, and its host applies patches shallowly (`MetadataInspectorProps.onPatch`: "Apply a shallow patch to the draft"), so the edges go into the same patch as the node. `StudioDesignSurface.tsx` is not touched. - **A node on a single path is spliced out**: its predecessor is reconnected to its successor. Single path, read off the edge shapes: - exactly one edge in (P to X) and exactly one edge out (X to S), not one self-loop edge; - the edge in is not a declared back-edge (retargeting a loop's closing hop would change what the loop re-enters); - the edge out is plain: no condition, no label, not the default branch, type `default`. So X made no routing choice of its own that the splice would drop; - P and S are other nodes of the flow, and P is not S; - no edge already joins P to S the same way (`edgeRouteKey`), so the splice never draws the repeated connection the Problems panel now flags. The reconnected edge is the edge in, retargeted to S, in the edge in's place. Its id, condition, label, default flag and type are P's routing choice, so they stay. A decision branch that led to X now leads to S, at the same position in the declaration order the engine evaluates branches in. This is exactly the inverse of the canvas's insert-on-edge, which splits P to S into the original edge retargeted to X (in place) plus an appended plain X to S. So inserting a node on an edge and removing it gives the draft back byte for byte (pinned). Anything else (a branch node, a join, a node whose edge out is guarded or labelled, the start or the end) loses its edges and is not reconnected. - **A new node never takes an id that existed in this editing session.** The four node minters (the canvas's add, insert-on-edge and revise loop, and the empty flow's first node) now go through `freshNodeId`: `uniqueId('node', …)` over the node ids, both endpoints of every edge, and the ids `FlowPreview` has seen this session. The session is the `FlowPreview` mount; Studio remounts it when another flow is opened. A draft with nothing removed and nothing dangling mints exactly what it minted before (pinned as a control). The shared `uniqueId` contract is unchanged; its widget, `kv`, `ol`, `sl` and edge callers are untouched. - **The Problems panel flags a repeated connection**: an edge that joins the same two nodes the same way as an earlier edge (same type, same condition read through `conditionText`, same default flag, same label). Each extra copy is its own error row, targeted at that copy's own edge key, so clicking the row selects the copy to remove in the edge inspector. Two edges that join the same nodes differently (two decision branches to one node, an approval's `approve` and `reject` to one node) are not flagged. New row `engine.flowProblems.repeatedEdge`, en and zh. ## The PM's mechanism hypotheses, measured - **H1 (where the pruning lives): option one.** `remove` reaches the draft through `loc.write(null)` and `onPatch`, and the host merges shallowly, so the edges fit in the same patch from the inspector. The canvas's Delete key already pruned edges with its own copy of the filter; it now calls the same function, which adds the single-path splice there too. - **H2 (the id rule's home): at the flow callers, not in `uniqueId`.** `uniqueId` keeps its lowest-free-suffix contract. Edge ids keep that plain rule: nothing in a flow refers to an edge by its id (the engine routes by endpoints, condition and label), so a reused edge id cannot re-attach anything. The edge minters in `FlowCanvas.tsx` are unchanged. - **H3 (single path): defined above, with one difference from the example in the brief.** The brief's example excluded a decision branch and a fault edge. Here the edge IN may be a decision branch or a fault edge, because its attributes are the predecessor's routing and survive the splice unchanged, the same way insert-on-edge keeps them on the first half. The edge OUT must be plain. A node that is itself a branch point (several edges out, or a guarded or labelled edge out) is not reconnected. - **H4 (Problems panel): half falsified.** An edge whose source or target is not a node was already flagged, by `validateFlowDraft` (`engine.flowValidate.edgeSourceMissing` / `edgeTargetMissing`). Measured on `main`: the card's second-reproduction draft already shows both rows, in en and zh. No second check was added, which would have reported each edge twice; the existing rows are now pinned through the real panel. The repeated pair was not flagged anywhere, and the card's published flow (three copies of `start → node_1`) read "No problems". That half is new. - **H5 (publish): the client does not block publish.** `buildFlowProblems` feeds only the canvas banner, the badges, the red ring and stroke (`deriveInvalidElements`), and the Problems panel. Nothing on the Studio save or publish path reads them, and Studio passes no server diagnostics to the flow preview. What an author with a dangling or repeated edge can do: see the error in the banner and the panel, click the row to select that edge, and remove it with the edge inspector's Remove. They can still save the draft and publish, until objectstack#22088 makes the server refuse such a flow. Stored flows are not migrated. ## Tests Pins (all through the real components): - `previews/FlowPreview.removeNode-11772.test.tsx`: the card's reproduction end to end; the insert-then-remove round trip, byte for byte; the control (nothing removed: `node_1` and `edge_1` as before); a removed id with no edges is not minted again in the session; a stored dangling edge's id is not minted; the Problems panel rows for the dangling edges and the repeated copies, in en and zh; the skeleton draws no row. - `inspectors/FlowNodeInspector.removeEdges-11772.test.tsx`: nine edge shapes through the real Remove button (single path, decision branch, branch node, guarded edge out, labelled edge out, back-edge in, already-joined ends, join, dangling edge in), plus a draft holding a duplicate node id. For each of the nine, the canvas's Delete key produces the same edges as the inspector. - `previews/flow-problems.repeatedEdge-11772.test.ts`: one error per extra copy with its own edge key, an id-less copy keyed by index, zh, the condition spellings agree, the four "joined differently" shapes stay unflagged, and a control. Ablations, each through `ablation-replace.mjs` from the committed implementation at `d2b3a07`, restore proven by blob hash equal to HEAD and an empty `git diff HEAD`: | Mutation | Result | |---|---| | inspector `remove` writes the node only (the old behaviour) | 30 of 38 red, including the card's reproduction and the round trip | | `freshNodeId` ignores edge endpoints and the session | 3 of 10 red: the card's reproduction, the session id, the stored dangling id | | the repeated-connection check removed | 6 of 17 red (en, zh, keys, red stroke); the "joined differently" controls stay green | | the single-path splice disabled | 3 of 38 red: the round trip, the single-path splice, the decision-branch splice | | the "already joined" guard removed | 1 of 28 red: the no-second-copy pin | Each local reading, with the commit it ran at, is in the report comment on the card. The full `@object-ui/app-shell` suite (1,062 test files) was not run locally: two attempts exceeded the container's 10-minute foreground limit on a shared box. Instead the narrowed set ran: every test file that names a changed module or a changed gesture (51 files). The full suite is left to CI. ## Files Everything is on the claim's file surface. `FlowCanvas.tsx` changes beyond its `uniqueId('node', …)` callers in one place: its Delete key handler calls the shared removal function, so the two removal gestures agree. `ProblemsPanel.tsx` needed no change: the new rows render like every other edge problem. Nothing is added to the package entry. The changed modules are not reachable from `dist/index.d.ts`, checked by walking its relative imports after a build, with `preview-registry.d.ts` as the reachable positive control. Patch changeset on `@object-ui/app-shell`. ## Acceptance notes - objectui#11778 (the three add-node entry points, the `create_record` default type, `notify` in the Node Type select) is not addressed here. - Out of scope, reported to the seat rather than fixed here: renaming a node's id in the inspector's ID field leaves the node's edges naming the old id. The rename patch writes only `nodes`, and the field commits on every keystroke. Measured with a probe on this branch: the patch carries no `edges`. After this PR the old id is never minted again and the Problems panel names the dangling edges, but the renamed node is still disconnected. Rewriting the endpoints on every keystroke could join a half-typed id to another node's edges, so this needs its own design. - Stored flows that already carry dangling or repeated edges are not rewritten. The Problems panel now names both kinds so an author can repair them. --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 282f252 commit 2b30d39

9 files changed

Lines changed: 930 additions & 17 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
The flow designer's Remove node takes the node's edges with it, and a new node never inherits the edges a removed one left behind (objectui#11772, the objectui half of objectstack#22088).
6+
7+
Removing a node from the flow inspector left every edge that named it in the draft. The next node the designer added got the same id (`node_1`), so those edges re-attached to it, drawn on top of each other where the author saw one line. A published flow then ran that node once per edge: one record update created the same follow-up record three times.
8+
9+
- **Remove node removes the node's edges in the same change.** When the node sat on a single path (one edge in, and one edge out with no condition, label or default flag), its predecessor is reconnected to its successor through the edge that led in, which keeps its id, condition, label and position. So removing a node that was inserted on a connection gives that connection back. A decision, a join, or a node whose edge out carries a condition or label loses its edges and is not reconnected. The canvas's Delete key makes the same removal.
10+
- **A new node never takes an id the editing session has seen**, on a node or at either end of an edge. A flow saved with an edge to a missing node no longer hands that edge to the next node added.
11+
- **The Problems panel flags a repeated connection**: an edge that joins the same two nodes the same way (the same type, condition, default flag and label) as an earlier edge. Each extra copy is its own row, and clicking the row selects that copy so the author can remove it. Two edges that join the same nodes differently, such as two decision branches leading to one node, are not flagged. An edge to a missing node was already flagged. Stored flows are not rewritten: these rows are how an author finds and repairs one.
12+
13+
Nothing is added to the package entry: no export, prop, type member or language-pack key. The new Problems-panel row lives in the metadata-admin designer's own string tables (en and zh).

‎packages/app-shell/src/views/metadata-admin/i18n.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1942,6 +1942,9 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
19421942
'engine.flowProblems.empty': 'No problems — this flow is structurally valid.',
19431943
'engine.flowProblems.sourceSchema': 'schema',
19441944
'engine.flowProblems.sourceExpression': 'expression',
1945+
// objectui#11772 — one extra copy of a connection (`edgeRouteKey`): the
1946+
// engine follows every copy, so the target runs once per copy.
1947+
'engine.flowProblems.repeatedEdge': 'Connection {source} → {target} is repeated; the flow runs "{target}" once for each copy. Remove this copy.',
19451948
// References side panel (ResourceEditPage) empty state.
19461949
'engine.edit.refsScanning': 'Scanning references…',
19471950
'engine.edit.refsEmptyTitle': 'No references found',
@@ -4913,6 +4916,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
49134916
'engine.flowProblems.empty': '没有问题 —— 该流程结构有效。',
49144917
'engine.flowProblems.sourceSchema': '架构',
49154918
'engine.flowProblems.sourceExpression': '表达式',
4919+
// objectui#11772 — one extra copy of a connection (see the en row).
4920+
'engine.flowProblems.repeatedEdge': '连线 {source} → {target} 重复;流程会为每一条副本各运行一次 “{target}”。请删除这条副本。',
49164921
// 引用关系侧栏(ResourceEditPage)空态。
49174922
'engine.edit.refsScanning': '正在扫描引用…',
49184923
'engine.edit.refsEmptyTitle': '未找到引用',
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11772 — the inspector's "Remove node" removes the edges that name
5+
* the node in the SAME patch, and splices a node on a single path out
6+
* (predecessor → successor). Every case presses the real Remove button and
7+
* reads the patch the inspector hands its host.
8+
*
9+
* The canvas's Delete key makes the same removal through the same function
10+
* (`edgesAfterNodeRemoval`); the last describe presses both on every fixture
11+
* here and requires the two patches' edges to be equal.
12+
*/
13+
14+
import * as React from 'react';
15+
import { describe, it, expect, vi, afterEach } from 'vitest';
16+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
17+
18+
vi.mock('../previews/useFlowNodePalette', () => ({
19+
useActionConfigSchemas: () => ({}),
20+
useFlowNodePalette: () => [],
21+
}));
22+
vi.mock('../previews/useObjectFields', () => ({
23+
useObjectFields: () => ({ fields: [], loading: false, error: null }),
24+
}));
25+
26+
import { FlowNodeInspector } from './FlowNodeInspector';
27+
import { FlowCanvas } from '../previews/FlowCanvas';
28+
import { t } from '../i18n';
29+
30+
afterEach(cleanup);
31+
32+
// The node inspectors read the object catalog (`/api/v1/meta/object`); answer
33+
// every read as an absent engine. Installed once for the whole file and never
34+
// torn down, so no read flushed after a test body can reach a real socket
35+
// (the shape `RecordDetailView.approvalDeclaredActions.test.tsx` documents).
36+
vi.stubGlobal(
37+
'fetch',
38+
vi.fn(async () => new Response('not found', { status: 404 })),
39+
);
40+
41+
type Edge = { id?: string; source: string; target: string } & Record<string, unknown>;
42+
type Node = { id: string; type: string } & Record<string, unknown>;
43+
interface Fixture {
44+
nodes: Node[];
45+
edges: Edge[];
46+
}
47+
48+
/** Press the inspector's Remove on `id`; the patch it hands the host. */
49+
function removeWithInspector(draft: Fixture, id: string): { nodes: Node[]; edges: Edge[] } {
50+
const onPatch = vi.fn();
51+
render(
52+
<FlowNodeInspector
53+
type="flow"
54+
name="f"
55+
draft={draft as unknown as Record<string, unknown>}
56+
selection={{ kind: 'node', id }}
57+
onPatch={onPatch}
58+
onClearSelection={() => {}}
59+
readOnly={false}
60+
locale="en-US"
61+
/>,
62+
);
63+
fireEvent.click(screen.getByRole('button', { name: t('engine.inspector.flowNode.remove', 'en-US') }));
64+
expect(onPatch).toHaveBeenCalledTimes(1);
65+
cleanup();
66+
return onPatch.mock.calls[0][0];
67+
}
68+
69+
/** Press Delete on the canvas with `id` selected; the patch it hands the host. */
70+
function removeWithCanvas(draft: Fixture, id: string): { nodes: Node[]; edges: Edge[] } {
71+
const onPatch = vi.fn();
72+
render(
73+
<FlowCanvas
74+
nodes={draft.nodes}
75+
edges={draft.edges as never}
76+
editable
77+
designMode
78+
selectedId={id}
79+
onSelect={() => {}}
80+
onPatch={onPatch}
81+
/>,
82+
);
83+
fireEvent.keyDown(screen.getByRole('application', { name: t('engine.flowCanvas.canvas', 'en-US') }), { key: 'Delete' });
84+
expect(onPatch).toHaveBeenCalledTimes(1);
85+
cleanup();
86+
return onPatch.mock.calls[0][0];
87+
}
88+
89+
const naming = (edges: Edge[], id: string) => edges.filter((e) => e.source === id || e.target === id);
90+
91+
const FIXTURES: Record<string, { draft: Fixture; remove: string }> = {
92+
'a single path s → x → e': {
93+
remove: 'x',
94+
draft: {
95+
nodes: [{ id: 's', type: 'start' }, { id: 'x', type: 'create_record' }, { id: 'e', type: 'end' }],
96+
edges: [
97+
{ id: 'in', source: 's', target: 'x' },
98+
{ id: 'out', source: 'x', target: 'e' },
99+
],
100+
},
101+
},
102+
'a node on a decision branch': {
103+
remove: 'x',
104+
draft: {
105+
nodes: [
106+
{ id: 'd', type: 'decision' },
107+
{ id: 'x', type: 'create_record' },
108+
{ id: 'y', type: 'create_record' },
109+
{ id: 'e', type: 'end' },
110+
],
111+
edges: [
112+
{ id: 'big', source: 'd', target: 'x', condition: 'amount > 100', label: 'Big' },
113+
{ id: 'else', source: 'd', target: 'y', isDefault: true },
114+
{ id: 'x-e', source: 'x', target: 'e' },
115+
{ id: 'y-e', source: 'y', target: 'e' },
116+
],
117+
},
118+
},
119+
'a branch node (a decision with two edges out)': {
120+
remove: 'd',
121+
draft: {
122+
nodes: [
123+
{ id: 's', type: 'start' },
124+
{ id: 'd', type: 'decision' },
125+
{ id: 'x', type: 'create_record' },
126+
{ id: 'y', type: 'create_record' },
127+
],
128+
edges: [
129+
{ id: 's-d', source: 's', target: 'd' },
130+
{ id: 'big', source: 'd', target: 'x', condition: 'amount > 100' },
131+
{ id: 'else', source: 'd', target: 'y', isDefault: true },
132+
],
133+
},
134+
},
135+
'a node whose one edge out is guarded': {
136+
remove: 'x',
137+
draft: {
138+
nodes: [{ id: 's', type: 'start' }, { id: 'x', type: 'create_record' }, { id: 'e', type: 'end' }],
139+
edges: [
140+
{ id: 'in', source: 's', target: 'x' },
141+
{ id: 'out', source: 'x', target: 'e', condition: 'ok == true' },
142+
],
143+
},
144+
},
145+
'a node whose one edge out is labelled (an approval outcome)': {
146+
remove: 'x',
147+
draft: {
148+
nodes: [{ id: 's', type: 'start' }, { id: 'x', type: 'approval' }, { id: 'e', type: 'end' }],
149+
edges: [
150+
{ id: 'in', source: 's', target: 'x' },
151+
{ id: 'out', source: 'x', target: 'e', label: 'approve' },
152+
],
153+
},
154+
},
155+
'a node entered only by a declared back-edge': {
156+
remove: 'w',
157+
draft: {
158+
nodes: [{ id: 'a', type: 'approval' }, { id: 'w', type: 'wait' }, { id: 'e', type: 'end' }],
159+
edges: [
160+
{ id: 'back', source: 'a', target: 'w', type: 'back' },
161+
{ id: 'out', source: 'w', target: 'e' },
162+
],
163+
},
164+
},
165+
'a single path whose ends are already joined the same way': {
166+
remove: 'x',
167+
draft: {
168+
nodes: [{ id: 's', type: 'start' }, { id: 'x', type: 'create_record' }, { id: 'e', type: 'end' }],
169+
edges: [
170+
{ id: 'in', source: 's', target: 'x' },
171+
{ id: 'out', source: 'x', target: 'e' },
172+
{ id: 'direct', source: 's', target: 'e' },
173+
],
174+
},
175+
},
176+
'a join (two edges in)': {
177+
remove: 'x',
178+
draft: {
179+
nodes: [{ id: 'a', type: 'start' }, { id: 'b', type: 'create_record' }, { id: 'x', type: 'create_record' }, { id: 'e', type: 'end' }],
180+
edges: [
181+
{ id: 'a-b', source: 'a', target: 'b' },
182+
{ id: 'a-x', source: 'a', target: 'x' },
183+
{ id: 'b-x', source: 'b', target: 'x' },
184+
{ id: 'x-e', source: 'x', target: 'e' },
185+
],
186+
},
187+
},
188+
'a node whose edge in already names a missing node': {
189+
remove: 'x',
190+
draft: {
191+
nodes: [{ id: 'x', type: 'create_record' }, { id: 'e', type: 'end' }],
192+
edges: [
193+
{ id: 'in', source: 'ghost', target: 'x' },
194+
{ id: 'out', source: 'x', target: 'e' },
195+
],
196+
},
197+
},
198+
};
199+
200+
describe('FlowNodeInspector — Remove node takes the edges that name it (objectui#11772)', () => {
201+
for (const [name, { draft, remove }] of Object.entries(FIXTURES)) {
202+
it(`${name}: no edge left names the removed node`, () => {
203+
const patch = removeWithInspector(draft, remove);
204+
expect(patch.nodes.map((n) => n.id)).toEqual(draft.nodes.map((n) => n.id).filter((id) => id !== remove));
205+
expect(naming(patch.edges, remove)).toEqual([]);
206+
});
207+
}
208+
209+
it('a single path is spliced out: the edge in is kept, retargeted to the successor', () => {
210+
const { edges } = removeWithInspector(FIXTURES['a single path s → x → e'].draft, 'x');
211+
expect(edges).toEqual([{ id: 'in', source: 's', target: 'e' }]);
212+
});
213+
214+
it('a decision branch through the removed node now leads to its successor, guard, label and position kept', () => {
215+
const { edges } = removeWithInspector(FIXTURES['a node on a decision branch'].draft, 'x');
216+
expect(edges).toEqual([
217+
{ id: 'big', source: 'd', target: 'e', condition: 'amount > 100', label: 'Big' },
218+
{ id: 'else', source: 'd', target: 'y', isDefault: true },
219+
{ id: 'y-e', source: 'y', target: 'e' },
220+
]);
221+
});
222+
223+
it('a branch node loses its edges and nothing is reconnected', () => {
224+
const { edges } = removeWithInspector(FIXTURES['a branch node (a decision with two edges out)'].draft, 'd');
225+
expect(edges).toEqual([]);
226+
});
227+
228+
for (const name of [
229+
'a node whose one edge out is guarded',
230+
'a node whose one edge out is labelled (an approval outcome)',
231+
'a node entered only by a declared back-edge',
232+
'a join (two edges in)',
233+
'a node whose edge in already names a missing node',
234+
]) {
235+
it(`${name}: dropped, not reconnected`, () => {
236+
const { draft, remove } = FIXTURES[name];
237+
const { edges } = removeWithInspector(draft, remove);
238+
expect(edges).toEqual(draft.edges.filter((e) => e.source !== remove && e.target !== remove));
239+
});
240+
}
241+
242+
it('a splice never draws a second copy of a connection that already exists', () => {
243+
const { edges } = removeWithInspector(FIXTURES['a single path whose ends are already joined the same way'].draft, 'x');
244+
expect(edges).toEqual([{ id: 'direct', source: 's', target: 'e' }]);
245+
});
246+
247+
it('while another node still carries the removed id, its edges are that node’s and stay', () => {
248+
const draft: Fixture = {
249+
nodes: [{ id: 's', type: 'start' }, { id: 'x', type: 'create_record', label: 'first' }, { id: 'x', type: 'create_record', label: 'second' }],
250+
edges: [{ id: 'in', source: 's', target: 'x' }],
251+
};
252+
const patch = removeWithInspector(draft, 'x');
253+
expect(patch.nodes.map((n) => n.label ?? n.id)).toEqual(['s', 'second']);
254+
expect(patch.edges).toEqual(draft.edges);
255+
});
256+
});
257+
258+
describe('the canvas Delete key makes the very removal the inspector makes (objectui#11772)', () => {
259+
for (const [name, { draft, remove }] of Object.entries(FIXTURES)) {
260+
it(name, () => {
261+
expect(removeWithCanvas(draft, remove).edges).toEqual(removeWithInspector(draft, remove).edges);
262+
});
263+
}
264+
});

‎packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import { NESTED_NODE_KIND, parseNestedNodeId, locateFlowNode, type InspectorFlow
6464
import { displayRegionLabel } from '../previews/flow-region-label.js';
6565
import type { FlowDesignerEdge } from '../previews/flow-canvas-layout.js';
6666
import { ScreenPreview } from '../previews/ScreenPreview.js';
67+
import { edgesAfterNodeRemoval } from '../previews/flow-problems.js';
6768

6869
/**
6970
* The node and edge shapes this panel edits — ALIASED, never restated
@@ -421,8 +422,23 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
421422
}
422423
};
423424

425+
// objectui#11772 — removing a node removes the edges that name it in the
426+
// SAME patch, splicing a single-path node out (predecessor → successor): the
427+
// canvas's Delete key makes the identical removal (`edgesAfterNodeRemoval`).
428+
// Writing `null` to the node alone left every edge naming it behind, and the
429+
// next node minted with that id inherited them all. Top-level only: a nested
430+
// node has no Remove (its routing lives in its region, not on `draft.edges`).
424431
const remove = () => {
425432
const patch = loc?.write(null);
433+
if (patch && !loc?.nested) {
434+
const draftEdges = Array.isArray((draft as { edges?: unknown }).edges)
435+
? ((draft as { edges: FlowEdge[] }).edges)
436+
: [];
437+
const remaining = new Set(
438+
(patch.nodes as Array<{ id?: unknown } | null>).flatMap((n) => (typeof n?.id === 'string' ? [n.id] : [])),
439+
);
440+
patch.edges = edgesAfterNodeRemoval(draftEdges, node.id, remaining);
441+
}
426442
if (patch) onPatch(patch);
427443
onClearSelection();
428444
};

0 commit comments

Comments
 (0)