Skip to content

2.0.0-beta.26 | optimistic store unexpected pending state for store or dependency, while having an optimistic override #2951

Closed

Description

@mizulu

Describe the bug

There are 2 main Issues

  1. the unexpected pending state of the store while it has an optimistic override
    both case1 and case2 of the repro covers this.

  2. In case 2 of the reproduction, it shows few issues
    a. the count() entering the pending state on the 2nd quick consecutive click
    b. the 2nd optimistic write instead of having 2 new items added, the item from the first click is getting overridden , however if you manage to click more than 2 times quickly, say 3 quick clicks or more
    from the 3rd click you will see new items being added optimistically.
    when settle we get the missing first item that was overridden by the 2nd click


start playground wait until Items is loaded

repro steps for the pending case, but since there are multiple issues here
you can see the video for the incorrect override state.

Case1:

  1. click the increment button increment once
    renders:
    s_not_pending
    count_not_pending
    Case2:
  2. click the increment button 2 times quickly
    on the first click
    renders:
    s_pending <----
    count_not_pending
    on the 2nd click
    renders:
    s_pending <----
    count_pending <----

In both cases
the button click increment the count() and should make
and reruns the store function, which should be pending
until data() is resolved

however we see that
store.items is pending even though we have an optimistic write
and on the 2nd call we also get count() back into pending

it is not clear why count is not pending on first click
but then is pending on the 2nd click ( case 2)

also why s.items is pending in the first and 2nd click
in both cases ( case 1 and case 2 )
it was assumed that an optimistic write to a store should
take it out of pending due to the "override"

Your Example Website or App

https://s.olid.uk/id/cBNfYlwqQxyf0NyEvb62DQ

Steps to Reproduce the Bug or Issue

import { render } from '@solidjs/web';
import { createSignal, createOptimisticStore, Loading, isPending } from 'solid-js';

const time = async (ms = 1000) => new Promise(r => setTimeout(r, ms))




export default function App() {

  const [count, setCount] = createSignal(0);


  // static data source
  let _data = [{ id: 1, name: "A" }];


  // no reactiviy deps in this function 
  async function data() {
    console.log("data function...")

    await time() // small delay

    console.log("/data function...")
    return {
      items: [
        ..._data
      ]
    }
  }


  // store has count() and data() as deps
  const [s, setStore] = createOptimisticStore(() => {
    console.log("Store Function")

    count() // add count as dep
    return data()
  }, { items: [] })





  // simulate new data in static source.
  function pushNewBackEnd() {
    const item = { id: _data.length + 1, name: "U" + (_data.length + 1) }
    _data.push(item)

    // this starts an optimistic override
    // when count is incrmented with the button click 
    // count does not show pending, because this takes it out of the transition 
    // however the store.items still shows as pending 
    // while the data() is resolved.
    // without this optimistic write both store.items and count 
    // show as pending. 
    setStore(s => { s.items.push({ ...item, ...{name: item.name+"*"}, id:item.id+"*" }) })

  }
  return (
    <div class="p-2">
      <Loading>
        <div>{isPending(() => s.items) ? <b>s_pending</b> : "s_not_pending"}</div>
        <div>{isPending(count) ? <b>count_pending</b> : "count_not_pending"}</div>
      </Loading>

      <div>
        Increment To trigger New Item <button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" onClick={() => {
          setCount(count() + 1) // trigger deps change in 
          pushNewBackEnd() // push new item to static source
                           // and do optimistic update 

        }}>
          Increment: {count()}
        </button>

      </div>



      <div style="font-size:24px;">Items:</div>
      <Loading fallback={"Loading..."}>
        {s.items.map((v) => {
          return <div>Id: {v.id}  Name:{v.name}</div>
        })}
      </Loading>
    </div>
  );
}


if (typeof document !== 'undefined') {
  render(() => <App />, document.getElementById('root')!);
}

Expected behavior

it might be expected that an optimistic override
to a pending store, will take it out of the transition
similar to how count is first not part of the transition

so in both case1 and case2 it might be expected
that count and store.items will not show pending due to the optimistic store right.
but it is possible that because the optimistic store is that one holding the transition
and also the one that has the optimistic override
the system struggles to manage the pending state ( just an assumption )

Screenshots or Videos

Image

Platform

.

Additional context

No response

Activity

  1. ryansolid commented on Jul 28, 2026

    @ryansolid
    Member

    Fixed in e9e6a78 (next). Taking the reported symptoms one at a time:

    2b. Second click clobbers the first optimistic item — bug, fixed

    This was the real defect, and the root cause explains the "works from the 3rd click" weirdness too.

    A bare (transaction-less) optimistic store write made in the same tick as a refetch trigger was dying at the end of its own flush. Internally, the flush that starts the refetch opens a transaction and adopts your optimistic write into it — but the "should this transaction stay open?" check only recognized signal-form optimistic state, where the pending async and the override live on one node. A derived optimistic store splits them: the override layer sits on the store while the in-flight truth lives on its backing computed. So the transaction settled in the same flush that started the refetch, and its settle consumed your override while the fetch was still in flight.

    The UI kept showing the first optimistic item (the effect had already rendered it), but the store had silently reverted to base underneath — so the second click's draft composed on base and clobbered it. The rule is now enforced for stores exactly as for signals: optimistic state clears when truth lands or its transaction ends — never mid-refetch. Consecutive writes to the same optimistic store during an in-flight refetch stack on the live optimistic view, and the settled data supersedes everything:

    • click 1: ["A", "U2*"]
    • click 2: ["A", "U2*", "U3*"] (composes, no clobber)
    • settle: server truth replaces the tentative layer

    1. s.items pending despite the override — by design

    isPending answers "is unsettled work in flight for this data?" — and it is: the refetch you triggered is still running. The override supplies an interim value; it doesn't cancel the question. This is the same loading/pending distinction ruled in #2910/#2929. It's also what makes the standard pattern work: isPending(() => s.items) is exactly how you show a "saving…" indicator next to optimistically-rendered rows.

    2a. count() pending on the 2nd click but not the 1st — by design (and more coherent after the fix)

    On click 1 there's no transaction in flight when setCount runs, so it commits immediately. On click 2 your previous click's transaction is still open (held by the refetch), and same-tick writes entangle with it — the write is held with the transaction and reveals when it settles. Before the fix that transaction was settling instantly (making the "pending on 2nd click" look arbitrary); now it genuinely spans the refetch, so the entanglement is visible and consistent.

    Regression tests cover same-tick stacking, write-order independence, writes landing mid-refetch on a later tick, and that a bare write with no refetch in flight still reverts at flush end (unchanged flash semantics). #2899's disjoint-action independence is untouched — per-key owner stamps still scope each action's settle.

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