Skip to content

2.0.0-rc.20: a non-keyed <Show> remounts its children in a loop when the condition reads an async memo/projection and a child writes the source on mount #4540

Closed

Description

@neokofg

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

  1. added 2 commits that reference this issue on Oct 31, 2026
  2. ryansolid commented on Nov 1, 2026

    @ryansolid
    Member

    Fixed on next by #4555 (404d06a); 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