Skip to content

createRoot's dispose function keeps fn alive, so <For>/<Index> rows retain removed list items #4095

Open

Description

@boombang

Describe the bug

createRoot creates the dispose function it passes to fn inside its own scope:

updateFn = unowned ? ... : () => fn(() => untrack(() => cleanNode(root)));

That scope also holds fn, because updateFn closes over it. So anything that keeps the dispose function also keeps fn, and everything fn closes over.

mapArray and indexArray keep every row's dispose function in disposers. Every row's fn is mapper, and mapper closes over newItems, the list that was current when the row was created. So a mounted row keeps that whole list reachable, including items that were removed from the list later.

We found this in production. A long-lived <For> row kept a list it was created from, and with it a replaced document of a few hundred MB.

Your Example Website or App

Standalone script below (Node 22, solid-js 1.9.17, client build).

Steps to Reproduce the Bug or Issue

// npm i solid-js@1.9.17
// node --expose-gc --conditions=browser repro.mjs
import { createRoot, createSignal, createMemo, mapArray, indexArray } from "solid-js";

const collect = async () => {
  // WeakRef targets stay alive until the current job ends.
  await new Promise(r => setTimeout(r, 0));
  globalThis.gc();
};

// A row created from [kept, removed] stays mounted after the list becomes [kept].
async function check(name, map) {
  const kept = { name: "kept" };
  let removed, setList;
  const dispose = createRoot(dispose => {
    const [list, set] = createSignal(
      (() => {
        const item = { name: "removed" };
        removed = new WeakRef(item);
        return [kept, item];
      })()
    );
    setList = set;
    createMemo(map(list))();
    return dispose;
  });
  setList([kept]);
  await collect();
  console.log(`${name}: removed item still reachable:`, removed.deref() !== undefined);
  dispose();
}

await check("mapArray (<For>)", list => mapArray(list, item => item.name));
await check("indexArray (<Index>)", list => indexArray(list, item => item().name));

Output:

mapArray (<For>): removed item still reachable: true
indexArray (<Index>): removed item still reachable: true

The dev build (dist/dev.js) gives the same output, and so does Bun (JavaScriptCore) with Bun.gc(true).

Expected behavior

Once the row for removed is disposed, nothing should keep removed reachable, so both lines should print false. They do once the dispose function is created outside createRoot's scope, in both V8 and JavaScriptCore.

Platform

  • solid-js: 1.9.17 (main)
  • Node 22.23 (V8), Bun 1.4.2 (JavaScriptCore)

Additional context

I haven't checked next (2.0) yet. The fix for 1.x is in the PR linked below.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions