Repository navigation
fix(signals): root the awaited refresh() waiter in every tier (#3888) - #3914
Conversation
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 detectedLatest commit: 4a63b5a The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
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 |
Size (brotli, eager entry chunk)
|
Coverage Report for CI Build 37756393253Coverage remained the same at 76.43%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will degrade performance by 6.75%
|
| 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)
Fixes #3888
Thanks to @ethan-huo for the report.
Root cause
refresh()returns a promise backed by a waiter (watch(), the same passresolve()/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 maderefresh()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 getsCONFIG_AUTO_DISPOSE. The waiter has no subscribers, so when the refetched source settles, the settle walk incore/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, ayield refresh(x)action never finishes, and since the action holds the transaction, the refetched value never reaches the UI. Fire-and-forgetrefresh()is unaffected because nothing waits on the waiter.This affects both non-dev tiers,
prodandobserve; onlydevbuilt the root.resolve()anduntil()already always usecreateRoot, sorefresh()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.tsgets 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.yield refresh(x)finishes with the refetched value, and the effect sees it.On
nextboth fail inprodandobserve(4 failures) and pass indev. 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):app: render + one signal(hello world) andsignals: core floorNo scenario imports⚠️ page warnings are identical on
refresh(), so nothing moves. The root only costs bytes for apps that userefresh(), andresolve()/until()already pull increateRootfor theirs. Size gate: passes. (The three pre-existingnext.)Public API changes
None.