What
Dragging a component in from the rail can mint an id a node in the design already has. Nothing
notices at the time, the design keeps working for the rest of the session, and then the next
reload silently replaces it with the Single Server preset.
It is the autosave that breaks, so there is no error and nothing to undo. You come back to what
looks like a fresh app and the work is gone.
Reproducing it without a reload
makeNode and the paste path disagree about ids inside a single session:
Ctrl+D mints : db-1
palette add mints : db-1
same id? : true
topology ids : client,api,db,db-1,db-1
isTopology accepts : false
Ctrl+D goes through freshId, which scans the topology, so it correctly picks db-1. A palette
drag goes through makeNode, which does not scan, and the module counter is independently
sitting at 0, so it picks db-1 as well.
Reproducing it in the app
Default preset, palette drag each time:
1. fresh boot : client,api,db
2. after palette add : client,api,db,cache-1 saved: client,api,db,cache-1
3. after reload : client,api,db,cache-1
4. add cache again : client,api,db,cache-1,cache-1 duplicate: true
saved to storage : client,api,db,cache-1,cache-1
5. after 2nd reload : client,api,db
Step 5 is the bug. Both caches are gone and nothing was said. React logs
Encountered two children with the same key at step 4, which is the only outward sign.
Cause
makeNode (src/sim/presets.ts:653) mints from a module-global counter:
let nodeCounter = 0;
export function makeNode(kind, x, y, label?) {
nodeCounter += 1;
return { id: `${kind}-${nodeCounter}`, ... };
}
The counter resets to 0 on every page load and never looks at the topology.
handleAddNode (src/App.tsx:1479) appends the result unconditionally. Every add path that
routes through makeNode has this shape; the rail is where I hit it.
The other two id paths already defend against exactly this, and their comments say why:
freshId (src/clipboard.ts:189): "Scanned against the live topology rather than a
counter, because ids on the clipboard may come from another tab whose counter this session
never saw, and a collision would silently merge two different nodes."
freshAnnId (src/App.tsx:1215): "scanned against the live list because a restored
session's ids predate this tab's counters."
Nodes added from the rail are the one path that skips the scan those comments exist to justify.
Why it costs the whole design rather than one node
isTopology rejects the entire topology on any duplicate id
(src/clipboard.ts, if (ids.has(n.id)) return false), and loadSession
(src/App.tsx:507) turns any rejection into a silent fall back to PRESETS[0].
That check is also the door for every other way a design leaves the app, so once a design has
picked up a duplicate id all four are unreadable:
- the saved session,
src/App.tsx:507
- named saved designs,
src/savedDesigns.ts:77, which drops the row
- share links,
src/share.ts:369, which returns BAD_LINK
- exported
.breakscale files, src/designFile.ts:185
So this is not only "a reload loses it". Save, share or export after hitting it and those
artefacts are already invalid.
Fix
freshId in src/clipboard.ts is already the right function and already takes a used set. It
is module-private today, but App.tsx:42 imports three other things from that module and
clipboard.ts exports no components, so exporting it costs nothing.
handleAddNode then mints against the live topology the way freshAnnId does, and every add
path that shares it gets the fix for free. makeNode's counter can stay for the presets, which
build a topology from nothing and cannot collide.
I have the approach ready currently testing and implementing it.
What
Dragging a component in from the rail can mint an id a node in the design already has. Nothing
notices at the time, the design keeps working for the rest of the session, and then the next
reload silently replaces it with the Single Server preset.
It is the autosave that breaks, so there is no error and nothing to undo. You come back to what
looks like a fresh app and the work is gone.
Reproducing it without a reload
makeNodeand the paste path disagree about ids inside a single session:Ctrl+D goes through
freshId, which scans the topology, so it correctly picksdb-1. A palettedrag goes through
makeNode, which does not scan, and the module counter is independentlysitting at 0, so it picks
db-1as well.Reproducing it in the app
Default preset, palette drag each time:
Step 5 is the bug. Both caches are gone and nothing was said. React logs
Encountered two children with the same keyat step 4, which is the only outward sign.Cause
makeNode(src/sim/presets.ts:653) mints from a module-global counter:The counter resets to 0 on every page load and never looks at the topology.
handleAddNode(src/App.tsx:1479) appends the result unconditionally. Every add path thatroutes through
makeNodehas this shape; the rail is where I hit it.The other two id paths already defend against exactly this, and their comments say why:
freshId(src/clipboard.ts:189): "Scanned against the live topology rather than acounter, because ids on the clipboard may come from another tab whose counter this session
never saw, and a collision would silently merge two different nodes."
freshAnnId(src/App.tsx:1215): "scanned against the live list because a restoredsession's ids predate this tab's counters."
Nodes added from the rail are the one path that skips the scan those comments exist to justify.
Why it costs the whole design rather than one node
isTopologyrejects the entire topology on any duplicate id(
src/clipboard.ts,if (ids.has(n.id)) return false), andloadSession(
src/App.tsx:507) turns any rejection into a silent fall back toPRESETS[0].That check is also the door for every other way a design leaves the app, so once a design has
picked up a duplicate id all four are unreadable:
src/App.tsx:507src/savedDesigns.ts:77, which drops the rowsrc/share.ts:369, which returnsBAD_LINK.breakscalefiles,src/designFile.ts:185So this is not only "a reload loses it". Save, share or export after hitting it and those
artefacts are already invalid.
Fix
freshIdinsrc/clipboard.tsis already the right function and already takes ausedset. Itis module-private today, but
App.tsx:42imports three other things from that module andclipboard.tsexports no components, so exporting it costs nothing.handleAddNodethen mints against the live topology the wayfreshAnnIddoes, and every addpath that shares it gets the fix for free.
makeNode's counter can stay for the presets, whichbuild a topology from nothing and cannot collide.
I have the approach ready currently testing and implementing it.