fix(signals): settle resolve() when the async source rejects - #2849
fix(signals): settle resolve() when the async source rejects#2849brenelz wants to merge 1 commit into
Conversation
resolve() was built on a bare computed, which is pull-based: a pending source that resolves re-enqueues blocked dependents via settlePendingSource(), but a rejection only marks subscribers errored for the next pull. Nothing ever pulls resolve()'s computed, so the returned promise never settled and the internal root leaked. Rebuild resolve() on a user effect — the primitive with an error notification channel — so a rejection rejects the promise with the original error (StatusError unwrapped, matching error boundaries) and disposes the root. Fixes solidjs#2842 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
Root cause is exactly right, and the asymmetry framing is the valuable part: Rebuilding on a user effect is the right fix — the effect's error-notification channel ( |
…from #2849) resolve() sat on a bare computed with no subscribers: settlePendingSource re-enqueues blocked dependents when a pending source resolves, but a rejection only propagates STATUS_ERROR via notifyStatus — marking the computed errored for a pull nothing would ever perform. The promise never settled and the internal root leaked. Rebuild on a user effect, whose _notifyStatus → _errorFn channel is actively invoked on rejection: resolve now rejects with the user's original error (StatusError unwrapped, same as error boundaries expose) and disposes its root on every terminal state. Co-authored-by: Brenley Dueck <brenelz@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Fixes #2842 —
resolve(fn)hung forever (never resolved, never rejected) and leaked its internalcreateRootwhenever the awaited async source rejected after having been pending. Resolution worked; rejection did not.Root cause
resolve()was built on a barecomputedinside a root, and computeds are pull-based. The two settlement paths are asymmetric:settlePendingSource()explicitly re-enqueues blocked dependents, so the computed re-ran, calledres(fn()), and disposed the root.handleAsync's error path only propagatesSTATUS_ERRORthroughnotifyStatus(), which marks subscribers errored for their next pull but never re-runs them. Nothing ever readsresolve()'s computed (it has no subscribers), so neitherresnorrejfired anddispose()never ran.Fix
Rebuild
resolve()on a user effect — the primitive that owns an error-notification channel (_notifyStatus→_errorFn) and is therefore actively told when a pending source rejects:res(value)+ dispose (same observable timing as before)rej(err)+ dispose, unwrapping the internalStatusErrorwrapper so the promise rejects with the user's original error, matching whatcreateErrorBoundaryexposesAlso documents the rejection behavior in the JSDoc.
Tests
New
tests/resolve.test.tspins the settlement contract:Full
solid-signalssuite (814 tests) andsolidsuite (418 tests) pass.🤖 Generated with Claude Code