Skip to content

2.0.0-rc.7: keyed projection never releases the leaf signals (and values) of deleted keys — memory grows with every row that ever existed #3351

Description

@thedanchez

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)

  1. 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).
  2. 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.
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions