Skip to content

fix(signals): net staged rewrites a custom equals calls equal to the committed value (D-8, #4483) - #4488

Merged
ryansolid merged 11 commits into
nextfrom
claude/project-thread-x1ore2-equals-netting
Oct 30, 2026
Merged

ryansolid merged 11 commits into
nextfrom
claude/project-thread-x1ore2-equals-netting

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Summary

Before: take a signal with a custom equals such as (a, b) => a.k === b.k. A mainline write stages {k: 2}. An action then writes a new {k: 1} back, which the comparator calls equal to the committed {k: 1}. The rewrite still pended the signal, and isPending of a memo over it went true.

setSignal compared the write only against the current value, which was the staged {k: 2}. It then staged the new object, and every netted test is identity (_pendingValue === _value). So a write that made no proposal counted as one.

After: that rewrite nets to the committed value, as D-8 says: "A tick whose writes net to the slot's committed value made no proposal: the slot is not held, pends nothing". isPending stays false. Without a custom equals nothing changes: only the committed object itself nets, and a new object is still a proposal.

How

When a write lands on an existing staging and the node is neither uninitialized nor showing an error, setSignal stages _value itself if a custom comparator calls the write equal to _value. Every existing netted test then sees it.

  • With the verdict engine (observeFlightHook), setSignal takes the comparator test once (custom = el._equals !== isEqual) and shares it between the equality gate and the netting. Under the default comparator, the gate then runs === inline instead of calling isSame, and the netting is skipped on a local test. On callgrind with CodSpeed's flags this makes propagation:avoidable 5.2% fewer instructions than next. Gating the netting alone, without sharing the test, measured 5–7% more.
  • Without the engine, custom is null: the gate is the plain isSame call as on next, and the netting is gone. Bundles without isPending/latest are byte-identical in core.js.
  • Only the netted test reads the staging before a commit, and only isPending and latest read that test.
  • Writes with no staging are unchanged.
  • A custom equals runs a second time only on writes that land on a staging. If it throws there, isSame reports the error on the node, as it already does.

Trade-off: a core-only build (no isPending/latest) still stages a custom-equals write-back as a new, non-netted staging. Nothing observes that without the verdict engine; 32 probe shapes in review found no difference on screen.

Size (client, vs next 64fedc7, brotli / minified, from the CI size report; no ledger edits in this PR):

Scenario Brotli Minified
signals: + isPending/latest +33 +81
page: live server components +30 +81
page: compiled live server components −35 +79
page: live + router +5 +81

Every bundle without the verdict engine is +0 minified. Hello world stays at 9,973 B.

How did you test this change?

New packages/signals/tests/netted-equals-4483.test.ts has three cases:

  • the committed object rewritten under a custom equals;
  • a new object the comparator calls equal. This case fails on next with [false, true];
  • no custom equals, where the committed object nets and a new object still pends.

After merging next @ 64fedc7: signals 6,102 passed / 17 expected fail; web 1,579 passed / 7 expected fail. RULES-INDEX regenerates with no change.

Closes #4483.


🤖 Generated with Claude Code

https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

…committed value (D-8, #4483)

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

changeset-bot Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a20b58

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

@github-actions

github-actions Bot commented Oct 30, 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.41 KB 0 B 0 B +15 B 7.43 KB ✅
signals: + createStore 14.96 KB 0 B 0 B +6 B 14.97 KB ✅
signals: + isPending/latest 11.33 KB +33 B (+0.3%) +81 B +115 B 11.29 KB ❌ over by 36 B
signals: + until/refresh (truth readers, no optimism) 8.05 KB 0 B 0 B +2 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.79 KB 0 B 0 B +15 B 9.78 KB ⚠️ over by 8 B, 5 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.72 KB 0 B 0 B 0 B 18.74 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.47 KB 0 B 0 B 0 B 32.48 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.45 KB 0 B 0 B +9 B 13.47 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.19 KB +7 B (+0.0%) 0 B +9 B 15.20 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.76 KB −2 B (−0.0%) 0 B +9 B 29.78 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.97 KB 0 B 0 B +2 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.27 KB 0 B 0 B +13 B 26.33 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.48 KB 0 B 0 B +7 B 32.54 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.77 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.72 KB 0 B 0 B +16 B 33.77 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.19 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 39.01 KB +30 B (+0.1%) +81 B +131 B 38.98 KB ❌ over by 34 B assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.17 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.72 KB 0 B 0 B +21 B 35.72 KB ❌ over by 3 B assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.13 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 42.99 KB −35 B (−0.1%) +79 B +119 B 42.95 KB ❌ over by 40 B eager (counted): web.js 22.98 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.19 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.16 KB 0 B 0 B +21 B 41.25 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.13 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.56 KB +5 B (+0.0%) +81 B +129 B 48.58 KB ✅ eager (counted): client.js 28.08 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.14 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.79 KB 0 B 0 B 0 B 22.80 KB ✅

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

  • app: render + one signal (the simple-app floor): over brotli cap by 8 B; minified 26,754 B vs 26,739 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base

❌ Fails the gate

  • signals: + isPending/latest: over brotli cap by 36 B; minified 31,216 B vs 31,101 B recorded with the cap (+115 B) — 95 B past the 20 B minified allowance; +81 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:)
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 34 B; minified 117,989 B vs 117,858 B recorded with the cap (+131 B) — 111 B past the 20 B minified allowance; +81 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:)
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 3 B; minified 108,069 B vs 108,048 B recorded with the cap (+21 B) — 1 B past the 20 B minified allowance; +0 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:)
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 40 B; minified 126,137 B vs 126,018 B recorded with the cap (+119 B) — 99 B past the 20 B minified allowance; +79 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).

@github-actions

Copy link
Copy Markdown

Coverage Report for CI Build 41100165665

Coverage remained the same at 78.1%

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: 1362
Covered Lines: 1125
Line Coverage: 82.6%
Relevant Branches: 1090
Covered Branches: 790
Branch Coverage: 72.48%
Branches in Coverage %: Yes
Coverage Strength: 29.89 hits per line

💛 - Coveralls

Only isPending/latest read the netted test before a commit, so the
write-back staging is gated on observeFlightHook and bundles without the
verdict engine pay 0 B. Drops the floor re-records and Size-Exception.

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

codspeed Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

2 warnings

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

⚠️ 2 benchmarks with a significant change ran on different hardware

Their base and head results were measured on machines with a different CPU, core count or memory, so the changes might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 198 untouched benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ memo + sync render effect only (reference) 32.1 ms 34.7 ms -7.45%
⚡ arrow in template 12.6 ms 11 ms +14.39%

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-equals-netting (8a20b58) with next (4ae408f)

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…tch PR)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…rator

A rewrite loop over a staged signal (CodSpeed propagation:avoidable,
updateSignals:update1to1000) ran the uninitialized/shown-error checks and
isSame on every write. Under the default comparator only the committed
object is equal and `v` is already it, so one load of `_equals` decides.

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

ryansolid commented Oct 30, 2026 •

Copy link
Copy Markdown
Member Author

State of this PR's checks on 8a20b58 (next 64fedc7 merged in):

  • CodSpeed: the earlier −5.33% on propagation:avoidable was real work. Each staged write loaded _equals and compared it, which costs about 120 instructions under CodSpeed's --no-opt Sparkplug tier. Moving the netting into a cold helper, or reordering the test, still paid that load: callgrind measured +5–7% for both. The pushed form shares one comparator test between the equality gate and the netting. Under the default comparator the gate then runs === inline, which measured 5.2% fewer instructions than next on this bench. The CodSpeed run on this head is pending.
  • Size gate (check): isPending/latest, live server components and compiled live server components are over by this PR's own growth (+79–81 B minified, in the body). Under the batch process these are re-recorded in the ledger PR after the batch. compiled base server components is over by 3 B at +0 minified vs base, and it is the same on next after fix(signals): a reader mounted mid-flight reads a derived store like its memo (#4491) #4500.
  • Tests (job): signals 6,102 passed / 17 expected fail; web 1,579 passed / 7 expected fail, locally on this head.

Generated by Claude Code

… D-8 netting

CodSpeed (simulation, --no-opt so setSignal runs as Sparkplug code) put
`propagation:avoidable` 5.3% behind next: every staged rewrite paid a
fresh `_equals` load and a strict compare for the netting, ~120
instructions a write under callgrind, on a bench that is 2,000 staged
rewrites per iteration.

With the verdict engine installed, `custom` (`_equals !== isEqual`) is now
taken once: the gate runs the default comparator's `===` inline instead of
calling `isSame`, and the netting tests the local. Without the engine
`custom` is `null`, so the gate folds to the plain `isSame` call and the
netting away: every store-free / non-verdict client bundle is byte for
byte next's (hello world 9,973 B).

callgrind, Ir per bench iteration (CodSpeed's V8 flags): next 3,294,205;
PR head 3,536,042 (+7.3%); this 3,122,267 (-5.2% vs next).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@ryansolid
ryansolid merged commit 6af149e into next Oct 30, 2026
7 of 9 checks passed
ryansolid added a commit that referenced this pull request Oct 30, 2026
…4499, #4500, #4493, #4488, #4498) (#4507)

next @ b5077ea is over six caps after the batch merged back to back (each
fix PR carried no ledger edits, per the batch process). Re-records,
measured over next @ b5077ea (linux):

- signals: + createStore: 14.97 -> 14.99 KB, minified 43,924 -> 43,956
- signals: + isPending/latest: 11.29 -> 11.35 KB, minified 31,101 -> 31,245
- app: compiled CSR: 26.33 -> 26.36 KB, minified 80,035 -> 80,074
- page: live server components (frozen floor): 38.98 -> 39.10 KB,
  minified 117,858 -> 118,033
- page: compiled live server components: 42.95 -> 43.01 KB,
  minified 126,018 -> 126,181
- page: live + router: 48.58 -> 48.65 KB, minified 143,269 -> 143,439

and the recorded minified only (caps unchanged) where the batch drifted
more than 20 B: hydrating + every store (98,535 -> 98,561), compiled
hydrating (100,803 -> 100,836), base server components (frozen,
101,675 -> 101,706), compiled base server components (108,048 ->
108,081), base + router (125,503 -> 125,539).

Hello world is 9,971 B (cap 9.98 KB). The build leaves the pinned
mangle-props.json unchanged.


Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

Co-authored-by: Claude <noreply@anthropic.com>
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