Describe the bug
An async createProjection whose settle is announced by a signal write in the same synchronous
step in which its promise resolves commits correctly — the DOM and direct leaf readers both see
the new value — but a leaf reader that reaches the same leaf through a createMemo(() => store.value) indirection is not notified, and is never notified again. Its last observation stays
the superseded value permanently.
The memo itself is behaving correctly by value: the projection reconciles in place, so
store.value keeps a stable identity and the memo has no reason to recompute. The problem is that
the propagation appears to stop at the memo — the downstream effect's own subscription to the
changed leaf is not re-evaluated on that flush either.
Found while narrowing TanStack/query#11351 (@tanstack/solid-query 6.0.0-rc.1, whose data is a
single createProjection node). The adapter's derive is provably correct: it runs exactly the
expected number of times, the commit lands, and an untracked read through the very same memo
returns the new value. Only the notification is lost.
Your minimal, reproducible example
A vitest file with no TanStack code — only solid-js and @solidjs/testing-library:
import{afterEach,beforeEach,describe,expect,it,vi}from'vitest'import{Loading,createEffect,createMemo,createProjection,createSignal,}from'solid-js'import{render}from'@solidjs/testing-library'describe('async projection commit is not seen through a memo',()=>{beforeEach(()=>vi.useFakeTimers())afterEach(()=>vi.useRealTimers())it('notifies a leaf reader behind a memo',async()=>{constthroughMemo: Array<boolean>=[]constdirect: Array<boolean>=[]letcommitted: {a: boolean}|undefinedletinFlight: Promise<{a: boolean}>|null=nullconst[version,setVersion]=createSignal(0)// Models a cache-backed fetch: on settle, the "cache" is written and// subscribers are notified in the SAME synchronous step in which the// promise resolves.constfetchNow=(next: {a: boolean})=>{inFlight=newPromise((resolve)=>{setTimeout(()=>{committed=nextinFlight=nullsetVersion((v)=>v+1)resolve(next)},10)})}functionProbe(){conststore=createProjection<{value: {a: boolean}}>(()=>{version()if(inFlight)returninFlight.then((d)=>({value: d}))return{value: committed!}},{}as{value: {a: boolean}},)constdata=()=>store.valueconstmemo=createMemo(()=>data())createEffect(()=>memo().a,(v)=>{throughMemo.push(v)},)createEffect(()=>data().a,(v)=>{direct.push(v)},)return<div>{String(data().a)}</div>}fetchNow({a: false})constrendered=render(()=>(<Loadingfallback={<span>loading</span>}><Probe/></Loading>))awaitvi.advanceTimersByTimeAsync(10)expect(throughMemo).toEqual([false])expect(direct).toEqual([false])// refetchfetchNow({a: true})setVersion((v)=>v+1)awaitvi.advanceTimersByTimeAsync(10)// the commit landed: the DOM and the direct reader both see itexpect(rendered.container.textContent).toBe('true')expect(direct.at(-1)).toBe(true)// ...but the reader behind the memo was never toldexpect(throughMemo.at(-1)).toBe(true)})})Steps to reproduce
- Create an async
createProjection with a { value } root wrapper whose derive returns a promise
while a "fetch" is in flight and the committed value otherwise, driven by a version signal. - Read a leaf of
store.value from a tracked computation throughcreateMemo(() => store.value). - Let the first flight settle, then start a second one whose settle writes the value and bumps the
version signal in the same synchronous step in which the promise resolves. - The direct reader and the DOM observe the new value; the reader behind the memo does not, and
never does again.
Expected behavior
The reader behind the memo observes the committed value, like the direct reader does. A subscriber's
last observation should not be the superseded value.
Narrowing
Measured at rc.4. Only the shapes that go through a memo over the projection fail:
| Reader shape | Result |
|---|
effect reads store.value.a | pass |
memo reads store.value.a, effect reads the memo | pass |
effect captures store.value via untrack, then reads .a off it | pass |
effect depends on a suspended memo over an unrelated async source, reads store.value.a | pass |
memo reads store.value, effect reads memo().a | fail |
memo reads store.value, effect reads bothstore.value and memo().a | fail |
Two of these seem load-bearing:
- Row 6: the effect reads the projection directly and depends on the memo, and is still not
notified — so this is not simply a missing subscription. Having the memo in the dependency set
suppresses the update. - Row 4: a suspended memo over an unrelated async source is harmless, so it is not "any suspended
memo dependency" — the memo has to be a consumer of this projection.
Two more observations:
- A memo created after the first settle works fine. Only a memo that suspended on its first run
is affected. - The same effect, given a plain
createStore leaf write instead of a projection commit, is
notified normally. The projection's async commit is required.
How the settle is announced is what decides it: variants that sequence the value write and the
version bump onto separate ticks pass. Making the notification synchronous with the promise
resolution — which is what a cache-backed data source naturally does — is what triggers the failure.
On that flush the derive recomputes and returns a value synchronously, superseding the promise the
node was already parked on.
Environment
solid-js / @solidjs/signals 2.0.0-rc.4, @solidjs/web 2.0.0-rc.3@solidjs/testing-library 0.8.10, vitest 4.1.2, jsdom, client-side only (no SSR)- Node 25.9.0, macOS
Additional context
Not a regression inside Solid as far as I can tell — the TanStack adapter only started hitting it
when it moved onto createProjection for its data node. I could not find a way to avoid it from the
consumer side without giving up either fine-grained leaf tracking (making the committed value's
identity change per commit) or the pending/hold semantics (not handing the engine a promise on
refetch).
Describe the bug
An async
createProjectionwhose settle is announced by a signal write in the same synchronousstep in which its promise resolves commits correctly — the DOM and direct leaf readers both see
the new value — but a leaf reader that reaches the same leaf through a
createMemo(() => store.value)indirection is not notified, and is never notified again. Its last observation staysthe superseded value permanently.
The memo itself is behaving correctly by value: the projection reconciles in place, so
store.valuekeeps a stable identity and the memo has no reason to recompute. The problem is thatthe propagation appears to stop at the memo — the downstream effect's own subscription to the
changed leaf is not re-evaluated on that flush either.
Found while narrowing TanStack/query#11351 (
@tanstack/solid-query6.0.0-rc.1, whosedatais asingle
createProjectionnode). The adapter's derive is provably correct: it runs exactly theexpected number of times, the commit lands, and an untracked read through the very same memo
returns the new value. Only the notification is lost.
Your minimal, reproducible example
A vitest file with no TanStack code — only
solid-jsand@solidjs/testing-library:Steps to reproduce
createProjectionwith a{ value }root wrapper whose derive returns a promisewhile a "fetch" is in flight and the committed value otherwise, driven by a version signal.
store.valuefrom a tracked computation throughcreateMemo(() => store.value).version signal in the same synchronous step in which the promise resolves.
never does again.
Expected behavior
The reader behind the memo observes the committed value, like the direct reader does. A subscriber's
last observation should not be the superseded value.
Narrowing
Measured at rc.4. Only the shapes that go through a memo over the projection fail:
store.value.astore.value.a, effect reads the memostore.valueviauntrack, then reads.aoff itstore.value.astore.value, effect readsmemo().astore.value, effect reads bothstore.valueandmemo().aTwo of these seem load-bearing:
notified — so this is not simply a missing subscription. Having the memo in the dependency set
suppresses the update.
memo dependency" — the memo has to be a consumer of this projection.
Two more observations:
is affected.
createStoreleaf write instead of a projection commit, isnotified normally. The projection's async commit is required.
How the settle is announced is what decides it: variants that sequence the value write and the
version bump onto separate ticks pass. Making the notification synchronous with the promise
resolution — which is what a cache-backed data source naturally does — is what triggers the failure.
On that flush the derive recomputes and returns a value synchronously, superseding the promise the
node was already parked on.
Environment
solid-js/@solidjs/signals2.0.0-rc.4,@solidjs/web2.0.0-rc.3@solidjs/testing-library0.8.10, vitest 4.1.2, jsdom, client-side only (no SSR)Additional context
Not a regression inside Solid as far as I can tell — the TanStack adapter only started hitting it
when it moved onto
createProjectionfor its data node. I could not find a way to avoid it from theconsumer side without giving up either fine-grained leaf tracking (making the committed value's
identity change per commit) or the pending/hold semantics (not handing the engine a promise on
refetch).