Skip to content

fix(signals): a removed own __proto__ reads undefined, not Object.prototype (#4672) - #4676

Merged
ryansolid merged 3 commits into
nextfrom
claude/fix-4672-proto-removal
Nov 4, 2026
Merged

ryansolid merged 3 commits into
nextfrom
claude/fix-4672-proto-removal

Conversation

@ryansolid

@ryansolid ryansolid commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Fixes #4672.

Summary

A store can carry own __proto__ data, for example rows parsed from JSON. After that data was removed, store.__proto__ read Object.prototype wrapped as a store, with 12 own keys, both tracked and untracked. The removal had told the key's node the backing's inherited value (pb.__proto__ / incoming.__proto__), and the get trap's hot path serves node values.

Scope follows the ruling after #4674, which is now on next. The fix covers __proto__ only. With #4674, a removed own constructor already reads the inherited class, the same answer as an object that never had one. Own prototype data never leaked. Both keys get one pin test each, and no code.

The removed __proto__ node is now told undefined at the two places a removal is notified:

  • notifyWrites (a setter delete, a projection's draft delete). It reuses the hasOwn(pb, key) result the accessor probe already computes, so an own key does no extra work. Only a key that is not own is compared against "__proto__".
  • reconcile's removed-key branch (reconcile, and a projection's or derived store's returned value). Inside the existing !hasOwn(incoming, key) branch, a removed __proto__ diffs against an empty null-prototype record.

The get trap is unchanged, so ordinary-key reads do no extra work.

Rules checked

  • CS-R30 (prototype pollution fully guarded, __proto__ assignment inert): an inherited __proto__ is never served. After removal the read is undefined, and Object.prototype is never wrapped or served. Assigning __proto__ stays inert after removal, and a later write pollutes nothing.
  • F-10 (a write becomes visible at the flush on every channel): tracked readers re-run onto undefined in the same flush that untracked reads see it.
  • CS-R26: setting __proto__ = undefined is inert under R30, so the own data stays.
  • CS-R12, the reverse direction (absent, then own __proto__ data lands): the untracked read shows the data. A tracked reader is not notified, because the get trap answers an absent __proto__ before it subscribes. This was already the case on next. Fixing it costs about +43 B minified (rows below), so it is pinned it.fails and left out of this PR.

Sizes (node size.mjs --no-gate, against next d5cf231)

row min Δ brotli Δ brotli / cap
signals: core floor 0 0 7421 / 7430
signals: + createStore +72 +35 15089 / 15030
signals: + isPending/latest 0 0 12055 / 12070
signals: + until/refresh 0 0 8044 / 8060
app: render + one signal 0 0 9800 / 9800
app: hydrating 0 0 19013 / 19050
app: hydrating + every store primitive family +72 +38 33260 / 33260
app: CSR with Show/For/Loading/Errored/lazy 0 0 13708 / 13730
app: CSR, observe tier 0 0 15451 / 15500
app: CSR, observe tier + attribution engine enabled 0 0 30082 / 30120
app: compiled floor 0 0 9977 / 9980
app: compiled CSR +72 +12 26651 / 26610
app: compiled hydrating +72 +32 32932 / 32870
frames: eager client consumer 0 0 11754 / 11770
page: base server components 0 0 34048 / 34110
page: live server components 0 0 40124 / 40140
page: compiled base server components 0 0 35981 / 36000
page: compiled live server components 0 0 44149 / 44190
page: base + router 0 0 41590 / 41600
page: live + router 0 0 49845 / 49860
server: floor 0 0 1275 / 1290
server: renderToString 0 0 22856 / 22870

node gate.mjs fails on createStore, compiled CSR and compiled hydrating. next already sits over the brotli cap on those three rows (after #4674) and passes only on the minified allowance: 15 B, 7 B and 15 B. Every variant below exceeds that allowance, so this PR needs a cap re-record whichever form ships. The hello-world compiled floor is unchanged at 9,977 B.

Variants measured on the same base. Brotli swings with mangling, so minified is the stable figure:

variant min Δ (store rows) createStore br Δ compiled CSR br Δ ordinary-key read cost
rare-branch (this PR) +72 +35 +12 none
rare-branch + a compare in notifyWrites in place of the reused hasOwn +67 +27 +18 one compare per written key at flush
rare-branch + reverse-direction subscription +115 +62 +42 none
top-of-get guard, __proto__ only (kept separate from the existing check) +54 +14 +39 one string compare per read
top-of-get guard, existing check moved above the hot path +30 +20 −10 three string compares per read

Perf

The change adds no work to the get or set trap. In notifyWrites an own key takes the same path as before: the hasOwn that was already computed is reused. The local A/B on built dist for the first revision (4 alternating rounds, every store bench including reconcile-dbmon-ab) showed no consistent regression (median Δ −2.1%, noise about ±15% on a shared machine). That revision also touched the get trap, so it was a superset of this one. It was not re-run for this narrowed form.

How did you test this change?

  • packages/signals/tests/store/proto-key-removal-4672.test.ts, 19 tests: 18 pass and 1 is an expected fail (the reverse-tracked pin). 7 fail on next. Covered:
    • the issue's repro, and parity with a store that never had the key;
    • five removal paths (reconcile, setter delete, projection draft delete, projection returned value, derived-store returned value), each tracked (the reader is notified) and untracked;
    • set-to-undefined is inert, and a write after removal is inert with no pollution;
    • a wide record on the overlay backing;
    • the reverse direction, untracked and pinned tracked;
    • pins: a removed own constructor reads Object, and a removed own prototype reads undefined.
  • Full signals suite on the merge with next: 478 files, 6502 passed, 14 expected fail, 2 skipped, 0 failed, no unhandled errors.
  • Web specs that touch stores: client 36 files / 348 passed, server 12 files / 137 passed, hydrate 6 files / 197 passed. The client and hydrate configs needed a local server.fs.allow override because the worktree's symlinked node_modules resolves outside the vite root.
  • Prettier on the changed files, RULES-INDEX.md regenerated, patch changeset updated.

🤖 Generated with Claude Code

https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE

… not the prototype (#4672)

A store whose own `__proto__` (or `constructor`) data was removed told the
key's node the backing's inherited value, so the hot read served
`Object.prototype` wrapped as a store (or `Object`) to tracked and untracked
readers. Core R30: inherited pollution keys are never served. Now the node
is told `undefined`, the same answer a store that never had the key gives:
- notifyWrites (setter delete, a projection's draft delete): an absent
  pollution key's node gets `undefined`. An own key pays nothing, because
  the existing hasOwn result is reused.
- reconcile's removed-key branch (reconcile, and a derive's returned value):
  a pollution key diffs against an empty record.
- the get trap's existing pollution-key branch: a tracked reader subscribes
  like any absent key (R12), and its node is born absent. Own data that
  lands later reaches the reader (F-10).

`__proto__` assignment stays inert (R30).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE
@github-actions

github-actions Bot commented Nov 3, 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.42 KB 0 B 0 B −98 B 7.43 KB ✅
signals: + createStore 15.09 KB +35 B (+0.2%) +72 B +57 B 15.03 KB ❌ over by 59 B
signals: + isPending/latest 12.05 KB 0 B 0 B 0 B 12.07 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.04 KB 0 B 0 B −91 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.80 KB 0 B 0 B −157 B 9.80 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 19.01 KB 0 B 0 B −7 B 19.05 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 33.26 KB +38 B (+0.1%) +72 B +69 B 33.26 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.71 KB 0 B 0 B −7 B 13.73 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.45 KB 0 B 0 B −7 B 15.50 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 30.08 KB 0 B 0 B −7 B 30.12 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.98 KB 0 B 0 B −194 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.65 KB +12 B (+0.0%) +72 B +65 B 26.61 KB ❌ over by 41 B stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.93 KB +32 B (+0.1%) +72 B +57 B 32.87 KB ❌ over by 62 B stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.75 KB 0 B 0 B 0 B 11.77 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.05 KB 0 B 0 B −7 B 34.11 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.30 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 40.12 KB 0 B 0 B 0 B 40.14 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.27 KB, wire.js 3.47 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.98 KB 0 B 0 B −7 B 36.00 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.26 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 44.15 KB 0 B 0 B 0 B 44.19 KB ✅ eager (counted): web.js 23.54 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.07 KB, sc-comments.js 0.19 KB, trace.js 9.28 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.59 KB 0 B 0 B 0 B 41.60 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.58 KB, trace.js 9.24 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 49.84 KB 0 B 0 B 0 B 49.86 KB ✅ eager (counted): client.js 28.75 KB; assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.30 KB, trace.js 9.27 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B 0 B 1.29 KB ✅
server: renderToString (the server-render floor) 22.86 KB 0 B 0 B 0 B 22.87 KB ✅

❌ Fails the gate

  • signals: + createStore: over brotli cap by 59 B; minified 43,868 B vs 43,811 B recorded with the cap (+57 B) — 37 B past the 20 B minified allowance; +72 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store): over brotli cap by 41 B; minified 80,558 B vs 80,493 B recorded with the cap (+65 B) — 45 B past the 20 B minified allowance; +72 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 62 B; minified 101,278 B vs 101,221 B recorded with the cap (+57 B) — 37 B past the 20 B minified allowance; +72 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)

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 Nov 3, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41742835001

Coverage remained the same at 78.249%

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: 1370
Covered Lines: 1132
Line Coverage: 82.63%
Relevant Branches: 1108
Covered Branches: 807
Branch Coverage: 72.83%
Branches in Coverage %: Yes
Coverage Strength: 30.18 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 3 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 201 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing claude/fix-4672-proto-removal (4fbe2a7) with next (d5cf231)

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. ↩

…-removal

# Conflicts:
#	packages/signals/docs/RULES-INDEX.md
With #4674 on next, a plain read of an inherited `constructor` returns the
source's class, and a removed own `constructor` already reads it, as an
object that never had one does. Ruling: narrow the fix to `__proto__`.

- notifyWrites and reconcile's removed-key branch tell a removed
  `__proto__` node `undefined`. An own key does no extra work in
  notifyWrites (the existing hasOwn result is reused), and the reconcile
  check sits inside the existing removed-key branch.
- Dropped: the get-trap subscription and getNode's `absent` parameter (the
  reverse direction is pinned `it.fails`), and every constructor-specific
  change and test, apart from one pin that a removed own `constructor` reads
  the inherited class, and a pin that `prototype` reads undefined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE
@changeset-bot

changeset-bot Bot commented Nov 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4fbe2a7

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

This PR includes changesets to release 15 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
canvas-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

@ryansolid ryansolid changed the title fix(signals): removed own __proto__/constructor data reads undefined, not the prototype (#4672) fix(signals): a removed own __proto__ reads undefined, not Object.prototype (#4672) Nov 3, 2026

Copy link
Copy Markdown
Member Author

The size check failure is expected and is not something a new push can fix. On next, the three store rows (createStore, compiled CSR, compiled hydrating) were already over their brotli caps. They passed only because they were inside the 20 B minified allowance, with 7 to 15 B of room left. This fix adds +72 B minified to every store row, so none of the variants we measured fits.

Fix PRs don't edit scripts/size. If this lands, a follow-up ledger re-record raises those three caps; if not, the PR closes. That decision is pending with the maintainer.

The CodSpeed report on the earlier head (f2c311d) flagged projection-root-write NESTED and dbmon partial tick. Both are known noisy benches, and that head also touched the get trap. The current head leaves the get trap untouched.


Generated by Claude Code

@ryansolid
ryansolid merged commit e704cfc into next Nov 4, 2026
8 of 9 checks passed
ryansolid added a commit that referenced this pull request Nov 4, 2026
@ryansolid
ryansolid deleted the claude/fix-4672-proto-removal branch November 5, 2026 17:34
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