Summary
@solidjs/signals@2.0.0-rc.7, prod and dev builds.
A createProjection that deletes a key from its draft keeps the leaf signals created under that key. They stay linked in the projection computed's firewall child list and keep their last _value. Nothing removes them: slotSignal prepends each node to firewall._x._child, and the unobserved sweep (slotUnobservedHook) only deletes the target's t.n cache entry. A long-lived keyed record (draft[id] = row on add, delete draft[id] on removal) therefore grows with every row that ever existed, until the projection itself is disposed.
A second behavior makes it expensive. A store proxy assigned into a projection draft is unwrapped on write and re-wrapped under the projection's own family on read, so record[id] !== store.row. Every nested field read through the record then belongs to the long-lived record, not to the short-lived row store. Deleting the row leaves the whole nested graph behind instead of one slot per key. The re-wrap may be intended; the workaround below relies on it.
Reproduction (headless, only @solidjs/signals)
mkdir solid-projection-retention && cd solid-projection-retention
npm init -y >/dev/null && npm i solid-js@2.0.0-rc.7 >/dev/null
cat > repro.mjs <<'JS'
// node --expose-gc --conditions=development retention.mjs
import { $TARGET, createMemo, createProjection, createRenderEffect, createRoot, createStore, flush, mapArray } from "@solidjs/signals";
const N = 2000;
console.warn = () => {}; // silence the HUGE_FAN_IN dev diagnostic (the record reads every row on purpose)
const mb = () => { global.gc(); global.gc(); return process.memoryUsage().heapUsed / 1048576; };
const kids = (node) => { let n = 0, objs = 0; for (let c = node?._x?._child ?? null; c !== null; c = c._nextChild) { n++; if (c._value && typeof c._value === "object") objs++; } return `${n} children linked, ${objs} still holding an object value`; };
const run = (label, wire) => {
const base = mb();
let g = createRoot((dispose) => {
const [rows, setRows] = createStore([]);
const rowStores = createMemo(mapArray(() => rows, (row) => ({ id: row.id, store: createProjection((d) => { d.row = { id: row.id, pos: { ...row.position }, data: row.data }; }, { row: null }, { key: null }) })));
const record = createProjection((draft) => {
const seen = new Set();
for (const e of rowStores()) { seen.add(e.id); const v = wire.value(e); if (draft[e.id] !== v) draft[e.id] = v; }
for (const k of Object.keys(draft)) if (!seen.has(k)) delete draft[k];
}, {}, { key: null, shallow: true });
const ids = createMemo(() => Object.keys(record));
createMemo(mapArray(ids, (id) => { createRenderEffect(() => wire.read(record, id), () => {}); return id; }))();
flush();
return { setRows, record, rowStores, dispose };
});
g.setRows(() => Array.from({ length: N }, (_, i) => ({ id: "n" + i, position: { x: i, y: 0 }, data: { pad: new Array(64).fill(i) } }))); flush();
const full = mb() - base;
const identity = wire.read(g.record, "n0") !== undefined && (wire.value(g.rowStores()[0]) === g.record.n0 ? "record.n0 === store.row" : "record.n0 !== store.row (re-wrapped)");
g.setRows(() => []); flush();
console.log(`${label.padEnd(44)} retained ${(mb() - base).toFixed(1)} of ${full.toFixed(1)} MB after delete-all; ${identity}; record node: ${kids(g.record[$TARGET].fam.node)}`);
g = null;
};
run("A draft[id] = row proxy; read record[id]", { value: (e) => e.store.row, read: (r, id) => r[id] });
run("B draft[id] = row proxy; read record[id].pos.x", { value: (e) => e.store.row, read: (r, id) => r[id]?.pos.x });
run("C frozen holder; read record[id].row.pos.x", { value: (e) => (e.holder ??= Object.freeze({ get row() { return e.store.row; } })), read: (r, id) => r[id]?.row.pos.x });
JS
# dev build: readable internals for the child-list count
node --expose-gc --conditions=development repro.mjs
# prod build: retained MB is the same; the child-list count reads 0 because the fields are mangled
node --expose-gc repro.mjs
The model: an array store, one createProjection per row through mapArray, one keyed record projection holding each row (draft[id] = store.row, delete on removal), one reader effect per key. Fill 2000 rows, delete them all, force GC, measure what the still-live root retains.
Results (Node 24.13, macOS, dev build)
A draft[id] = row proxy; read record[id] retained 1.5 of 14.9 MB after delete-all; record.n0 !== store.row (re-wrapped); record node: 2001 children linked, 0 still holding an object value
B draft[id] = row proxy; read record[id].pos.x retained 12.7 of 18.9 MB after delete-all; record.n0 !== store.row (re-wrapped); record node: 6001 children linked, 2000 still holding an object value
C frozen holder; read record[id].row.pos.x retained 0.5 of 16.8 MB after delete-all; record.n0 === store.row; record node: 2001 children linked, 0 still holding an object value
Prod build: 1.4, 12.2 and 0.5 MB retained.
- A: reading only
record[id] leaves the 2000 id slots linked, but their values are cleared on delete. About 200 bytes per deleted key.
- B: one nested read per row (
record[id].pos.x). The record node now owns 6001 slot signals, and 2000 of them still point at the deleted rows' pos objects. 12.7 of 18.9 MB stays retained after every row is gone.
- C: the workaround below. 0.5 MB.
Disposing the root releases everything in all three cases. The problem is limited to a live graph.
Where it goes wrong (rc.7 source)
getNode creates the slot with slotSignal(current, slotNodeEquals, target, key, acc, target.fam?.node). For a projection store the node's _firewall is the projection computed, and the node is prepended to that computed's child list (_nextChild: firewall._x._child, ext(firewall)._child = s, CONFIG_FW_CHILDREN).
- The only removal path is the unobserved sweep:
setSlotUnobserved(node => { ... delete t.n[key]; t.nc--; }). It drops the cache entry, leaves the node in the _child chain, and does not clear _value. Nothing else writes _nextChild, so the chain only grows for the projection's lifetime.
deleteProperty on the draft commits the deletion to the record's own slot, whose _value becomes undefined. That is why case A is nearly clean. The nested targets created under that key keep their nodes linked with values. That is case B.
- On the re-wrap:
draft[id] = store.row stores the raw object (the setter unwraps), and reads wrap it under the record's fam (wrapNext(raw, …, fam)), a second target for the same raw. record.a !== store.row, and record.a.pos[$TARGET].fam.node is the record node. The nested leaves are the record's, not the row projection's.
Workaround
Store a non-wrappable holder in the slot instead of the proxy. Frozen objects are not wrappable (isWrappable checks Object.isFrozen), so they are served raw, and the getter hands out the row store's own proxy:
const holder = Object.freeze({ get row() { return store.row; } });
draft[id] = holder; // record[id].row === store.row
Nested leaves then belong to the row store and die with it (case C). Reactivity through the getter is intact: a row write re-runs exactly that key's reader.
Possible fix
Unlink a slot from its firewall's child list when the sweep drops it, or when its key is deleted. Clearing _value there would already release the nested values. The child list is singly linked, so this needs either a prev pointer or a compaction pass in the firewall walk. markNode already visits every child in its CONFIG_FW_CHILDREN loop.
Impact
Solid Flow (@dschz/solid-flow) keeps its node and edge rows in exactly this shape: per-row projections plus a keyed record that consumers read nested fields through. At 10k nodes and 10k edges, deleting every element retained about 260 MB for the flow's lifetime, roughly 26 KB per deleted node and edge pair, and re-adding the elements landed at mount size plus the retained amount. With the frozen-holder workaround: 37 MB retained, which is the example app's own arrays. The mounted heap is also about 13% smaller, since each leaf now has one signal instead of two.
Platform
- macOS 15 (Darwin 24.6.0), Apple Silicon
- Node 24.13.0
solid-js@2.0.0-rc.7, @solidjs/signals@2.0.0-rc.7. Reproduces in Chromium too: V8 heap snapshots show the retaining path record computed._x._child → … → signal._value → row.
Summary
@solidjs/signals@2.0.0-rc.7, prod and dev builds.A
createProjectionthat deletes a key from its draft keeps the leaf signals created under that key. They stay linked in the projection computed's firewall child list and keep their last_value. Nothing removes them:slotSignalprepends each node tofirewall._x._child, and the unobserved sweep (slotUnobservedHook) only deletes the target'st.ncache entry. A long-lived keyed record (draft[id] = rowon add,delete draft[id]on removal) therefore grows with every row that ever existed, until the projection itself is disposed.A second behavior makes it expensive. A store proxy assigned into a projection draft is unwrapped on write and re-wrapped under the projection's own family on read, so
record[id] !== store.row. Every nested field read through the record then belongs to the long-lived record, not to the short-lived row store. Deleting the row leaves the whole nested graph behind instead of one slot per key. The re-wrap may be intended; the workaround below relies on it.Reproduction (headless, only
@solidjs/signals)The model: an array store, one
createProjectionper row throughmapArray, one keyed record projection holding each row (draft[id] = store.row, delete on removal), one reader effect per key. Fill 2000 rows, delete them all, force GC, measure what the still-live root retains.Results (Node 24.13, macOS, dev build)
Prod build: 1.4, 12.2 and 0.5 MB retained.
record[id]leaves the 2000 id slots linked, but their values are cleared on delete. About 200 bytes per deleted key.record[id].pos.x). The record node now owns 6001 slot signals, and 2000 of them still point at the deleted rows'posobjects. 12.7 of 18.9 MB stays retained after every row is gone.Disposing the root releases everything in all three cases. The problem is limited to a live graph.
Where it goes wrong (rc.7 source)
getNodecreates the slot withslotSignal(current, slotNodeEquals, target, key, acc, target.fam?.node). For a projection store the node's_firewallis the projection computed, and the node is prepended to that computed's child list (_nextChild: firewall._x._child,ext(firewall)._child = s,CONFIG_FW_CHILDREN).setSlotUnobserved(node => { ... delete t.n[key]; t.nc--; }). It drops the cache entry, leaves the node in the_childchain, and does not clear_value. Nothing else writes_nextChild, so the chain only grows for the projection's lifetime.deletePropertyon the draft commits the deletion to the record's own slot, whose_valuebecomes undefined. That is why case A is nearly clean. The nested targets created under that key keep their nodes linked with values. That is case B.draft[id] = store.rowstores the raw object (the setter unwraps), and reads wrap it under the record'sfam(wrapNext(raw, …, fam)), a second target for the same raw.record.a !== store.row, andrecord.a.pos[$TARGET].fam.nodeis the record node. The nested leaves are the record's, not the row projection's.Workaround
Store a non-wrappable holder in the slot instead of the proxy. Frozen objects are not wrappable (
isWrappablechecksObject.isFrozen), so they are served raw, and the getter hands out the row store's own proxy:Nested leaves then belong to the row store and die with it (case C). Reactivity through the getter is intact: a row write re-runs exactly that key's reader.
Possible fix
Unlink a slot from its firewall's child list when the sweep drops it, or when its key is deleted. Clearing
_valuethere would already release the nested values. The child list is singly linked, so this needs either a prev pointer or a compaction pass in the firewall walk.markNodealready visits every child in itsCONFIG_FW_CHILDRENloop.Impact
Solid Flow (
@dschz/solid-flow) keeps its node and edge rows in exactly this shape: per-row projections plus a keyed record that consumers read nested fields through. At 10k nodes and 10k edges, deleting every element retained about 260 MB for the flow's lifetime, roughly 26 KB per deleted node and edge pair, and re-adding the elements landed at mount size plus the retained amount. With the frozen-holder workaround: 37 MB retained, which is the example app's own arrays. The mounted heap is also about 13% smaller, since each leaf now has one signal instead of two.Platform
solid-js@2.0.0-rc.7,@solidjs/signals@2.0.0-rc.7. Reproduces in Chromium too: V8 heap snapshots show the retaining pathrecord computed._x._child → … → signal._value → row.