Skip to content

fix(signals): root the awaited refresh() waiter in every tier (#3888) - #3914

Merged
ryansolid merged 1 commit into
nextfrom
fix/rc15-3888
Oct 8, 2026
Merged

ryansolid merged 1 commit into
nextfrom
fix/rc15-3888

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Fixes #3888

Thanks to @ethan-huo for the report.

Root cause

refresh() returns a promise backed by a waiter (watch(), the same pass resolve()/until() use) that re-runs when the refetch settles. The waiter was created with __DEV__ ? createRoot(make) : make(). The root was dropped outside dev in 51ffcb9 (the change that made refresh() awaitable) to save ~560 B: at the time it only existed to silence the dev-only "effect without an owner" warning.

Under the L2 core (#3774) that's no longer safe. A computed() created with no owner gets CONFIG_AUTO_DISPOSE. The waiter has no subscribers, so when the refetched source settles, the settle walk in core/async.ts (releaseIfSettledUnobserved) releases it as a settled, unobserved node before its re-run is scheduled. The waiter never runs again. The promise never settles, a yield refresh(x) action never finishes, and since the action holds the transaction, the refetched value never reaches the UI. Fire-and-forget refresh() is unaffected because nothing waits on the waiter.

This affects both non-dev tiers, prod and observe; only dev built the root. resolve() and until() already always use createRoot, so refresh() was the only waiter without one.

Fix

Always call createRoot(make), and replace the "No createRoot" comment with the constraint. Nothing depended on the root being absent: the waiter still disposes itself directly on settle, as dev already did.

Test

packages/signals/tests/dist-artifacts.test.ts gets a per-tier block, following the existing "cleanup order per tier" pattern, that imports each built artifact (dist/prod, dist/observe, dist/dev). Each tier gets two tests over an async memo with a render effect reading it:

  • await refresh(x) resolves with the refetched value, and the effect sees it.
  • an action's yield refresh(x) finishes with the refetched value, and the effect sees it.

On next both fail in prod and observe (4 failures) and pass in dev. With the fix, all 6 pass. The full signals suite passes.

Size

From CI's Size job (Rolldown, brotli, eager entry chunk, vs base next @ 8d23a5a):

scenario vs base (brotli) minified vs base
every scenario, including app: render + one signal (hello world) and signals: core floor 0 B 0 B

No scenario imports refresh(), so nothing moves. The root only costs bytes for apps that use refresh(), and resolve()/until() already pull in createRoot for theirs. Size gate: passes. (The three pre-existing ⚠️ page warnings are identical on next.)

Public API changes

None.

An unowned computed is auto-disposing, and the settle walk releases an
unobserved one instead of re-running it, so the prod/observe waiter (built
without a root to save bytes) never saw the refetch land: an awaited
refresh() and an action's yield refresh(x) hung, and the refetched value
never rendered.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Oct 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4a63b5a

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 8, 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 0 B 0 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 0 B 0 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 0 B 0 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 0 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

Copy link
Copy Markdown

Coverage Report for CI Build 37756393253

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 degrade performance by 6.75%

⚠️ 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 regressed benchmark
✅ 187 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ memo + sync render effect only (reference) 28 ms 30 ms -6.75%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/rc15-3888 (4a63b5a) with next (8d23a5a)

Open in CodSpeed

@ryansolid
ryansolid merged commit 5a26ebe into next Oct 8, 2026
7 of 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