Version: 2.0.0-beta.19, branch next (reproduced @ c7bb2c8, post-#2899; the mechanism is untouched by #2902–#2904)
Summary
When one effect subscribes to keys/signals touched by two different actions, insertSubs → assignOrMergeLane merges the earlier action's lane into the later action's lane without merging their transactions. From then on resolveTransition reports the later action's transaction for the earlier action's nodes. Two consequences:
Notably, the shipped #2899 test 3 passes only because of write order: mirror it with action B's two writes swapped (disjoint key first, same key second) and it fails.
Root cause
packages/solid-signals/src/core/lanes.ts:159 — mergeLanes(sourceRoot, existingRoot) inside assignOrMergeLane, reached from insertSubs (core/scheduler.ts:621), merges lanes from different transactions. resolveTransition (lanes.ts:118-123) then prefers the hijacked lane's _transition. Consumers: core/optimistic.ts:85, the store owner stamp (store/store.ts:621-624), and the scoped clear (store/optimistic.ts:155-193).
Repros
The shipped #2899 test with B's writes swapped
const [s, setS] = createOptimisticStore({ a: 1, b: 2 });
createRoot(() => createRenderEffect(() => s.a + s.b, () => {})); // ONE effect reads both
flush();
const pA = action(function* () { setS(d => { d.a = 10; }); yield gA.promise; })();
flush();
const pB = action(function* () {
setS(d => { d.b = 20; }); // disjoint key FIRST — hijacks A's lane via the shared effect
setS(d => { d.a = 11; }); // same key as A — should merge the transactions
yield gB.promise;
})();
flush();
// [s.a, s.b] === [11, 20] ✓
gB.resolve(); await pB; flush();
[s.a, s.b]; // FAILS: [1, 2] — both keys reverted while A is still in flight.
// Expected [11, 20]: merged same-key semantics hold until the LAST action.
Plain createOptimistic signals, three actions
One effect reads x() + y(). Action A writes x, action B writes y (hijacks x's lane into B's transaction), action C rewrites x — C entangles with B instead of A. When A settles, x reverts to committed while C is still in flight (observed 1, expected 11). A two-store variant fails the same way.
Impact
HIGH — one component reading two independently-mutated values is the normal case of optimistic UI, and overlapping actions silently revert each other's live overrides. The fix likely needs a design decision at the lane/transaction ownership boundary: either merge transactions when lanes merge, or key resolveTransition off the node's own transaction rather than the lane's.
Version: 2.0.0-beta.19, branch
next(reproduced @ c7bb2c8, post-#2899; the mechanism is untouched by #2902–#2904)Summary
When one effect subscribes to keys/signals touched by two different actions,
insertSubs→assignOrMergeLanemerges the earlier action's lane into the later action's lane without merging their transactions. From then onresolveTransitionreports the later action's transaction for the earlier action's nodes. Two consequences:core/optimistic.ts:85initTransition(resolveTransition(el))), andNotably, the shipped #2899 test 3 passes only because of write order: mirror it with action B's two writes swapped (disjoint key first, same key second) and it fails.
Root cause
packages/solid-signals/src/core/lanes.ts:159—mergeLanes(sourceRoot, existingRoot)insideassignOrMergeLane, reached frominsertSubs(core/scheduler.ts:621), merges lanes from different transactions.resolveTransition(lanes.ts:118-123) then prefers the hijacked lane's_transition. Consumers:core/optimistic.ts:85, the store owner stamp (store/store.ts:621-624), and the scoped clear (store/optimistic.ts:155-193).Repros
The shipped #2899 test with B's writes swapped
Plain createOptimistic signals, three actions
One effect reads
x() + y(). Action A writesx, action B writesy(hijacks x's lane into B's transaction), action C rewritesx— C entangles with B instead of A. When A settles,xreverts to committed while C is still in flight (observed1, expected11). A two-store variant fails the same way.Impact
HIGH — one component reading two independently-mutated values is the normal case of optimistic UI, and overlapping actions silently revert each other's live overrides. The fix likely needs a design decision at the lane/transaction ownership boundary: either merge transactions when lanes merge, or key
resolveTransitionoff the node's own transaction rather than the lane's.