Skip to content

fix(signals): a memo computes under its own lane posture, never its puller's (#3442) - #3445

Merged
ryansolid merged 1 commit into
solidjs:nextfrom
brenelz:fix/memo-lane-posture-3442
Sep 15, 2026
Merged

ryansolid merged 1 commit into
solidjs:nextfrom
brenelz:fix/memo-lane-posture-3442

Conversation

@brenelz

@brenelz brenelz commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #3442.

The gap

const slow = createMemo(() => delay(1000, count()));
const fast = createMemo(async () => count());
const copy = createMemo(() => slow());
<p>Pending: {String(isPending(() => [fast(), copy()]))}</p>
<p>Fast: {fast()}</p>
<p>Slow: {copy()}</p>

setCount(1) starts two flights; fast lands in a microtask and is held with slow. The hold then released early: Fast: 1 beside Slow: 0 with Pending: false, and Slow: 1 a second later. Without the probe, or with copy a plain getter, both reveal together.

Traced: the Pending effect reads the pending-signal companions of fast and copy. Companions are optimistic nodes, so the effect runs under their lane. Inside the probe, pendingCheckRead pulls copy to refresh it, and that nested recompute inherited the effect's lane — recompute only sets a lane for a node that is OPT-dirty itself or adopts one through its deps, and otherwise leaves the puller's in force. Under a lane, a pending node on no lane serves its committed value instead of throwing (laneSuspends), so copy read the in-flight slow as 0, published a clean value, and dropped its pending status. Its two readers then stopped counting as reporters for slow (reporterBlocksSource), and transitionComplete settled the transaction on fast's landing with slow still in flight. The plain-getter shape never hit this: the probe read slow directly, and a probe observes without deriving (A23).

Rule (A31)

A memo computes under its own lane posture, never its puller's. Its value is one shared slot every reader sees, so the lane's read carve-outs — sound for the lane's effects, whose runs are that view — must not shape a memo's cached result. recompute clears the ambient lane for a non-effect node before the two lane branches re-establish what the memo itself owns (OPT-dirty, or adopted through its deps); effects keep the ambient lane. One assignment:

if (!isEffect) currentOptimisticLane = null;

copy's pull now throws NotReady as a plain reader would, stays pending, and the hold lasts until both flights land:

2000: Pending: true
3000: Fast: 1 | Pending: false | Slow: 1

Note the rule is broader than the probe path: any lane-carrying effect that pulled a lane-less memo used to compute it under the lane's carve-outs. Both suites pass with the wider rule, but flagging it in case the inherited posture was load-bearing somewhere the tests don't reach. The spec entry is marked live (stated by the fix), not ruled.

Verification

  • @solidjs/signals: 171 files / 1799 tests green, including the new tests/ispending-combined-atomic-3442.test.ts (the issue shape, the plain-getter control, and the no-probe control, each pinned to exact frames on a manual clock).
  • @solidjs/web: 85 files / 781 tests green, including the new test/ispending-combined-atomic-3442.spec.tsx — a jsdom port of the issue's playground (real click, DOM sampled through the hold) with the same two controls; fails on next, passes here.
  • Core floor 23,318 → 23,330 (+12 B, noted, no bump). Two brotli caps ratcheted by 0.05 KB with measurements against a pristine next build (+ createStore +62, hydrating-no-stores +44); every other scenario is flat or smaller. These are the same two caps fix(signals): an effect's dependencies are the committed frame's until its run applies (#3438) #3439 ratchets, so whichever lands second needs a two-line rebase.
  • Spec: A31 under Reads and visibility, cited from recompute and the test; rules index regenerated (--check clean; the one unresolved test id is pre-existing on next). Changeset included.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6eda9c7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

…uller's (solidjs#3442)

A combined `isPending(() => [fast(), copy()])` over two async memos, with
`copy` a sync memo wrapping the slow one, released the hold as soon as the
fast flight landed: `Fast: 1` beside `Slow: 0` with `Pending: false`, then
`Slow: 1` a second later.

The probe effect carries the companion lane of the pending signals it
reads, and its pull of `copy` ran under that lane — where a pending node
on no lane serves its committed value instead of throwing — so `copy`
published a stale settled `0`, dropped its pending status, and its
readers stopped holding `slow`: the transaction settled on `fast`'s
landing with `slow` still in flight. A plain getter in place of `copy`
never had the gap (the probe read `slow` directly, and a probe observes
without deriving).

recompute now clears the ambient lane for a non-effect node before the
lane branches re-establish the posture the memo itself owns (OPT-dirty,
or adopted through its deps). A memo's value is one shared slot every
reader sees, so the puller's lane carve-outs must not shape it; effects
keep the ambient lane, since their runs are the lane's own view. Both
values now reveal together, with the probe reporting pending until they
do. Spec rule A31.

+12 B core floor (noted, no bump); two brotli caps ratcheted 0.05 KB with
measurements against `next`. Rules index regenerated, changeset included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@brenelz
brenelz force-pushed the fix/memo-lane-posture-3442 branch from eaddf9e to 6eda9c7 Compare September 14, 2026 23:48
@codspeed

codspeed Bot commented Sep 15, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 160 untouched benchmarks


Comparing brenelz:fix/memo-lane-posture-3442 (6eda9c7) with next (63560a1)

Open in CodSpeed

@brenelz

brenelz commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

PR #3445 — memo lane posture (A31)

Intent: Fix early reveal when isPending(() => [fast(), copy()]) wraps a sync memo over a slow async memo. Root cause: recompute left the puller’s optimistic lane in force, so copy read committed values under lane carve-outs, published “settled,” and dropped the hold. Fix clears ambient lane for non-effects, then re-establishes OPT-dirty / dep adoption; effects keep the ambient lane. prevLane save/restore was already there — nesting is sound.

Verdict: No blockers. I’d merge after a maintainer glance at the breadth note below.

Blockers

None.

Should fix / confirm before merge

  1. Behavior change is wider than the bug report. Any lane-carrying effect that pulls a lane-less memo used to compute under that lane’s carve-outs. The new tests pin the isPending probe path (signals + web) and the two controls — good — but there’s still no case for a normal lane effect (optimistic / reveal-gated) pulling createMemo(() => pendingAsync()). You already flag this in the PR body; worth either one extra test or an explicit maintainer ACK that suite green is enough.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants