Skip to content

fix(signals): a lane's pending leaf re-runs as the lane's work (#3766) - #3794

Open
brenelz wants to merge 1 commit into
solidjs:nextfrom
brenelz:fix/ispending-memo-gate-3766
Open

brenelz wants to merge 1 commit into
solidjs:nextfrom
brenelz:fix/ispending-memo-gate-3766

Conversation

@brenelz

@brenelz brenelz commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #3766. Since #3774, a render effect that reads a memo over isPending(source) and then a second, initially async memo never mounts (the reduced repro in this comment, `${pending()} | ${gate()}`). Both promises fulfill and nothing updates afterwards.

Root cause: when source lands, the pending memo re-runs as a node of the transaction's verdict lane while still uninitialized on screen. The effect reads it, becomes the lane's work, and goes pending on gate, so the lane is blocked on its own member. When gate lands the effect re-runs outside the lane, because only non-leaf nodes carry CONFIG_OVERRIDE to seat their pass there. laneRead then sees a held, unshown, uninitialized node and throws NotReadyError(null). No source wakes the effect again, the lane never shows, and the transaction never lands.

The change is one branch in laneStage (lanes.ts): a leaf that ends a pass pending as a lane's work is flagged REACTIVE_LANE_DIRTY, so its next pass is seated in that lane, the same way a lane's derivation is. I first put this in settlePendingSource, scoped to the landing only, but that added 31 minified bytes to the signals core floor; lanes.ts is pay-for-use and the core floor is byte-identical here.

With the mount fixed, the original playground from the issue no longer shows two different gate() values: both rows go from false | 100 to false | 200 in the same flush.

How did you test this change?

  • packages/signals/tests/ispending-memo-gate-3766.test.ts: the three read orders from the comment, a second reader still waiting on a slower flight, a plain write while the reader waits, and the original update scenario polled for disagreement.
  • packages/web/test/ispending-memo-gate-3766.spec.tsx: the playground through compiled JSX. Both tests fail on next at 6f77b1bd9 and pass with the change.
  • npx vitest run in packages/signals: 4929 passed, 0 failed. packages/solid: 819 passed.
  • npx vitest run in packages/web: 1135 passed, 6 failed. The same 6 (dev-warning, lowercase-on-attribute, performance-tracks) fail on next without this change on my machine, which runs an older prebuilt compiler binary.
  • node size.mjs in scripts/size passes with the caps below.

Open

Size caps need a maintainer decision. Five brotli caps are raised, two of them frozen floor caps, so check-floor-caps fails until a size exception line is added to this description. I have not added one because the cost has not been accepted.

scenario next this PR minified
signals: + isPending/latest 9,446 B 9,452 B +14 B
app: CSR 12,807 B 12,826 B +0 B
app: CSR, observe tier 14,389 B 14,396 B +0 B
page: base server components (floor) 44,762 B 44,792 B +0 B
page: live server components (floor) 48,436 B 48,493 B +18 B

The three scenarios with no minified change do not include the edit; the added _flags use shifts the property mangle order and the growth is brotli layout.

The flag also covers a leaf that ends a lane pass errored, and it stays set until the leaf's next pass, including across the lane dissolving. I did not find a failing case for either, but a narrower marker would cost core bytes, so I left it for review.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 586b5f3

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

This PR includes changesets to release 12 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
todos-server-example 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

@codspeed

codspeed Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 8.03%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 187 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ memo + sync render effect only (reference) 29.3 ms 27.2 ms +8.03%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing brenelz:fix/ispending-memo-gate-3766 (586b5f3) with next (a8c98bd)

Open in CodSpeed

@ryansolid

Copy link
Copy Markdown
Member

Thanks for this, @brenelz. The diagnosis is right and the tests are good. While fixing the semantic fuzzer's findings in #3801, we tried your mechanism. It fixes F5 (a second guess on a shown optimistic lane tears against the first guess's derivation), the same unseated-leaf re-run as #3766, and on that branch all six of your #3766 tests pass.

The problem: under the semantic fuzzer (fuzz/semantic-fuzzer-l2) it crashes with TypeError: Cannot read properties of null (reading '_into') (recompute → txOf → resolveTx). On your head (a887ad0) 38 of the 39 crash cases reproduce; your base reproduces 1, with an unrelated error. A broader form of the mark (every errored node, not just leaves) crashes on all 39.

What happens:

  1. A render effect held by a transaction gets taken over by a lane. list re-points _transaction at the lane, and CONFIG_HELD stays set ("held by a blocked lane").
  2. The REACTIVE_LANE_DIRTY mark makes its next pass a lane pass.
  3. That pass doesn't read the lane, so laneStage's leave path nulls the lane _transaction but keeps CONFIG_HELD.
  4. The pass after that hits txOf(null).

Without the mark, a leaf never reaches that leave path.

We tried two narrowings, and neither works:

A correct fix has to change how a lane takes over a held effect, so the effect doesn't lose the transaction that held it.

A minimal repro: on the fuzzer branch, seed 3289, cohort optimistic-readiness, case 112. After shrinking, the scenario is:

  • a latest source;
  • two async memos (one await, one manual);
  • one isPending reader of depth 1 over [latest, m2, m1];
  • an action, alternating clicks and resumes.
cd packages/signals
node tests/semantics/cli.mjs --seed 3289 --cases 1000 --cohort optimistic-readiness --shrink --out /tmp/out
node tests/semantics/cli.mjs --replay /tmp/out/findings.jsonl --index 112

We're holding this PR until that's resolved. Your tests are valuable: we'll keep ispending-memo-gate-3766 in signals and web as the acceptance tests for the fix, with credit. Thanks again for the careful reduction and write-up.

— Claude via Cursor

…js#3766)

A render effect that read a memo over isPending(source) and then went pending on a second async memo became the verdict lane's work. The landing re-ran it outside the lane, where the uninitialized probe memo had nothing to show, so it threw with no source to wake it and the lane stayed blocked on its own reader.

Only a leaf's own lane pass that ends pending is marked; a pending propagation or a first pass under a lane is not. A held render effect that a lane took over and whose pass leaves the lane goes back to the lane's holder instead of keeping CONFIG_HELD with no transaction, which crashed in txOf under the semantic fuzzer. This also fixes F5, so its pin is now a plain test.
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