Describe the bug
Optimistic signal: writes that don't change the value don't merge transitions, so overrides revert while another action is still in flight
Summary
When two actions overlap and both write the same value to an optimistic signal (e.g. setBusy(true) from both), the second writer's transition is not merged into the first's. As soon as the first action settles, its transition completes and the optimistic override reverts — even though the second action is still in flight.
This breaks the natural pattern for boolean optimistic indicators (setBusy(true) at the start of an action) under any kind of overlap. It works correctly with a counter pattern (setCount(c => c + 1)) only because each write changes the value.
Versions
@solidjs/signals@2.0.0-beta.14
- Node 20+ (also reproduces in Vite/browser)
Reproduction
import { createOptimistic, action, flush, createRoot } from "@solidjs/signals";
const deferred = () => {
let resolve;
const promise = new Promise(r => (resolve = r));
return { promise, resolve };
};
const { isBusy, runA, runB, dA, dB } = createRoot(() => {
const [isBusy, setBusy] = createOptimistic(false);
const dA = deferred();
const dB = deferred();
const runA = action(function* () {
setBusy(true);
yield dA.promise;
});
const runB = action(function* () {
setBusy(true);
yield dB.promise;
});
return { isBusy, runA, runB, dA, dB };
});
const snap = label => console.log(`${label.padEnd(40)} isBusy=${isBusy()}`);
(async () => {
snap("initial");
const pA = runA();
flush();
snap("after A start");
const pB = runB();
flush();
snap("after B start");
// Resolve A while B is still pending.
dA.resolve();
await pA;
snap("after A resolved (B still pending)");
dB.resolve();
await pB;
snap("after B resolved");
})();
Expected
initial isBusy=false
after A start isBusy=true
after B start isBusy=true
after A resolved (B still pending) isBusy=true ← override held
after B resolved isBusy=false
Actual
initial isBusy=false
after A start isBusy=true
after B start isBusy=true
after A resolved (B still pending) isBusy=false ← reverts mid-flight
after B resolved isBusy=false
Replacing createOptimistic(false) with createOptimistic(0) and setBusy(true) with setCount(c => c + 1) produces the expected behavior. So does createOptimistic(false, { equals: false }).
Root cause
In setSignal, the value-equality check returns before the optimistic-merge logic. The merge into the existing transition only runs on the changed-value path:
const valueChanged =
!el._equals || !el._equals(currentValue, v) || !!(el._statusFlags & STATUS_UNINITIALIZED);
if (!valueChanged) {
if (isOptimistic && hasOverride && el._fn) {
insertSubs(el, true);
schedule();
}
return v; // ← early return
}
if (isOptimistic) {
const firstOverride = el._overrideValue === NOT_PENDING;
if (!firstOverride) globalQueue.initTransition(resolveTransition(el)); // ← merge, only on changed path
...
}
Result: when action B writes the same true to a signal that already has an override owned by action A's transition, B's transition stays separate. When A settles, A's transition completes and the override is cleared, even though B is still in flight.
Suggested fix
Move the transition-merge into the no-change path as well, while keeping the recompute optimization:
if (!valueChanged) {
if (isOptimistic && hasOverride) {
const existingTransition = resolveTransition(el);
if (existingTransition && activeTransition !== existingTransition) {
globalQueue.initTransition(existingTransition);
}
if (el._fn) {
insertSubs(el, true);
schedule();
}
}
return v;
}
Verified locally against the repro above: with this patch, isBusy stays true through the overlap and reverts to false only after the last action settles, matching the counter / equals: false behavior.
Workarounds
- Use a counter (
setCount(c => c + 1)) and read count() > 0.
- Pass
{ equals: false } to createOptimistic so writes always count as changes.
Notes
- I'd argue the current behavior is also surprising as a documentation matter: from a user's perspective,
setBusy(true) during an action says "I'm holding the override true for the duration of this action," and that should compose under overlap regardless of write equality.
- Filing as an issue (vs. PR) since
createOptimistic is still in beta and the team may prefer a different shape of fix.
Your Example Website or App
N/A
Steps to Reproduce the Bug or Issue
Details in description
Expected behavior
Details in description
Screenshots or Videos
No response
Platform
- OS: macOS
- Browser: Chrome
Additional context
No response
Describe the bug
Optimistic signal: writes that don't change the value don't merge transitions, so overrides revert while another action is still in flight
Summary
When two
actions overlap and both write the same value to an optimistic signal (e.g.setBusy(true)from both), the second writer's transition is not merged into the first's. As soon as the first action settles, its transition completes and the optimistic override reverts — even though the second action is still in flight.This breaks the natural pattern for boolean optimistic indicators (
setBusy(true)at the start of an action) under any kind of overlap. It works correctly with a counter pattern (setCount(c => c + 1)) only because each write changes the value.Versions
@solidjs/signals@2.0.0-beta.14Reproduction
Expected
Actual
Replacing
createOptimistic(false)withcreateOptimistic(0)andsetBusy(true)withsetCount(c => c + 1)produces the expected behavior. So doescreateOptimistic(false, { equals: false }).Root cause
In
setSignal, the value-equality check returns before the optimistic-merge logic. The merge into the existing transition only runs on the changed-value path:Result: when action B writes the same
trueto a signal that already has an override owned by action A's transition, B's transition stays separate. When A settles, A's transition completes and the override is cleared, even though B is still in flight.Suggested fix
Move the transition-merge into the no-change path as well, while keeping the recompute optimization:
Verified locally against the repro above: with this patch,
isBusystaystruethrough the overlap and reverts tofalseonly after the last action settles, matching the counter /equals: falsebehavior.Workarounds
setCount(c => c + 1)) and readcount() > 0.{ equals: false }tocreateOptimisticso writes always count as changes.Notes
setBusy(true)during an action says "I'm holding the overridetruefor the duration of this action," and that should compose under overlap regardless of write equality.createOptimisticis still in beta and the team may prefer a different shape of fix.Your Example Website or App
N/A
Steps to Reproduce the Bug or Issue
Details in description
Expected behavior
Details in description
Screenshots or Videos
No response
Platform
Additional context
No response