Skip to content

fix(signals): L2 lane takeover of held effects (#3766) and optimistic writes over a held row (#3796) - #3812

Merged
ryansolid merged 5 commits into
nextfrom
fix/l2-remaining-bugs
Oct 6, 2026
Merged

ryansolid merged 5 commits into
nextfrom
fix/l2-remaining-bugs

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Two L2 hold-model fixes from the remaining-bugs list, one commit each, each with a fail-first test and the rule it implements.

Fixes #3766
Fixes #3796

Commits

1. fix(signals): a lane keeps the held render effect it took over (F5) — #3766

A render effect born held in a transaction whose hold a lane took over (#3698: lane work never makes its node transaction work) dropped the lane on its next pass but kept CONFIG_HELD.

  • The mark (from @brenelz's fix(signals): a lane's pending leaf re-runs as the lane's work (#3766) #3794): an effect whose lane pass was interrupted (pending or errored) is marked REACTIVE_LANE_DIRTY. The landing that wakes it is the lane's re-staging, so it runs as the lane's (A31), and the lane holds it until its derivations land (A17).
  • Errored passes don't leave: a pass interrupted before it reached the lane's world no longer counts as leaving the lane (A30: "it never got there").
  • Leaving drops the hold: a pass that does leave the lane drops the hold the lane took over, along with the lane's transaction.

The mark alone crashed the semantic fuzzer: resolveTx returned null, because the leave arm nulled the transaction of an effect that was still CONFIG_HELD (seed 3289, optimistic-readiness case 112, plus 11 more). The second and third changes make the mark safe.

Tests:

  • fix(signals): a lane's pending leaf re-runs as the lane's work (#3766) #3794's ispending-memo-gate-3766 (signals and web), 4 of 6 signals tests failing at base.
  • The F5 pin in fuzz-findings-l2.test.ts flips from it.fails to passing.
  • A new pin covers a born-held reader that leaves the lane.
  • A new it.fails pin, F5b, records a separate display() issue found along the way (see below).

2. fix(signals): an optimistic write over a held row guesses the row's keys, not its slot — #3796

createOptimisticStore(local) over a derived store composes its draft from the inner store's raws, so a row's slot holds the row's committed backing. When an earlier write in the same action (setLocal(d => { d[0].failed = false })) had staged that row, the view the setter is judged against resolved the slot to the row's staging. The two differed only by identity, so the setter's exit guessed the container slot cards[0] itself, with the committed row. That slot guess served a fresh target over the old raw object and shadowed the row's own title and saving guesses: the screen showed Old / saving: false while the action was pending.

The fix resolves a raw in a chained draft to the inner slot it stands for before comparing, the same way a read of it is served (resolveChainedRaw). An unwritten slot is now the same row in both frames, not a guess (A17: an active override is the displayed value until its transaction commits).

Test: optimistic-over-held-row-3796.test.ts covers the report's shape with and without the local write. The local-write case fails at base.

Not in this PR

  • Fuzzer 91501/827, plus seed 3289 cases 416 and 936: needs a ruling. A freshly mounted render effect that reads latest(s) and then a lane memo whose answer hasn't shown yet waits as verdict-lane work, so the mount control publishes alone. Reading the memo first holds the whole mount. The outcome depends on read order. Under --shrink-mode focused, cases 416 and 936 shrink to the same family; the earlier "disposal order" reading came from a discovery shrink that drifted to another signature. I've written this up for the maintainer separately, with options and a recommendation.
  • F5b: a stale reader of a held re-guess shows the unrevealed guess (display() returns the latest lane value once the lane has shown). It's pinned as it.fails. Fixing it needs the revealed lane value tracked apart from the latest guess.

Size

Measured with the #3821 gate (size.mjs --no-gate on both trees, then gate.mjs), next (c54fb10) vs this branch. A follow-up commit trims both fixes without changing behavior: the lane-leave condition is now one negation, and the chained-draft resolution is one expression. F5 now costs +13 B minified on isPending/latest and +15 B on the live server-components page; F5 plus #3796 together cost +39 B on the store-heavy hydrating app.

Scenario Minified (next → PR) Brotli (next → PR) Cap New gate
signals: core floor (createSignal/Memo/Effect/Root/flush) 20103 → 20103 (0) 7349 → 7344 7350 pass
signals: + createStore 44366 → 44366 (0) 14529 → 14571 14560 pass (warn: over cap, minified ≤ 20 B)
signals: + isPending/latest 26785 → 26798 (+13) 9486 → 9500 9490 pass (warn: over cap, minified ≤ 20 B)
app: render + one signal (the simple-app floor) 27657 → 27657 (0) 9846 → 9842 9860 pass
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 52537 → 52537 (0) 17687 → 17705 17710 pass
app: hydrating + every store primitive family 91556 → 91595 (+39) 28818 → 28850 28870 pass
app: CSR with Show/For/Loading/Errored/lazy 36538 → 36538 (0) 12830 → 12843 12860 pass
app: CSR, observe tier (same app on the observe artifacts) 41061 → 41061 (0) 14388 → 14388 14460 pass
app: CSR, observe tier + attribution engine enabled 86389 → 86389 (0) 28642 → 28642 28660 pass
app: compiled floor (one template, one text hole, one delegated click) 28188 → 28188 (0) 10033 → 10019 10050 pass
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 78868 → 78868 (0) 25114 → 25071 25130 pass
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 99168 → 99168 (0) 30921 → 30943 30930 pass (warn: over cap, minified ≤ 20 B)
frames: eager client consumer (frames client + transport, lazy codec) 43310 → 43310 (0) 13770 → 13770 13780 pass
page: base server components (hydrating + dynamic + frames + sf reference) 145569 → 145569 (0) 44819 → 44823 44840 pass
page: live server components (base + live/GET + action + isPending/latest) 157517 → 157532 (+15) 48489 → 48576 48510 pass (warn: over cap, minified ≤ 20 B)
server: floor (getRequestEvent + isServer) 3324 → 3324 (0) 1331 → 1331 1340 pass
server: renderToString (the server-render floor) 71813 → 71813 (0) 20412 → 20412 20420 pass

The hello-world scenario (app: render + one signal) is unchanged. Four scenarios are over their brotli caps, all within the 20 B minified allowance: two from this PR's +13 and +15 B, and two at +0 B minified, which is brotli layout noise. The store-heavy hydrating app's +39 B is under its brotli cap. No cap was raised and there's no Size-Exception.

Fuzzer gate

fuzz/semantic-fuzzer-l2 (oracle rev 19), 1000 cases, --shrink. "Before" is next's sources and "after" is this branch's.

Seed / cohort Before After Change
3289 latest 80 79 fixed 388 (S1)
3289 readiness 0 0
3289 derived-readiness 0 0
3289 optimistic-readiness 0 0 (#3794's mark alone: 12 crash cases)
3289 ordinary 1 1
91501 latest 70 69 fixed 792 (S1)
91501 readiness 0 0
91501 derived-readiness 0 0
91501 optimistic-readiness 2 1 fixed 937 (S1)
91501 ordinary 0 0
Total 153 150 no new failures, 0 worker errors

Suites

  • signals: 265 files, 4947 passed, 7 expected fail
  • solid: 819 passed
  • web client: 1141 passed, 1 expected fail
  • web server: 1469 passed, 3 expected fail
  • web hydrate: 275 passed
  • pnpm types: passes
  • rules-index --check: current

Overlap with in-flight branches

  • fix/create-time-holds and fix/f6-boundary-frame-reader: the only textual conflict is the generated packages/signals/docs/RULES-INDEX.md (regenerate). F6's edits to fuzz-findings-l2.test.ts auto-merge.
  • A scratch merge of all three branches passes the signals suite. The only failures there were dist-artifacts, because the scratch tree had no build.

Public API changes

None. resolveChainedRaw is now exported from src/store/store.ts to src/store/optimistic.ts. That is an internal module edge, and the export is not re-exported from any package entry.

Co-authored-by: Brenley Dueck brenleydueck@gmail.com

ryansolid and others added 2 commits October 5, 2026 15:53
A render effect born held in a transaction whose hold a lane took over
(#3698: lane work never makes its node transaction work) lost the lane on
its next pass while keeping the held flag:

- A leaf has no lane value to seat it, so an effect left waiting on the
  lane's flight re-ran outside the lane when the landing woke it and
  published past the lane's hold. It is now marked as the lane's
  (REACTIVE_LANE_DIRTY) when its pass was interrupted, so the landing that
  wakes it — the lane's re-staging — runs it as the lane's (A31), and the
  lane holds it until its derivations land (A17).
- A pass interrupted before it reached the lane's world (pending, errored)
  no longer counts as leaving the lane (A30: it never got there).
- A pass that does leave the lane drops the hold the lane took over along
  with the lane's transaction, instead of keeping CONFIG_HELD with no
  transaction behind it (the `resolveTx` null crash the semantic fuzzer
  found against the mark alone: seed 3289, optimistic-readiness case 112).

The mark is brenelz's from #3794; the leave and errored arms make it safe.
Tests: #3794's ispending-memo-gate-3766 (signals + web), the F5 pin flips
to passing, and a new pin for a held reader that leaves the lane.

Fixes #3766

Co-authored-by: Brenley Dueck <brenleydueck@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…eys, not its slot (#3796)

An optimistic store chained over a derived store composes its draft from
the inner store's raws (the descriptor read of the inner proxy): a row's
slot holds the row's committed backing. When an earlier write in the same
action staged that row (a local `setLocal` held with the action), the view
the setter is judged against resolves the slot to the row's staging
instead. The two differ by identity only, so the setter's exit guessed the
container's slot itself — with the committed row — and that slot guess
served a fresh target over the old raw, shadowing the row's own `title`
and `saving` guesses: `Old / saving: false` while the action was pending.

A raw in a chained draft is now resolved to the inner slot it stands for —
as a read of it is served (`resolveChainedRaw`) — before it is compared, so
an unwritten slot is the same row in both frames and no guess. The edit
shows while the action is pending (A17: an active override is the
displayed value until its transaction commits).

Fixes #3796

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c4885b1

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

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.34 KB −5 B (−0.1%) 0 B 7.35 KB ✅
signals: + createStore 14.57 KB +42 B (+0.3%) 0 B 14.56 KB ⚠️ over by 11 B, noise
signals: + isPending/latest 9.50 KB +14 B (+0.1%) +13 B 9.49 KB ⚠️ over by 10 B, noise
app: render + one signal (the simple-app floor) 9.84 KB −4 B (−0.0%) 0 B 9.86 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.70 KB +18 B (+0.1%) 0 B 17.71 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 28.85 KB +32 B (+0.1%) +39 B 28.87 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.84 KB +13 B (+0.1%) 0 B 12.86 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.39 KB 0 B 0 B 14.46 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.64 KB 0 B 0 B 28.66 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.02 KB −14 B (−0.1%) 0 B 10.05 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.07 KB −43 B (−0.2%) 0 B 25.13 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 30.94 KB +22 B (+0.1%) 0 B 30.93 KB ⚠️ over by 13 B, noise stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 13.77 KB 0 B 0 B 13.78 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 44.82 KB +4 B (+0.0%) 0 B 44.84 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 48.58 KB +87 B (+0.2%) +15 B 48.51 KB ⚠️ over by 66 B, noise decode.js 6.07 KB, lazy-page.js 0.04 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.41 KB 0 B 0 B 20.42 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • signals: + createStore: over brotli cap by 11 B, minified +0 B — layout noise; cap will be re-based at the next ratchet
  • signals: + isPending/latest: over brotli cap by 10 B, minified +13 B — layout noise; cap will be re-based at the next ratchet
  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 13 B, minified +0 B — layout noise; cap will be re-based at the next ratchet
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 66 B, minified +15 B — layout noise; cap will be re-based at the next ratchet

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and grew more than 20 B minified over the base; over the cap within that allowance is brotli layout noise and passes with a warning. Caps in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). Caps are re-based downward by npm run ratchet (scripts/size/README.md).

@coveralls

coveralls commented Oct 5, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37424787042

Coverage remained the same at 76.058%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1196
Covered Lines: 963
Line Coverage: 80.52%
Relevant Branches: 930
Covered Branches: 654
Branch Coverage: 70.32%
Branches in Coverage %: Yes
Coverage Strength: 28.4 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 7.98%

⚠️ 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 regressed benchmark
✅ 187 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ memo + sync render effect only (reference) 27.1 ms 29.5 ms -7.98%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/l2-remaining-bugs (c4885b1) with next (759a9b6)1

Open in CodSpeed

Footnotes

  1. No successful run was found on next (c54fb10) during the generation of this report, so 759a9b6 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

ryansolid and others added 3 commits October 5, 2026 23:29
# Conflicts:
#	packages/signals/docs/RULES-INDEX.md
The lane-leave condition reads as one negation ("unless created,
errored, read the lane, or a guess"), and the chained-draft resolution
is one expression. No behavior change.

Co-authored-by: Cursor <cursoragent@cursor.com>
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