Skip to content

[2.0 / next] A memo's own isPending(heldX) answers false when the memo is pulled inside latest(), true when a plain read wakes it #4468

Closed

Description

@ryansolid

This is the latestActive half of #4429. #4465 fixes the probing half and deliberately leaves this one open.

Setup:

  • An action holds a write to x.
  • Memo m = createMemo(() => \${sa()}:${isPending(x)}`)asksisPending` of that held write in its own pass.
  • A render effect reads m after sa changes.

What the effect sees depends on how it reads m:

read result
latest(() => m()) (pulled dirty inside the window) "1:false"
m(); latest(m) (a plain read wakes it first) "1:true"

Expected: "1:true" both ways. A memo's value is one shared slot, and its pass runs under its own posture, never its puller's (A31). So the value must not depend on who woke it (D-25).

Cause: verdictValue pulls the dirty memo with the dispatch closed, but leaves latestActive set. The isPending call inside the memo's pass therefore runs as if inside the outer latest(). There heldNotFinal answers "final", which gives false.

Repro (place under packages/signals/tests/):

import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { action, createMemo, createRenderEffect, createRoot, createSignal, flush, isPending, latest } from "../src/index.js";

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));

for (const form of ["pulled", "plainFirst"] as const)
  test(`memo's own isPending(held x) is the same whoever wakes it (${form})`, async () => {
    const [x, setX] = createSignal(0);
    const [s, setS] = createSignal(0);
    const [on, setOn] = createSignal(false);
    const seen: unknown[] = [];
    createRoot(() => {
      createRenderEffect(x, () => {});
      const sa = createMemo(() => s());
      const m = createMemo(() => `${sa()}:${isPending(x)}`);
      createRenderEffect(
        () => (on() ? (form === "pulled" ? latest(() => m()) : (m(), latest(m))) : "off"),
        v => void seen.push(v)
      );
    });
    flush();
    action(function* () { setX(1); yield sleep(100); })();
    flush();
    setS(1);
    setOn(true);
    flush();
    expect(seen[seen.length - 1]).toBe("1:true");
    await vi.advanceTimersByTimeAsync(150);
    flush();
  });

A fix is ready locally. It clears latestActive around the pulled pass and restores it afterwards, the same way #4465 handles probing. It costs +13 B minified on the isPending bundles. It opens as a PR once #4465 lands, because both touch the same lines.

Activity

  1. ryansolid commented on Oct 30, 2026

    @ryansolid
    MemberAuthor

    Fixed on next by #4475 (edd3c44); 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