Skip to content

Solid v2.0 beta.14 - Optimistic signal: writes that don't change the value don't merge transitions #2711

@brenelz

Description

@brenelz

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions