Skip to content

2.0.0-rc.20: createEffect({ defer: true }) runs its first time when it was created under a held update #4543

Closed

Description

@neokofg

Describe the bug

EffectOptions.defer is documented as "When true, defers the initial effect execution until the next change", and the reference says "defer: true skips the effect's first run".

An effect with defer: true created inside a component that is mounted by a held update (an async memo above it resolves later) runs its effect function once when the hold commits, although its source never changed. Mounted without a hold, it does not run.

  • rc.13: [] in both cases
  • rc.20 / next@215c365: [] without hold, [[1]] under hold

Steps to Reproduce

import { render } from "@solidjs/web";
import { createEffect, createMemo, createSignal, flush, Loading, Show } from "solid-js";
import { expect, it } from "vitest";

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function mountChild(held: boolean): Promise<{ text: string; runs: unknown[] }> {
  const runs: unknown[] = [];
  const [path, setPath] = createSignal("a");
  const matched = createMemo(() => {
    const p = path();
    return p === "a" || !held ? p : wait(10).then(() => p);
  });
  function Child() {
    const [local] = createSignal(1);
    const keys = createMemo(() => [local()]);
    createEffect(
      () => keys(),
      (k) => void runs.push(k),
      { defer: true },
    );
    return <p>B</p>;
  }
  const el = document.createElement("div");
  const dispose = render(
    () => (
      <Loading fallback="root">
        <Show when={matched() === "b"} fallback={<p>A</p>}>
          <Child />
        </Show>
      </Loading>
    ),
    el,
  );
  setPath("b");
  flush();
  await wait(80);
  flush();
  const text = el.textContent ?? "";
  dispose();
  return { text, runs };
}

it("control: defer skips the first run when the mount is not held", async () => {
  expect(await mountChild(false)).toEqual({ text: "B", runs: [] });
});

it("defer skips the first run when the mount is held by an async memo", async () => {
  expect(await mountChild(true)).toEqual({ text: "B", runs: [] });
});

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

runs stays [] in both cases.

Actual behavior

Under hold: runs === [[1]] — the effect ran with its initial value at commit.

Impact

Code that relies on defer to skip the initial value (e.g. "react to key changes only", "save on edit") fires spuriously on every held mount.

Activity

  1. ryansolid commented on Oct 31, 2026

    @ryansolid
    Member

    Fixed on next by #4547 (65da5de); 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