Skip to content

createOptimisticStore overlay is not reverted after a failed action while attribution is enabled (rc.10 regression) #3687

Closed

Description

@ethan-huo

When the attribution engine is on — captureArtifact from @solidjs/diagnostics, or the performance tracks @solidjs/vite-plugin 3.0.0-next.46 enables by default under vite dev — an optimistic store write made inside an action that fails is not reverted when the action settles. The same code reverts correctly with attribution off, and reverts under attribution on rc.9.

createOptimistic (signal) is not affected. The source kind does not matter (Promise and AsyncIterable both reproduce).

Steps (full test below):

  1. createOptimisticStore(() => source, [], { key: 'id' }) with one row { id: 'a', done: false }.
  2. An action(function* () { setList(l => { l[0].done = true }); try { yield Promise.reject(new Error('x')) } catch {} }).
  3. Run it inside captureArtifact(...), flush, and read the row after settle.

Expected: done === false (the overlay drops at settle; RFC 06).
Actual (rc.10, attribution on): done === true.

Results of the test below:

Case rc.9 rc.10
store, Promise source, attribution off pass pass
store, Promise source, captureArtifact pass fail (a:true)
store, AsyncIterable source, attribution off pass pass
store, AsyncIterable source, captureArtifact pass fail (a:true)
createOptimistic(false), attribution off pass pass
createOptimistic(false), captureArtifact pass pass

Related symptoms seen in the same scenario family (larger test, not minimized):

  • an OPTIMISTIC_REVERTED finding reporting "showed 1; it settled to 1" (same value) under expectNoDiagnostics;
  • an unhandled rejection TypeError: Cannot read properties of undefined (reading '_host') during the run.

Observation changing runtime semantics also means dev servers with the default performance tracks behave differently from production for optimistic stores.

Environment:

  • solid-js, @solidjs/web, @solidjs/signals, @solidjs/diagnostics: 2.0.0-rc.10 (compared with 2.0.0-rc.9)
  • @solidjs/vite-plugin 3.0.0-next.46 (rc.9 comparison: 3.0.0-next.44)
  • vitest via vite-plus 0.3.3, jsdom; Node v26.10.0; Bun 1.4.2; macOS arm64

Reproduction

import { captureArtifact } from '@solidjs/diagnostics'
import { action, createOptimistic, createOptimisticStore, createRoot, createEffect, flush } from 'solid-js'
import { expect, it } from 'vitest'

const tick = () => new Promise((r) => setTimeout(r, 0))
async function settle() { for (let i = 0; i < 4; i++) { await tick(); flush() } }

type Row = { id: string; done: boolean }
function liveSource(initial: Row[]): AsyncIterable<Row[]> {
  return { async *[Symbol.asyncIterator]() { yield initial; await new Promise(() => {}) } }
}

for (const source of ['promise', 'async-iterable'] as const)
  for (const observed of [false, true]) {
    it(`failed action reverts: source=${source} captureArtifact=${observed}`, async () => {
      const body = async () => {
        const seen: string[] = []
        let run!: () => Promise<unknown>
        const dispose = createRoot((d) => {
          const init = [{ id: 'a', done: false }]
          const [list, setList] = createOptimisticStore<Row[]>(() => source === 'promise' ? Promise.resolve(init) : liveSource(init), [], { key: 'id' })
          createEffect(() => list.map((r) => `${r.id}:${r.done}`).join(' '), (v) => { seen.push(v) })
          const act = action(function* () {
            setList((l) => { if (l[0]) l[0].done = true })
            try { yield Promise.reject(new Error('x')) } catch {}
          })
          run = () => act()
          return d
        })
        await settle(); await run(); await settle()
        dispose()
        return seen.at(-1)
      }
      let last: string | undefined
      if (observed) await captureArtifact(async () => { last = await body() }, { scenario: 'probe' })
      else last = await body()
      expect(last).toBe('a:false')
    })
  }

for (const observed of [false, true])
  it(`createOptimistic(false) reverts after caught failure: captureArtifact=${observed}`, async () => {
    const body = async () => {
      let read!: () => boolean, run!: () => Promise<unknown>
      const dispose = createRoot((d) => {
        const [busy, setBusy] = createOptimistic(false)
        read = busy
        createEffect(() => busy(), () => {})
        const act = action(function* () { setBusy(true); try { yield Promise.reject(new Error('x')) } catch {} })
        run = () => act()
        return d
      })
      await settle(); await run(); await settle()
      const v = read(); dispose(); return v
    }
    let last: boolean | undefined
    if (observed) await captureArtifact(async () => { last = await body() }, { scenario: 'probe2' })
    else last = await body()
    expect(last).toBe(false)
  })

Activity

  1. ethan-huo commented on Sep 28, 2026

    @ethan-huo
    Author

    Additional evidence from a larger scenario in the same family (optimistic store fed by a live source, two overlapping actions, run inside captureArtifact): the attribution engine's revert check throws, which surfaces as an unhandled rejection and is likely also behind the "showed 1; it settled to 1" OPTIMISTIC_REVERTED finding:

    TypeError: Cannot read properties of undefined (reading '_host')
     ❯ slotNodeEquals            @solidjs/signals/dist/dev.js:4699:48
     ❯ checkOptimisticRevert     @solidjs/signals/dist/dev.attribution.js:1848:17
     ❯ Object.optimisticReverted @solidjs/signals/dist/dev.attribution.js:2784:5
     ❯ resolveOptimisticNodes    @solidjs/signals/dist/dev.js:510:21
     ❯ GlobalQueue._resolveOptimistic @solidjs/signals/dist/dev.js:7962:5
    

    (@solidjs/signals 2.0.0-rc.10)

  2. ryansolid commented on Sep 28, 2026

    @ryansolid
    Member

    Confirmed and fixed in #3692 — thank you for the matrix and the stack trace, both pointed straight at it.

    Root cause: the OPTIMISTIC_REVERTED check called the node's comparator detached from the node. Store slot nodes share one comparator that reads this._host, so with the engine on the check threw inside the settle before the drop notified subscribers. The override was lifted internally but nobody reading the store was told. Plain createOptimistic uses isEqual with no this, which is why it was unaffected.

    On the "showed 1; it settled to 1" finding from your larger scenario: with the comparator now invoked correctly the store's equality short-circuits on same values, so I expect that one to go quiet too. If it still shows up on the fix, please reopen with the case — it would be a separate path.

    — Claude via Cursor

  3. ryansolid commented on Sep 28, 2026

    @ryansolid
    Member

    Fixed by #3692, squash-merged to next as 656f0df — thanks for the precise reproduction. Closing by hand because GitHub only auto-closes on merges into the default branch, and 2.0 development lands on next. It will ship in the next RC.

    — Claude via Cursor

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