Skip to content

[2.0 rc.19 / next] Endless flush after a parent key change: a keyed <For> row's memo recomputes without bound until queuePendingNode throws RangeError (regression from e509ad1136, #4307) #4342

Closed

Description

@neokofg

Describe the bug

On 2.0.0-rc.19 a flush never finishes after a parent key change. A memo inside a keyed <For> row recomputes over and over (1000+ times in under a second, unbounded without a guard), pendingNodes grows until queuePendingNode throws RangeError: Invalid array length, and the flush loop guard never trips. In a browser the tab freezes and memory climbs. On rc.13 – rc.18 the same tree settles after 3 runs.

The shape is a data-fetching hook: a key memo returning an object, and a createProjection that returns a promise on a cache miss. On a miss, the projection does a non-subscribing snapshot read of the other mounted hooks' key memos (untrack(key), catching only NotReadyError, as in the untrack reference's "non-suspending snapshot" example). A list query feeds a keyed <For>; each row runs its own hook keyed on (props.link, props.item.key). Switching the parent key while the new list answer is still in flight starts the loop.

Your Example Website or App

Inline below: a self-contained vitest + jsdom test, solid-js / @solidjs/web only.

Steps to Reproduce the Bug or Issue

repro.test.jsx:

import { render } from "@solidjs/web";
import { createMemo, createProjection, createSignal, Errored, For, Loading, NotReadyError, onCleanup, untrack } from "solid-js";
import { expect, it } from "vitest";

const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const keys = new Set();
let runs = 0;

// Shape of a data-fetching hook: key memo + projection; on a cache miss it peeks
// (untracked, NotReadyError tolerated) at every other mounted hook's key.
function useQuery(keyFn, fetcher) {
  const key = createMemo(() => ({ hash: keyFn() }));
  keys.add(key);
  onCleanup(() => keys.delete(key));
  const cache = new Map();
  const data = createProjection(() => {
    const k = key().hash;
    let e = cache.get(k);
    if (!e) {
      cache.set(k, (e = { p: fetcher().then((d) => ((e.d = d), { value: d })) }));
      for (const other of keys) {
        try { untrack(other); } catch (err) { if (!(err instanceof NotReadyError)) throw err; }
      }
    }
    return e.d !== undefined ? { value: e.d } : e.p;
  }, {});
  return () => data.value;
}

it("switching the parent key does not spin the flush loop", async () => {
  const el = document.createElement("div");
  const [id, setId] = createSignal("A");
  let release;
  const later = new Promise((r) => (release = r));
  function Item(props) {
    const opts = createMemo(() => {
      if (++runs > 1000) throw new Error("runaway: `opts` recomputed 1000 times");
      return { link: props.link, item: props.item.key };
    });
    useQuery(() => `${opts().link}:${opts().item}`, async () => 0);
    return <p>{props.item.key}</p>;
  }
  function List(props) {
    const list = useQuery(() => props.link, () => (props.link === "A" ? Promise.resolve([{ key: "a" }]) : later));
    return (
      <Loading fallback="...">
        <Errored fallback={(e) => String(e())}>
          <For each={list()} keyed={(x) => x.key}>{(x) => <Item link={props.link} item={x()} />}</For>
        </Errored>
      </Loading>
    );
  }
  render(() => {
    const link = createMemo(async () => { const v = id(); await Promise.resolve(); return v; });
    return <Loading fallback="..."><List link={link()} /></Loading>;
  }, el);
  await wait(50);
  setId("B");
  setTimeout(() => release([{ key: "b" }]), 100);
  await wait(300);
  expect(runs).toBeLessThan(20); // rc.13–rc.18: 3; rc.19: guard trips (without it the flush never returns)
  expect(el.textContent).toBe("b");
});
Setup (package.json + vitest.config.mjs)
{
  "private": true,
  "type": "module",
  "dependencies": {
    "solid-js": "2.0.0-rc.19", "@solidjs/web": "2.0.0-rc.19", "@solidjs/signals": "2.0.0-rc.19",
    "@solidjs/babel-plugin": "2.0.0-rc.19", "@babel/core": "7.28.5",
    "vitest": "3.2.4", "vite": "7.1.12", "jsdom": "26.1.0"
  },
  "pnpm": { "overrides": { "solid-js": "2.0.0-rc.19", "@solidjs/web": "2.0.0-rc.19", "@solidjs/signals": "2.0.0-rc.19" } }
}
// vitest.config.mjs
import { transformAsync } from "@babel/core";
import solid from "@solidjs/babel-plugin";
const conditions = ["browser", "development"];
export default {
  plugins: [{
    name: "solid-jsx", enforce: "pre",
    async transform(code, id) {
      if (!id.endsWith(".jsx")) return null;
      const r = await transformAsync(code, { filename: id, babelrc: false, configFile: false, sourceMaps: true,
        plugins: [[solid, { generate: "dom", hydratable: false }]] });
      return { code: r.code, map: r.map };
    },
  }],
  resolve: { conditions },
  ssr: { resolve: { conditions, externalConditions: conditions }, noExternal: [/solid-js/, /@solidjs\//] },
  test: { environment: "jsdom", server: { deps: { inline: [/solid-js/, /@solidjs\//] } } },
};

Run: pnpm install && pnpm vitest run repro.test.jsx. Swap all four Solid versions (and the overrides) to test another RC.

The runs > 1000 guard only exists so the test fails instead of hanging. Without it the process spins until RangeError: Invalid array length in queuePendingNode (stack: recompute → GlobalQueue._update → runHeap → GlobalQueue.flush → flush, called from a timer).

Expected behavior

The rows settle after a few runs and the list shows b, as on rc.18 (opts runs 3 times).

Actual on rc.19 and on next@17d4d1b: opts recomputes without bound, and the flush loop guard does not fire.

Each of these makes the loop go away on rc.19:

  • the key memo returns a string instead of { hash };
  • dropping the untrack(other) snapshot read of the other hooks' keys;
  • a plain async memo for the list instead of the projection-based hook.

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Runtime: Node 25.8.2, vitest 3.2.4, jsdom 26.1.0, @solidjs/babel-plugin (generate dom). Originally seen in Chromium.
  • Version: solid-js / @solidjs/web / @solidjs/signals 2.0.0-rc.19. Also reproduces on next@17d4d1b (signals built from source).

Additional context

solid-js / @solidjs/web / @solidjs/signals result
2.0.0-rc.13 … 2.0.0-rc.18 passes (opts runs 3 times)
2.0.0-rc.19 loops
next@17d4d1b loops

Bisect. I built packages/signals from source (esbuild, __DEV__/__OBSERVE__ true) at each signals commit between the rc.18 and rc.19 release commits (4c18c229f4 … d2323b077c), with solid-js and @solidjs/web held at rc.19. Building the two release commits the same way reproduces the npm results.

  • first bad: e509ad1 fix(signals): keyed derived store over an async memo follows every landing (#4305) (#4307);
  • last good: its parent 55c732907d.

Usage. Every piece is documented:

  • createProjection with a function that returns a promise;
  • <For keyed={fn}> with accessor children;
  • the untrack reference, "Take a non-suspending snapshot of an async source": "For an advanced one-shot read that must return immediately, handle only NotReadyError and let real errors propagate."

The same loop shows up through @tanstack/solid-query@6.0.0-rc.5. Its useQuery reads untrack(defaultedOptions) from a synchronous cache listener, and with it the first bad version is also rc.19.

Activity

  1. ryansolid commented on Oct 28, 2026

    @ryansolid
    Member

    Fixed on next by #4351 (2c63f41); it ships in the next RC.


    Generated by Claude Code

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