Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1478,15 +1478,22 @@ export default function App() {

const handleAddNode = useCallback(
(kind: NodeKind, x: number, y: number) => {
const node = makeNode(kind, x, y);
// Read the live mirror, not the React binding: two adds in quick
// succession both run against the closure's pre-add snapshot, so the
// second would build on a topology missing the first node and drop
// it (see topoLiveRef, and #47 which found the stale closure first).
// The mirror also supplies the ids already on the canvas, so a fresh
// page load cannot mint an id a restored design is using (makeNode).
const t = topoLiveRef.current;
const node = makeNode(kind, x, y, undefined, new Set(t.nodes.map((n) => n.id)));
history.commit('add', snapRef.current);
applyTopology({
...topology,
nodes: [...topology.nodes, node],
...t,
nodes: [...t.nodes, node],
});
setSelectedIds(new Set([node.id]));
},
[applyTopology, topology, history],
[applyTopology, history],
);

/**
Expand Down
25 changes: 25 additions & 0 deletions src/sim/makeNode.ids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { makeNode } from './presets';

describe('makeNode', () => {
it('never reuses an id that is already on the canvas', () => {
// A design restored after a reload carries ids minted by an earlier
// session, while the module counter has restarted from zero.
const taken = new Set(Array.from({ length: 10 }, (_, i) => `service-${i + 1}`));
const added = makeNode('service', 0, 0, undefined, taken);
expect(taken.has(added.id)).toBe(false);
expect(added.id).toMatch(/^service-\d+$/);
});

it('keeps minting distinct ids across repeated adds', () => {
const taken = new Set<string>(['cache-1', 'cache-2', 'cache-3']);
const seen = new Set<string>();
for (let i = 0; i < 5; i += 1) {
const n = makeNode('cache', 0, 0, undefined, taken);
expect(taken.has(n.id)).toBe(false);
expect(seen.has(n.id)).toBe(false);
seen.add(n.id);
taken.add(n.id);
}
});
});
21 changes: 19 additions & 2 deletions src/sim/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,15 +652,32 @@ const DEFAULT_LABEL: Record<NodeKind, string> = {

let nodeCounter = 0;

/**
* Mint a node for the canvas.
*
* `taken` is the set of ids already on the canvas. It has to be consulted:
* the counter above restarts at zero on every page load, while the ids it
* minted in earlier sessions come back through localStorage, share links
* and design files. Without the check the eleventh service added after a
* reload is `service-1` again, and two nodes then share one id: selecting
* either selects both, the inspector reads "2 components", edges drawn to
* one land on both, and a config change applies to both. The paste path
* already dedupes this way (clipboard.ts freshId); the add path did not.
*/
export function makeNode(
kind: NodeKind,
x: number,
y: number,
label?: string,
taken?: ReadonlySet<string>,
): SimNode {
nodeCounter += 1;
let id: string;
do {
nodeCounter += 1;
id = `${kind}-${nodeCounter}`;
} while (taken !== undefined && taken.has(id));
return {
id: `${kind}-${nodeCounter}`,
id,
kind,
label: label ?? DEFAULT_LABEL[kind],
x,
Expand Down
Loading