Skip to content

fix(signals): skip UNSTABLE_MEMO_OUTPUT for merge()'s function-source memo (#4256) - #4260

Open
ryansolid wants to merge 1 commit into
nextfrom
claude/project-thread-x1ore2-4256
Open

ryansolid wants to merge 1 commit into
nextfrom
claude/project-thread-x1ore2-4256

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Before: a component spread whose expression isn't a plain object, such as <Child {...(on() ? { tone: "info" } : {})} />, compiles to mergeProps(() => …). merge() wraps that function in a memo with no options. The idiom returns a fresh key bag on every run, so after four runs the attribution engine reports UNSTABLE_MEMO_OUTPUT for memo "computed" and says to pass equals. Nobody wrote that memo, so nobody can pass it options.

After: observe builds create that memo with the name merge and a new CONFIG_KEY_BAG bit. The unstable-output check skips any memo carrying that bit. Production output is unchanged: the prod branch is still createMemo(s).

How: merge() passes { name: "merge", _extraConfig: CONFIG_KEY_BAG } behind __OBSERVE__, using the existing _extraConfig route. checkUnstableOutput returns early for that bit. This follows the existing exemptions (CONFIG_WIDE, CONFIG_SUPERSEDES).

The issue also proposed a shallow equals on the merge memo. I left that out because it would add bytes to every app that spreads into a component.

Fixes #4256

How did you test this change?

  • New packages/signals/tests/attribution-unstable-merge-4256.test.ts uses the issue's repro shape with merge(() => …) and a reader of props.tone, run eight times. It fails on next (one UNSTABLE_MEMO_OUTPUT) and passes with this change.
  • The existing attribution-unstable-memo.test.ts still passes, so user memos still warn.
  • signals: 5,577 passed (2 expected fail, 2 skipped). The build's type check is clean, and dist/prod/store/utils.js still emits createMemo(o).

Generated by Claude Code

… memo (#4256)

A component spread whose expression is not a plain object compiles to
merge(fn), and merge() wraps the function in a memo with no options. The
idiom returns a fresh key bag every run, so the attribution engine reported
UNSTABLE_MEMO_OUTPUT for memo "computed" with advice (`equals`) nobody can
apply. Observe builds now create that memo as `merge` with a new
CONFIG_KEY_BAG bit, and the unstable-output check skips it. Production
creates the memo exactly as before.

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 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 78dab45

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

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 −79 B 7.44 KB ✅
signals: + createStore 14.97 KB 0 B 0 B −930 B 15.04 KB ✅
signals: + isPending/latest 10.30 KB 0 B 0 B −83 B 10.30 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.06 KB 0 B 0 B −85 B 8.05 KB ⚠️ over by 5.9999999999990905 B, 105 B minified headroom
app: render + one signal (the simple-app floor) 9.79 KB 0 B 0 B −84 B 9.75 KB ⚠️ over by 35 B, 104 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.43 KB 0 B 0 B −93 B 18.38 KB ⚠️ over by 45 B, 113 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.68 KB 0 B 0 B −949 B 30.66 KB ⚠️ over by 21 B, 969 B minified headroom lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.38 KB 0 B 0 B −78 B 13.33 KB ⚠️ over by 51 B, 98 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.10 KB +7 B (+0.0%) 0 B −79 B 15.08 KB ⚠️ over by 19 B, 99 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.68 KB +1 B (+0.0%) +15 B −60 B 29.64 KB ⚠️ over by 41 B, 80 B minified headroom lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.96 KB 0 B 0 B −83 B 9.93 KB ⚠️ over by 32 B, 103 B minified headroom
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.24 KB 0 B 0 B −923 B 26.23 KB ⚠️ over by 6 B, 943 B minified headroom stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.23 KB 0 B 0 B −946 B 32.23 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.33 KB 0 B 0 B −83 B 33.27 KB ⚠️ over by 56 B, 103 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.03 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 37.58 KB 0 B 0 B −93 B 37.64 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.04 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.33 KB 0 B 0 B −87 B 35.30 KB ⚠️ over by 29 B, 107 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.08 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.49 KB 0 B 0 B −91 B 41.47 KB ⚠️ over by 21 B, 111 B minified headroom eager (counted): web.js 22.12 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.19 KB, trace.js 9.08 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 40.81 KB 0 B 0 B −89 B 40.78 KB ⚠️ over by 29 B, 109 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, server.js 1.03 KB, serverForms.js 3.58 KB, trace.js 9.09 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.00 KB 0 B 0 B −99 B 47.01 KB ✅ eager (counted): client.js 27.16 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.32 KB, trace.js 9.09 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B −69 B 1.34 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)

  • signals: + until/refresh (truth readers, no optimism): over brotli cap by 5.9999999999990905 B; minified 21,861 B vs 21,946 B recorded with the cap (−85 B) — 105 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: render + one signal (the simple-app floor): over brotli cap by 35 B; minified 26,945 B vs 27,029 B recorded with the cap (−84 B) — 104 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 45 B; minified 53,505 B vs 53,598 B recorded with the cap (−93 B) — 113 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating + every store primitive family: over brotli cap by 21 B; minified 94,888 B vs 95,837 B recorded with the cap (−949 B) — 969 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: CSR with Show/For/Loading/Errored/lazy: over brotli cap by 51 B; minified 37,174 B vs 37,252 B recorded with the cap (−78 B) — 98 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: CSR, observe tier (same app on the observe artifacts): over brotli cap by 19 B; minified 42,130 B vs 42,209 B recorded with the cap (−79 B) — 99 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: CSR, observe tier + attribution engine enabled: over brotli cap by 41 B; minified 88,467 B vs 88,527 B recorded with the cap (−60 B) — 80 B of the 20 B minified allowance left; +15 B minified over this PR's base
  • app: compiled floor (one template, one text hole, one delegated click): over brotli cap by 32 B; minified 27,477 B vs 27,560 B recorded with the cap (−83 B) — 103 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store): over brotli cap by 6 B; minified 80,404 B vs 81,327 B recorded with the cap (−923 B) — 943 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 56 B; minified 101,443 B vs 101,526 B recorded with the cap (−83 B) — 103 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 29 B; minified 107,789 B vs 107,876 B recorded with the cap (−87 B) — 107 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 21 B; minified 123,029 B vs 123,120 B recorded with the cap (−91 B) — 111 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 29 B; minified 125,275 B vs 125,364 B recorded with the cap (−89 B) — 109 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

Copy link
Copy Markdown

Coverage Report for CI Build 40149257064

Warning

No base build found for commit 2a0da2f on next.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 77.764%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 1358
Covered Lines: 1114
Line Coverage: 82.03%
Relevant Branches: 1066
Covered Branches: 771
Branch Coverage: 72.33%
Branches in Coverage %: Yes
Coverage Strength: 27.72 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 13.38%

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

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 195 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) 29.6 ms 34.1 ms -13.38%

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-4256 (78dab45) with next (2a0da2f)

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

Copy link
Copy Markdown
Member Author

CodSpeed flags one benchmark: the mount-rows "memo + sync render effect only (reference)" bench, 29.6 → 34.3 ms. The benches run the prod tier, and this PR leaves that tier's merge() call as createMemo(s), the same as base. The size report also shows 0 B change on every prod scenario. CodSpeed says base and head ran on different runner types. This reference bench flips by about the same amount between runner types: #4250 and #4251 went 34.2 → 29.6 ms against an earlier base. I'm treating it as runner noise and re-running CodSpeed once.


Generated by Claude Code

@ryansolid
ryansolid marked this pull request as ready for review October 25, 2026 01:20
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