Repository navigation
Description
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(generatedom). Originally seen in Chromium. - Version:
solid-js/@solidjs/web/@solidjs/signals2.0.0-rc.19. Also reproduces onnext@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:
createProjectionwith a function that returns a promise;<For keyed={fn}>with accessor children;- the
untrackreference, "Take a non-suspending snapshot of an async source": "For an advanced one-shot read that must return immediately, handle onlyNotReadyErrorand 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
fix(signals): a derive's peek at a reader of its own keys settles (#4342
ryansolid commentedon Oct 28, 2026
Fixed on
nextby #4351 (2c63f41); it ships in the next RC.Generated by Claude Code
fix(signals): derived-store E3 twin — a landing wakes a reader parked…
test: pin the #4342 query-cache shape as a CAUGHT_REACTIVE_READ true …
feat(signals): dev diagnostic CAUGHT_REACTIVE_READ — a reactive read'…