Skip to content

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

Open
ryansolid wants to merge 2 commits into
nextfrom
fix/l2-remaining-bugs
Open

ryansolid wants to merge 2 commits into
nextfrom
fix/l2-remaining-bugs

Conversation

@ryansolid

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

Rolldown + brotli via scripts/size, next (a8c98bd) vs this branch. Minified bytes are the attributable unit; brotli moves by a few bytes even where minified is unchanged.

Scenario Minified (next → PR) F5 commit #3796 commit Brotli (next → PR) Cap
signals: core floor (createSignal/Memo/Effect/Root/flush) 20103 → 20103 (0) 0 0 7349 → 7344 7350
signals: + createStore 44305 → 44305 (0) 0 0 14560 → 14545 14560
signals: + isPending/latest 26785 → 26805 (+20) +20 0 9486 → 9505 9490 over by 15 B
app: render + one signal (the simple-app floor) 27657 → 27657 (0) 0 0 9846 → 9842 9860
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 52497 → 52497 (0) 0 0 17671 → 17675 17710
app: hydrating + every store primitive family 91456 → 91507 (+51) +27 +24 28857 → 28787 28870
app: CSR with Show/For/Loading/Errored/lazy 36538 → 36538 (0) 0 0 12830 → 12843 12860
app: CSR, observe tier (same app on the observe artifacts) 41061 → 41061 (0) 0 0 14388 → 14388 14460
app: CSR, observe tier + attribution engine enabled 86389 → 86389 (0) 0 0 28642 → 28642 28660
frames: eager client consumer (frames client + transport, lazy codec) 43310 → 43310 (0) 0 0 13770 → 13770 13780
page: base server components (hydrating + dynamic + frames + sf reference) 145469 → 145469 (0) 0 0 44798 → 44804 44840
page: live server components (base + live/GET + action + isPending/latest) 157417 → 157439 (+22) +22 0 48481 → 48479 48510
server: floor (getRequestEvent + isServer) 3324 → 3324 (0) 0 0 1331 → 1331 1340
server: renderToString (the server-render floor) 71813 → 71813 (0) 0 0 20412 → 20412 20420

The hello-world scenario (app: render + one signal) is unchanged. In the store scenario, the F5 commit's +27 is +25 in signals plus 2 B of solid/web mangling noise.

Size decision needed

  • signals: + isPending/latest is 15 B brotli over its cap (9505 vs 9490). That scenario had 4 B of headroom on next, and F5's +20 B minified (in laneStage) uses it up. No cap was raised in this PR. The options are a 20 B cap bump for this scenario (it isn't one of the frozen floor caps), or a trim elsewhere in the lanes module.
  • Per-fix budget: F5 is +20 B in its main scenario, but +25 B (signals) in the store-heavy hydrating app. Optimistic store update is hidden after a local derived-store write inside an action #3796 is +24 B, only in that scenario. Both are slightly over a 20 B per-fix budget there. The PR total is +51 B worst-case, under 100 B.

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: 4e40669

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 cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.34 KB −5 B (−0.1%) 7.35 KB ✅
signals: + createStore 14.54 KB −15 B (−0.1%) 14.56 KB ✅
signals: + isPending/latest 9.51 KB +19 B (+0.2%) 9.49 KB ❌ over by 15 B
app: render + one signal (the simple-app floor) 9.84 KB −4 B (−0.0%) 9.86 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.68 KB +4 B (+0.0%) 17.71 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 28.79 KB −70 B (−0.2%) 28.87 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.84 KB +13 B (+0.1%) 12.86 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.39 KB 0 B 14.46 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.64 KB 0 B 28.66 KB ✅ lazy-page.js 0.04 KB
frames: eager client consumer (frames client + transport, lazy codec) 13.77 KB 0 B 13.78 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 44.80 KB +6 B (+0.0%) 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.48 KB −2 B (−0.0%) 48.51 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.41 KB 0 B 20.42 KB ✅

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. 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).

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 37386808735

Warning

No base build found for commit a8c98bd on next.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 75.991%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 1195
Covered Lines: 962
Line Coverage: 80.5%
Relevant Branches: 925
Covered Branches: 649
Branch Coverage: 70.16%
Branches in Coverage %: Yes
Coverage Strength: 27.62 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 5, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 7.02%

⚠️ 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

⚡ 2 improved benchmarks
✅ 186 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ memo + sync render effect only (reference) 29.3 ms 27 ms +8.63%
⚡ dbmon shallow full tick 53.2 ms 50.4 ms +5.42%

Tip

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


Comparing fix/l2-remaining-bugs (4e40669) with next (a8c98bd)

Open in CodSpeed

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