Skip to content

2.0.0-beta.15: isPending(() => latest(x)) is inconsistent across node shapes #2831

Description

@brenelz

Summary

isPending(() => latest(x)) has semantics pinned in packages/solid-signals/tests/createMemo.test.ts
(tests around lines 2001–2530): while async is in flight and latest() falls back to stale, it reads
true; once the newest value is visible and its lane has no unresolved downstream async, it reads false.

Probing the same expression across node shapes shows two deviations from that contract, plus one
unspecified case. Probe used in all tables:

const probe = (fn) =>
  `read=${fn()} latest=${latest(fn)} pRead=${isPending(fn)} pLatest=${isPending(() => latest(fn))}`;
# Shape Phase read latest pRead pLatest
A plain signal write held by action stale new true true
B sync memo over held signal during action stale stale false false
C async memo source-change fetch in flight stale stale true true
D async memo refresh() fetch in flight stale stale true true
E async memo resolved while action still open new new true true
F optimistic signal override active override override true true
G optimistic computed override active override override true true
H derived-store leaf write held by action stale new true true
I derived-store leaf firewall refetch in flight stale stale true false

Rows A, C, D, F, G, H match the pinned contract. Rows I, E, and B are the findings below.

1. Store leaf during firewall refetch reports pLatest === false (row I vs C/D) — bug?

Same semantic situation as row C/D (fetch in flight, latest showing stale), opposite answer:

const [store] = createStore(async () => api.getState(), {}); // settled: store.value === "v1"

refresh(store);
// while refetch is in flight:
isPending(() => store.value)               // true  ✓
isPending(() => latest(() => store.value)) // false ✗ — async memo in the same phase reads true

Suspected cause: the leaf's _latestValueComputed pending check consults the leaf's status flags
(computePendingState, core.ts:1098–1102), but a store leaf's pending status lives on its
_firewall — which the shadow computed never consults (unlike plain reads, core.ts:727).

Expected: true while the firewall refetch is in flight, matching the async-memo contract
pinned by "single async - [isPending(() => latest(x)), latest(x)] pairs update atomically"
(createMemo.test.ts:2203, phase-1 assertion [true, 10] at line 2237).

2. [isPending(x), x()] pair atomicity violated when async resolves inside an open action (row E)

Tests 3 and 5 (createMemo.test.ts:2155, 2251) pin the invariant: if isPending(x) is true, x()
must be the stale value
(asserted explicitly at lines 2197–2199 and 2321–2327). This holds for plain
transitions but breaks when the resolve happens while an action is still open:

const [x, setX] = createSignal(1);
const m = createMemo(() => fetchData(x())); // settled: "data-1"

const act = action(function* () {
  setX(2);                 // held write → memo refetches with held value
  yield gate;              // action stays open past the fetch
});
act();
// ... fetch for x=2 resolves while the action is still open; effect observes:
//   read = "data-2", isPending(() => m()) = true   ← forbidden pair: pending + NEW value

Expected: either the read stays on "data-1" until the transition commits, or pRead drops to
false — but not true alongside the new value.

3. Sync derivations of held sources are invisible to both latest and isPending (row B) — spec question

const [x, setX] = createSignal(1);
const m = createMemo(() => x() * 10);

// inside an action: setX(2) (held). Then:
latest(x)                    // 2     ✓
isPending(x)                 // true  ✓
latest(() => m())            // 10    — does not tunnel through the cached memo
isPending(() => m())         // false — silently reports "nothing pending"

The cached memo read never re-executes, so the held source is never reached. Mechanically
understandable, but it contradicts the JSDoc for both APIs (latest: "reads the in-flight value of a
signal/computation"; isPending: true when the read "is showing a stale value while newer async
work is pending" — m's visible value is stale-to-be). If this is a deliberate limitation for cost
reasons, it may deserve a documentation note, since latest(page) working while
latest(() => pageLabel()) silently doesn't is an easy trap.

Minor

The test title at createMemo.test.ts:2001 says isPending(() => latest(x)) is false, but its own
in-transition assertion at line 2036 expects [true, 2] — looks like a stale title.

Environment

  • solid-js / @solidjs/signals 2.0.0-beta.15
  • Repros run as vitest tests against packages/solid-signals (happy to PR them as failing specs)

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions