Repository navigation
Description
Describe the bug
There are 2 main Issues
-
the unexpected pending state of the store while it has an optimistic override
both case1 and case2 of the repro covers this. -
In case 2 of the reproduction, it shows few issues
a. thecount()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:
- click the increment button increment once
renders:
s_not_pending
count_not_pending
Case2: - 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
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
Platform
.
Additional context
No response
Activity
fix(signals): hold optimistic store layers while their truth is in fl…
ryansolid commentedon Jul 28, 2026
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:
["A", "U2*"]["A", "U2*", "U3*"](composes, no clobber)1.
s.itemspending despite the override — by designisPendinganswers "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
setCountruns, 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.
perf(signals): shrink the transition blocked-check below its pre-#2951 …
chore(size): raise the +createStore budget to 13.15 KB
store rewrite: optimistic stores on core lanes (increment, 64/68 main…
Store rewrite: single-home storage model replaces the legacy store (#…
fix(signals): truth-author drafts read the authoritative view (#3108)
fix(signals): an optimistic store's first flight declares no transact…
fix(signals): draft reads compose overrides over a truth-staged backi…
signals: the hold model (L2) — core rebuilt from its rulings; −23% fl…
fix(signals): only a reader in the frame's hold keeps observing a fli…
fix(signals): a guess over a question in flight keeps the frame whole…