Repository navigation
fix(signals): an unread key written beside a held sibling publishes at flush (#4146) - #4159
Conversation
…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 detectedLatest commit: fabfb10 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
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 |
Size (brotli, eager entry chunk)
|
Coverage Report for CI Build 39331278093Coverage remained the same at 77.555%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will regress 1 benchmark
|
| 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)
Footnotes
-
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
#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
|
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:
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
Summary
Fixes #4146.
Before: an action held
card.version. A mainline write tocard.text, a key the hold never touched, stayed held until the action landed, but only when no live reader had created a node fortext. 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 plaincreateStoreand 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):ensurePBused to write a mainline setter's draft straight into the container's own held staging.readSourcethen sawtextas changed by the hold and served the committed value.settrap now creates the key's leaf for a user write on a held container before writing.bornStagedalready 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.slotKept(node)check stopssetSlotUnobservedand thedrainFoldsrelease 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.mdis 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 andapp: compiled hydrating, whose brotli total crossed its cap by 18 B. None of them is a frozen floor.Known gap, not addressed here: a
reconcilemainline 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 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