Skip to content

fix(signals): an unread key written beside a held sibling publishes at flush (#4146) - #4159

Merged
ryansolid merged 7 commits into
nextfrom
claude/project-thread-x1ore2-4146
Oct 19, 2026
Merged

ryansolid merged 7 commits into
nextfrom
claude/project-thread-x1ore2-4146

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes #4146.

Before: an action held card.version. A mainline write to card.text, a key the hold never touched, stayed held until the action landed, but only when no live reader had created a node for text. With a live render effect, the write showed at the flush. So whether a write was held depended on whether something was reading it, which breaks R1 ("node existence is unobservable"). It reproduces with a plain createStore and an action that writes one key and yields.

After: the write shows at the flush whether or not something reads the key (A28, #3706/A29 per-key holds, #3688). A write to version, the key the hold changed, still joins the hold (A34). Added keys and appended array indexes also read the same with or without a reader. Their values show at the flush, and the record's key list or the array's length still waits for the hold to land, as it already did when a reader existed.

How (src/store/store.ts):

  • ensurePB used to write a mainline setter's draft straight into the container's own held staging. readSource then saw text as changed by the hold and served the committed value.
  • The set trap now creates the key's leaf for a user write on a held container before writing. bornStaged already holds a new leaf only when the hold changed that key, so the leaf stages like a signal write unless the hold owns the key.
  • A shared slotKept(node) check stops setSlotUnobserved and the drainFolds release from dropping a slot leaf while its container is held. Without it, a reader disposed after the write would send the read back to the held path.
  • RULES-INDEX.md is regenerated.

Size: +48 B minified on signals: + createStore, app: hydrating + every store primitive family, and the two compiled apps that retain the store, with 0 B elsewhere. Three caps are re-set from CI numbers: the two store scenarios and app: compiled hydrating, whose brotli total crossed its cap by 18 B. None of them is a frozen floor.

Known gap, not addressed here: a reconcile mainline write bypasses the set trap, so it still depends on whether a reader exists.

How did you test this change?

  • packages/signals/tests/continuation-sibling-reader-4146.test.ts, 5 tests:

    • the issue's five-reader matrix
    • a plain-store shape
    • a reader disposed after the write
    • a write to the held key staying held
    • an added key and an appended index reading the same with or without a reader

    The first three fail on next; the added-key test also fails on the previous head of this PR.

  • packages/signals: 316 of 316 files pass (5361 tests).

🤖 Generated with Claude Code

https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

…lush (#4146)

A setter outside the holding transaction writing a store key that a held
container's staging left unchanged wrote it into the held staging, so plain
reads waited for the hold's landing, unless a live reader's leaf gave the
key its own staging. The key now gets its leaf before the write (R1, #3706),
kept while the container is held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@changeset-bot

changeset-bot Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fabfb10

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

This PR includes changesets to release 14 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
board-example Patch
server-dbmon-example 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 19, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.26 KB 0 B 0 B 0 B 7.27 KB ✅
signals: + createStore 14.84 KB +39 B (+0.3%) +48 B 0 B 14.85 KB ✅
signals: + isPending/latest 10.02 KB 0 B 0 B 0 B 10.03 KB ✅
signals: + until/refresh (truth readers, no optimism) 7.86 KB 0 B 0 B 0 B 7.88 KB ✅
app: render + one signal (the simple-app floor) 9.58 KB 0 B 0 B 0 B 9.59 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.23 KB 0 B 0 B 0 B 18.24 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.22 KB +3 B (+0.0%) +48 B 0 B 30.23 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.17 KB 0 B 0 B 0 B 13.19 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.94 KB 0 B 0 B 0 B 14.95 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.49 KB 0 B 0 B −1 B 29.48 KB ⚠️ over by 14 B, 21 B minified headroom lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.78 KB 0 B 0 B 0 B 9.79 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.02 KB +36 B (+0.1%) +48 B 0 B 26.03 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.00 KB +30 B (+0.1%) +48 B 0 B 32.02 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.75 KB 0 B 0 B 0 B 11.76 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.11 KB 0 B 0 B 0 B 33.12 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, trace.js 9.05 KB, wire.js 2.78 KB
page: live server components (base + live/GET + action + isPending/latest) 37.34 KB 0 B 0 B 0 B 37.37 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.06 KB, wire.js 2.78 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.15 KB 0 B 0 B 0 B 35.16 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.05 KB, wire.js 2.77 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.22 KB 0 B 0 B 0 B 41.23 KB ✅ eager (counted): web.js 21.84 KB; assets.js 0.78 KB, decode.js 6.25 KB, regions.js 1.06 KB, sc-comments.js 0.19 KB, trace.js 9.10 KB, wire.js 2.77 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 40.54 KB 0 B 0 B 0 B 40.58 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, server.js 1.02 KB, serverForms.js 3.59 KB, trace.js 9.13 KB, wire.js 2.77 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 46.78 KB 0 B 0 B 0 B 46.79 KB ✅ eager (counted): client.js 26.96 KB; assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.02 KB, serverForms.js 3.30 KB, trace.js 9.08 KB, wire.js 2.80 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.62 KB 0 B 0 B −23 B 20.59 KB ⚠️ over by 34 B, 43 B minified headroom

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

  • app: CSR, observe tier + attribution engine enabled: over brotli cap by 14 B; minified 88,234 B vs 88,235 B recorded with the cap (−1 B) — 21 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • server: renderToString (the server-render floor): over brotli cap by 34 B; minified 72,131 B vs 72,154 B recorded with the cap (−23 B) — 43 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified 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). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

coveralls commented Oct 19, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 39331278093

Coverage remained the same at 77.555%

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: 1347
Covered Lines: 1103
Line Coverage: 81.89%
Relevant Branches: 1050
Covered Branches: 756
Branch Coverage: 72.0%
Branches in Coverage %: Yes
Coverage Strength: 27.65 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚠️ 3 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

⚠️ 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
❌ 1 regressed benchmark
✅ 193 untouched benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ ⚠️ build + consume 694.6 µs 777.8 µs -10.69%
⚡ projection derive: write one NESTED field (reference) 1,181 µs 881.5 µs +33.98%
⚡ memo + sync render effect only (reference) 33 ms 29.2 ms +13.1%

Tip

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


Comparing claude/project-thread-x1ore2-4146 (fabfb10) with next (8c75f82)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

…ios)

Inline the leaf creation into the set trap and fold the held-host check
into the existing release conditions; drop conditions the remaining ones
already cover.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
CI measured +80 B minified on the two store scenarios. Caps at CI
brotli + 10 B, recorded minified = CI's minified.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@ryansolid
ryansolid marked this pull request as ready for review October 19, 2026 18:07
#4146)

Golf from review of #4159 (-32 B min on the store scenarios):

- G1: one `slotKept(node)` predicate for the slot-release handler and the
  deferred-release drain (no behaviour change).
- G2: drop `key in pb` and `!heldKeyChanged` from the set trap. `bornStaged`
  already holds a leaf only for a key the held staging changed, so a changed
  key still joins the hold (A34), and an added key (or an index appended to
  a held array) now gets the same unheld leaf a reader's would. Its value
  shows at the flush whether or not anything reads it (R1: node existence
  is unobservable). Before, an unread added key waited for the hold while a
  read one published.

New test pins that reader-invariance; it fails without G2. The changeset no
longer says array writes join the hold: an index the hold left unchanged
publishes at the flush, and appended indexes/added keys publish their value
while length/key membership waits for the landing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
+48 B minified on the store scenarios (was +80). The compiled hydrating
app crossed its brotli cap by 18 B, so it gets a ledger entry too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

Copy link
Copy Markdown
Member Author

CodSpeed's two flags on 57d9872 (projection derive NESTED −24%, props-chain depth build + consume −11%) don't come from this diff, so I'm leaving the code as is:

  • props-chain only calls merge/omit on plain objects plus createSignal. It never reaches the store set trap or the slot-release path, which is everything this PR touches (store.ts only).
  • projection derive NESTED writes without any transition, so no container is CONFIG_HELD. The only new work on that path is one target.k?._config & CONFIG_HELD check per user write, plus one extra property load in slotKept.
  • I ran both benches locally against dist/prod, alternating next (2aa9e86) and this head. Medians were the same within noise: NESTED 0.056/0.051 ms on base vs 0.076/0.049 ms on head; build + consume 0.0086–0.0228 ms on both.
  • The same projection bench flipped on fix(signals): route async protocol throws and error-formatting failures to the memo (#4112) #4158 and on next itself. CodSpeed also reported a +14.6% mount-rows swing on fix(web): keep a streaming frame's flight origin for late-mounted consumers (#4140) #4160, which doesn't change signals at all. Its "different runtime environments" warning covers these runs.

Generated by Claude Code

Resolved the scenarios.js conflicts with next's re-records, then measured
the merged head: createStore 14.85 KB, compiled hydrating 32.02 KB, and
recorded minified for hydrating + stores and compiled CSR (+48 B each).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
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.

3 participants