Repository navigation
Description
Describe the bug
A non-keyed <Show> whose condition is derived from an async memo (or async projection) remounts its children on every write to the memo's source, even though the condition stays true. If the child itself writes that source during mount (a render effect), the child remounts forever.
The same shape with a synchronous memo/projection mounts the child once.
| kind | rc.13 | rc.20 | next@215c365 |
|---|---|---|---|
| sync memo | 1 | 1 | 1 |
| sync projection | 1 | 1 | 1 |
| async memo | loop | loop | loop |
| async projection | 1 | loop | loop |
The async-projection row is a regression after rc.13; the async-memo row has looped since at least rc.13.
The real-world trigger is TanStack Query's Solid adapter: a second useQuery of the same key mounted under <Show when={q.data !== undefined}> bumps the adapter's internal version signal on observerAdded, which re-runs the first query's projection (TanStack/query issue to follow).
Your Example Website or App
Self-contained vitest repro below (pnpm i && pnpm test).
Steps to Reproduce the Bug or Issue
package.json: solid-js, @solidjs/web, @solidjs/signals 2.0.0-rc.20; dev @solidjs/vite-plugin 3.0.0-next.54, vitest 5.0.5, jsdom 30.1.2.
vitest.config.ts: plugins: [solid()], test: { environment: "jsdom" }.
repro.test.tsx
import { render } from "@solidjs/web";
import { createMemo, createProjection, createRenderEffect, createSignal, Show } from "solid-js";
import { expect, it } from "vitest";
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
type Kind = "sync memo" | "sync projection" | "async memo" | "async projection";
function mount(kind: Kind) {
let mounts = 0;
const [version, setVersion] = createSignal(0, { ownedWrite: true });
let value: number | undefined = kind.startsWith("sync") ? 1 : undefined;
const firstAnswer = wait(10).then(() => {
value = 1;
setVersion((v) => v + 1);
return 1;
});
let read: () => number | undefined;
if (kind.endsWith("memo")) {
const m = createMemo(() => (version(), value ?? firstAnswer));
read = () => m();
} else {
const p = createProjection<{ data?: number }>(() => {
version();
return (value !== undefined ? { data: value } : firstAnswer.then((v) => ({ data: v }))) as never;
}, {});
read = () => p.data;
}
function Child() {
mounts++;
if (mounts > 20) throw new Error("remount loop");
createRenderEffect(
() => 0,
() => void setVersion((v) => v + 1),
);
return <p>child</p>;
}
const el = document.createElement("div");
const dispose = render(
() => (
<Show when={read() !== undefined}>
<Child />
</Show>
),
el,
);
return { mounts: () => mounts, dispose };
}
it.each<Kind>(["sync memo", "sync projection", "async memo", "async projection"])(
"%s: a non-keyed Show whose condition stays true mounts its child once",
async (kind) => {
const { mounts, dispose } = mount(kind);
await wait(100);
dispose();
expect(mounts()).toBe(1);
},
);package.json
{
"name": "solid-repro",
"private": true,
"type": "module",
"scripts": { "test": "vitest run" },
"dependencies": {
"solid-js": "2.0.0-rc.20",
"@solidjs/web": "2.0.0-rc.20",
"@solidjs/signals": "2.0.0-rc.20"
},
"devDependencies": {
"@solidjs/vite-plugin": "3.0.0-next.54",
"jsdom": "30.1.2",
"vitest": "5.0.5"
}
}vitest.config.ts
import solid from "@solidjs/vite-plugin";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [solid()],
test: { environment: "jsdom" },
});Expected behavior
The condition read() !== undefined is true from the first answer on and never changes, so <Child> mounts once (mounts === 1), as it does for the synchronous variants.
Actual behavior
async memo and async projection: <Child> remounts on every setVersion; since it writes version from its own render effect, it hits the 20-mount guard (expected 21 to be 1).
Platform
- solid-js / @solidjs/web / @solidjs/signals: 2.0.0-rc.20 (also next@215c365, built locally)
- @solidjs/vite-plugin 3.0.0-next.54, vitest 5.0.5, jsdom 30.1.2, Node 24
Activity
fix(signals): fold the #4540 staged check into the existing staged local
fix(signals): order the #4540 gate term for size
ryansolid commentedon Nov 1, 2026
Fixed on
nextby #4555 (404d06a); it ships in the next RC.Generated by Claude Code
fix(signals): a re-pass equal to a staged first value is not a change (…