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()=>{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

  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 throughcreateMemo(() => 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 shapeResult
effect reads store.value.apass
memo reads store.value.a, effect reads the memopass
effect captures store.value via untrack, then reads .a off itpass
effect depends on a suspended memo over an unrelated async source, reads store.value.apass
memo reads store.value, effect reads memo().afail
memo reads store.value, effect reads bothstore.value and memo().afail

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

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
      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()=>{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

      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 throughcreateMemo(() => 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 shapeResult
      effect reads store.value.apass
      memo reads store.value.a, effect reads the memopass
      effect captures store.value via untrack, then reads .a off itpass
      effect depends on a suspended memo over an unrelated async source, reads store.value.apass
      memo reads store.value, effect reads memo().afail
      memo reads store.value, effect reads bothstore.value and memo().afail

      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

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          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()=>{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

          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 throughcreateMemo(() => 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 shapeResult
          effect reads store.value.apass
          memo reads store.value.a, effect reads the memopass
          effect captures store.value via untrack, then reads .a off itpass
          effect depends on a suspended memo over an unrelated async source, reads store.value.apass
          memo reads store.value, effect reads memo().afail
          memo reads store.value, effect reads bothstore.value and memo().afail

          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

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              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()=>{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

              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 throughcreateMemo(() => 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 shapeResult
              effect reads store.value.apass
              memo reads store.value.a, effect reads the memopass
              effect captures store.value via untrack, then reads .a off itpass
              effect depends on a suspended memo over an unrelated async source, reads store.value.apass
              memo reads store.value, effect reads memo().afail
              memo reads store.value, effect reads bothstore.value and memo().afail

              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

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  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()=>{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

                  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 throughcreateMemo(() => 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 shapeResult
                  effect reads store.value.apass
                  memo reads store.value.a, effect reads the memopass
                  effect captures store.value via untrack, then reads .a off itpass
                  effect depends on a suspended memo over an unrelated async source, reads store.value.apass
                  memo reads store.value, effect reads memo().afail
                  memo reads store.value, effect reads bothstore.value and memo().afail

                  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

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      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()=>{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

                      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 throughcreateMemo(() => 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 shapeResult
                      effect reads store.value.apass
                      memo reads store.value.a, effect reads the memopass
                      effect captures store.value via untrack, then reads .a off itpass
                      effect depends on a suspended memo over an unrelated async source, reads store.value.apass
                      memo reads store.value, effect reads memo().afail
                      memo reads store.value, effect reads bothstore.value and memo().afail

                      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

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          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()=>{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

                          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 throughcreateMemo(() => 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 shapeResult
                          effect reads store.value.apass
                          memo reads store.value.a, effect reads the memopass
                          effect captures store.value via untrack, then reads .a off itpass
                          effect depends on a suspended memo over an unrelated async source, reads store.value.apass
                          memo reads store.value, effect reads memo().afail
                          memo reads store.value, effect reads bothstore.value and memo().afail

                          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

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              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()=>{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

                              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 throughcreateMemo(() => 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 shapeResult
                              effect reads store.value.apass
                              memo reads store.value.a, effect reads the memopass
                              effect captures store.value via untrack, then reads .a off itpass
                              effect depends on a suspended memo over an unrelated async source, reads store.value.apass
                              memo reads store.value, effect reads memo().afail
                              memo reads store.value, effect reads bothstore.value and memo().afail

                              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