Skip to content

2.0.0-beta.19: createOptimisticStore: first-settling action wipes other in-flight actions' overrides #2899

Description

@brenelz

Version: 2.0.0-beta.19 (branch next)

Summary

With two overlapping actions writing disjoint properties of the same createOptimisticStore, the first action to settle wipes the other action's still-live optimistic overrides. The store's override layer (STORE_OPTIMISTIC_OVERRIDE) is one flat layer per store, and settle clears it wholesale rather than per-transition.

The signal-side equivalent already guarantees the opposite: the existing spec test "holds same-value optimistic writes until all overlapping actions settle" passes for createOptimistic. The store side violates that guarantee.

Root cause

  • Store→transition ownership is tracked as a flat per-transition set: trackOptimisticStore (packages/solid-signals/src/core/optimistic.ts:310).
  • When any owning transition settles, clearOptimisticStoresclearOptimisticOverride deletes the entire layer: delete target[STORE_OPTIMISTIC_OVERRIDE] (packages/solid-signals/src/store/optimistic.ts:130), reverting every overridden node regardless of which transition wrote it.
  • The two actions never entangle into one transition, because entanglement requires a shared node write (via optimisticWrite's initTransition) or a $TRACK node — prepareStoreWrite (store/store.ts:556) only entangles via firewall._transition, which plain-form optimistic stores don't have. Disjoint-property writes therefore keep two independent transitions sharing one override layer.

Repro

const [s, setS] = createOptimisticStore({ a: 1, b: 2 });
createRoot(() => {
  createRenderEffect(() => s.a, () => {});
  createRenderEffect(() => s.b, () => {});
});
flush();

const actA = action(function* () {
  setS(d => { d.a = 10; });
  yield gateA.promise;
});
const actB = action(function* () {
  setS(d => { d.b = 20; });
  yield gateB.promise;
});

const pA = actA();
flush();
// s.a === 10 ✓
const pB = actB();
flush();
// s.b === 20 ✓, s.a === 10 ✓

gateB.resolve();  // settle B first
await pB;
flush();
s.a; // expected: 10 (action A still in flight) — actual: 1 (override wiped)

gateA.resolve();
await pA;

Existing optimistic/transition spec suites pass on the same checkout.

Impact

HIGH — any UI with two concurrent optimistic mutations touching the same store (e.g. two in-flight form submissions updating different rows) visibly reverts the still-pending mutation the moment the other one finishes, then has no way to recover until its own settle.

Activity

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