Skip to content

fix(signals): EFFECT_RELAY_TEAR stops flagging layout-measuring effects - #4121

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

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

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Requested by Ryan · project thread

Summary

Reported by a library author porting Base UI to Solid 2. The Select popup has a fallback that can only be measured after render:

createEffect(open, () => {
  setAlignFallback(popup.getBoundingClientRect().bottom > innerHeight);
});

Before: near the screen edge, the measured true equals the compute output open === true. The detector read that as a copy and warned on the second open with "read open where alignFallback is read and delete the effect", which is wrong. Without the copy verdict, the advisory still escalated to a console warning after 3 repeats, saying "it is derived state, make it a memo". Apps using popups flooded the console.

After: an effect whose apply half takes no argument is never treated as copying its compute output, and its tears stay info (structured channel only) however often they repeat. An effect that takes the value is judged exactly as on next. So a boolean mirror like v => setOpen(v) and a boolean-keyed derivation like v => setLabel(v ? "Close" : "Open") still warn.

How:

  • The apply half's arity (_effectFn.length) is the signal. An apply half that takes no argument cannot be copying its compute output. If it writes, it's writing from outside the graph: layout, time or a ref. A signal read there is already STRICT_READ's job. An effect node without _effectFn keeps the old behaviour.
  • The sole-writer message no longer asserts "it is derived state". It now carries the same memo-or-measuring fork as the other-writers message.
  • The reactivity-diagnostics skill's EFFECT_RELAY_TEAR section is updated to match. It also notes that a measuring effect that takes its value (i => setLeft(offsets[i]) with stable layout) still warns, so drop the unused parameter. The solid-docs diagnostics page picks this up on the next sync:diagnostics.

This revision replaces the first one, which exempted booleans and required 3 distinct inputs. The merge-train review found that the first version silenced real boolean mirrors. The size-cap raise is dropped with it.

How did you test this change?

  • pnpm vitest run tests/attribution-relay-tear.test.ts in packages/signals: 12 pass. The new tests cover:
    • the Select shape (5 open/close cycles stay one info, with no console warning)
    • an arity-0 relay whose same input wrote different values, which never escalates
    • a boolean mirror and a boolean→"Open"/"Close" derivation, both of which still warn
  • Full packages/signals suite after pnpm build: 311 files and 5342 tests pass, dist-artifacts included.
  • Size, observe tier + attribution engine, measured locally against next @ f3973ea: minified 88,235 -> 88,181 B (−54 B). Brotli 29,462 -> 29,492 B shows up as a gate warning within the minified allowance. No cap changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_013UCcDHG5TmofXFqENFHC2b

@ryansolid ryansolid self-assigned this Oct 16, 2026
@changeset-bot

changeset-bot Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 73f9836

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

This PR includes changesets to release 14 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
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 16, 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.24 KB 0 B 0 B 0 B 7.25 KB ✅
signals: + createStore 14.77 KB 0 B 0 B 0 B 14.79 KB ✅
signals: + isPending/latest 9.95 KB 0 B 0 B +4 B 9.96 KB ✅
signals: + until/refresh (truth readers, no optimism) 7.85 KB 0 B 0 B −1132 B 7.86 KB ✅
app: render + one signal (the simple-app floor) 9.55 KB 0 B 0 B 0 B 9.56 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.17 KB 0 B 0 B 0 B 18.19 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.09 KB 0 B 0 B +4 B 30.09 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.12 KB 0 B 0 B 0 B 13.14 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.86 KB −30 B (−0.2%) 0 B 0 B 14.91 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.49 KB +30 B (+0.1%) −54 B −54 B 29.48 KB ⚠️ over by 12 B, 74 B minified headroom lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.75 KB 0 B 0 B +2 B 9.77 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.94 KB 0 B 0 B 0 B 25.95 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.95 KB 0 B 0 B 0 B 31.96 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.87 KB 0 B 0 B 0 B 11.87 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.83 KB 0 B 0 B 0 B 34.84 KB ✅ assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.04 KB, wire.js 1.35 KB
page: live server components (base + live/GET + action + isPending/latest) 39.03 KB 0 B 0 B +4 B 39.01 KB ⚠️ over by 17 B, 16 B minified headroom assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.04 KB, wire.js 1.35 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.77 KB 0 B 0 B 0 B 35.78 KB ✅ assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.25 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 9.01 KB, wire.js 1.35 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.77 KB 0 B 0 B +4 B 41.77 KB ⚠️ over by 3 B, 16 B minified headroom eager (counted): web.js 22.26 KB; assets.js 0.78 KB, bind.js 2.55 KB, decode.js 6.25 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.99 KB, wire.js 1.35 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 42.23 KB 0 B 0 B 0 B 42.25 KB ✅ assets.js 0.78 KB, bind.js 2.56 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, serverForms.js 3.59 KB, trace.js 9.03 KB, wire.js 1.36 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.44 KB 0 B 0 B +4 B 48.46 KB ✅ eager (counted): client.js 28.46 KB; assets.js 0.78 KB, bind.js 2.56 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, serverForms.js 3.30 KB, trace.js 9.01 KB, wire.js 1.36 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.60 KB 0 B 0 B +7 B 20.58 KB ⚠️ over by 20 B, 13 B minified headroom

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

  • app: CSR, observe tier + attribution engine enabled: over brotli cap by 12 B; minified 88,181 B vs 88,235 B recorded with the cap (−54 B) — 74 B of the 20 B minified allowance left; −54 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 17 B; minified 120,189 B vs 120,185 B recorded with the cap (+4 B) — 16 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 3 B; minified 124,662 B vs 124,658 B recorded with the cap (+4 B) — 16 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • server: renderToString (the server-render floor): over brotli cap by 20 B; minified 72,380 B vs 72,373 B recorded with the cap (+7 B) — 13 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 16, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 39002680073

Coverage remained the same at 77.554%

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: 1338
Covered Lines: 1096
Line Coverage: 81.91%
Relevant Branches: 1050
Covered Branches: 756
Branch Coverage: 72.0%
Branches in Coverage %: Yes
Coverage Strength: 27.79 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 2 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 200 untouched benchmarks


Comparing claude/project-thread-nh96yw (73f9836) with next (f3973ea)

Open in CodSpeed

An effect whose apply half takes no argument is reading something other
than its compute output (layout, time, a ref), so a write that matches the
compute output is not a copy and its tears stay advisory however often they
repeat. Effects that take the value are judged as before: boolean mirrors
and boolean-keyed derivations still warn. The sole-writer message offers the
memo-or-measuring fork instead of asserting derived state.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UCcDHG5TmofXFqENFHC2b
@ryansolid
ryansolid force-pushed the claude/project-thread-nh96yw branch from 45e6d71 to 73f9836 Compare October 17, 2026 04:28

Copy link
Copy Markdown
Member Author

CodSpeed is the only red check. It flags one benchmark, mount 4000 rows › memo + sync render effect only (reference): 27.7 ms → 33.3 ms. This PR doesn't cause it:

  • The diff only touches core/attribution.ts, which loads only when attribution is enabled. mount-rows.bench.ts imports only core primitives and never enables it.
  • On this PR's earlier heads, the same benchmark swung the other way, +20% and then +16% "improvements".
  • The merge-train review saw the same benchmark's noise on next itself.

No code fix applies. I'm re-running the benchmark job once. If it stays red, the regression needs acknowledging on CodSpeed.


Generated by Claude Code

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