Skip to content

fix(signals): an optimistic view reads a property its inner store removed as undefined (#3883) - #3885

Merged
ryansolid merged 2 commits into
nextfrom
fix/3883-optimistic-removed-prop
Oct 8, 2026
Merged

ryansolid merged 2 commits into
nextfrom
fix/3883-optimistic-removed-prop

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #3883

Thanks to @GabbeV for bisecting this to the store rewrite in #3774 and for the fix, which is taken as-is (credited with a Co-authored-by trailer), plus tests.

Root cause

In a chained store (an optimistic store over a store), the outer store's per-key node is a subscription point; the inner store is the truth unless the outer node carries an optimistic guess (§7b). Present keys follow that rule through serveDataKey. The absent-key branch of the get trap did not: it returned the outer node's cached value directly. When reconciliation removed a property from the inner store, the view kept serving the last cached value (createFailed: true in the report).

The fix routes an absent key on a chained target reading its committed backing (target.ch && src === target.v) through serveDataKey, the same read-through present keys take: the inner store answers, a guess on the outer node still wins, and the link's committed value is synced to the inner store's.

Paths checked: optimistic overrides (a guess on the removed key shows during the action, undefined after); derived/projection stores (same treatment as present keys); the #3767 alias / #3859 resolveChainedRaw path (only for non-chained reads, not reached here); deleted-key tracking (checked earlier, against the outer staging only, unchanged); in-draft reads (skip the node and serve the inner value, as before).

Tests

packages/signals/tests/store/optimistic-removed-prop-3883.test.ts:

  • the removed property reads undefined through the optimistic view (fails on next)
  • remove, re-add, remove, re-add (fails on next)
  • the report's flow: action inserts, retains with createFailed: true, settles, then reconcile removes it (fails on next)
  • the same through the derived store with no optimistic layer (control)
  • in / Object.keys through the view (control)
  • a guess on the removed key during an action, undefined after

Full signals suite passes. docs/RULES-INDEX.md regenerated (one more §7b citation).

Behavior note

An equal-value guess (true written over true) is not held as an override. Before this fix, if the inner store then removed the key mid-action, the view kept showing true, during the action and after it (the bug). Now it reads undefined during the action. This matches present keys: the same equal guess followed by the inner store changing the value to false already shows false.

Size

Local measurement, base = this branch without the fix:

Scenario minified brotli
signals: + createStore +40 +16
app: hydrating + every store primitive family +40 −25
app: compiled CSR +40 +43
app: compiled hydrating +40 0
all others, including app: render + one signal 0 0

Size-Exception: maintainer accepted on 2026-10-07: +40 B minified for the #3883 fix, store scenarios only; hello world (app: render + one signal) unchanged.

Public API changes

None.

…oved as undefined (#3883)

A chained store (an optimistic store over a store) served an absent key
from the outer node's cached value instead of reading through to the inner
store, so a property reconciliation removed kept its last value. Absent
keys now take the same read-through path as present ones: the outer node
is a subscription point unless it carries a guess.

Co-authored-by: GabbeV <gabriel.valfridsson@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a609f2c

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 7, 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.43 KB 0 B 0 B +15 B 7.45 KB ✅
signals: + createStore 14.69 KB +16 B (+0.1%) +40 B 0 B 14.70 KB ✅
signals: + isPending/latest 9.63 KB 0 B 0 B +15 B 9.65 KB ✅
app: render + one signal (the simple-app floor) 9.92 KB 0 B 0 B +15 B 9.93 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.89 KB 0 B 0 B +15 B 17.91 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.17 KB −25 B (−0.1%) +40 B +55 B 29.19 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.92 KB 0 B 0 B +15 B 12.96 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.53 KB 0 B 0 B +15 B 14.53 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.85 KB 0 B 0 B +15 B 28.89 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.12 KB 0 B 0 B +15 B 10.13 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.22 KB +43 B (+0.2%) +40 B +55 B 25.24 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.16 KB 0 B +40 B 0 B 31.17 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.11 KB 0 B 0 B 0 B 11.13 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.91 KB 0 B 0 B +161 B 33.92 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.20 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.61 KB 0 B 0 B +15 B 37.59 KB ⚠️ over by 20 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.19 KB, wire.js 0.93 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 +15 B 35.13 KB ⚠️ over by 16 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.18 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.65 KB 0 B 0 B +15 B 40.66 KB ✅ eager (counted): web.js 22.02 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.20 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 46.00 KB 0 B 0 B +15 B 46.02 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.33 KB 0 B 0 B +15 B 47.29 KB ⚠️ over by 36 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, trace.js 8.20 KB, wire.js 0.94 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.40 KB 0 B 0 B +4 B 20.42 KB ✅

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

  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 20 B; minified 117,456 B vs 117,441 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 16 B; minified 109,461 B vs 109,446 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 36 B; minified 148,683 B vs 148,668 B recorded with the cap (+15 B) — 5 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 7, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37705969691

Coverage remained the same at 76.43%

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: 1227
Covered Lines: 996
Line Coverage: 81.17%
Relevant Branches: 958
Covered Branches: 674
Branch Coverage: 70.35%
Branches in Coverage %: Yes
Coverage Strength: 28.43 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 6.99%

⚠️ 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) 30.1 ms 28.1 ms +6.99%

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/3883-optimistic-removed-prop (a609f2c) with next (3086f1b)

Open in CodSpeed

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit d231b99 into next Oct 8, 2026
8 checks passed
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