Skip to content

2.0.0-rc.4 | async createProjection commit is not seen through a memo over the projection — leaf reader behind createMemo(() => store.value) is never notified again #3181

Description

@mgarcialeniolabs

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 () => {
    const throughMemo: Array<boolean> = []
    const direct: Array<boolean> = []

    let committed: { a: boolean } | undefined
    let inFlight: Promise<{ a: boolean }> | null = null
    const [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.
    const fetchNow = (next: { a: boolean }) => {
      inFlight = new Promise((resolve) => {
        setTimeout(() => {
          committed = next
          inFlight = null
          setVersion((v) => v + 1)
          resolve(next)
        }, 10)
      })
    }

    function Probe() {
      const store = createProjection<{ value: { a: boolean } }>(
        () => {
          version()
          if (inFlight) return inFlight.then((d) => ({ value: d }))
          return { value: committed! }
        },
        {} as { value: { a: boolean } },
      )
      const data = () => store.value

      const memo = 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 })
    const rendered = render(() => (
      <Loading fallback={<span>loading</span>}>
        <Probe />
      </Loading>
    ))
    await vi.advanceTimersByTimeAsync(10)
    expect(throughMemo).toEqual([false])
    expect(direct).toEqual([false])

    // refetch
    fetchNow({ a: true })
    setVersion((v) => v + 1)
    await vi.advanceTimersByTimeAsync(10)

    // the commit landed: the DOM and the direct reader both see it
    expect(rendered.container.textContent).toBe('true')
    expect(direct.at(-1)).toBe(true)

    // ...but the reader behind the memo was never told
    expect(throughMemo.at(-1)).toBe(true)
  })
})

Steps to reproduce

  1. 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.
  2. Read a leaf of store.value from a tracked computation through createMemo(() => store.value).
  3. 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.
  4. 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 both store.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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions