@solidjs/signals 2.0.0-beta.20 → 2.0.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,7 +112,7 @@ isPending(data); // true while an unrevealed value change is in flight for this
112
112
  latest(data); // last resolved value; follows the not-ready path if no value has resolved yet
113
113
  ```
114
114
 
115
- Use `action()` to coordinate async workflows with the reactive graph:
115
+ Use `action()` for mutations — imperative async workflows whose writes span an async gap. Each invocation runs as a single transaction: every write between yields batches into one atomic update, and nothing commits until the action completes or the next `yield` resolves.
116
116
 
117
117
  ```typescript
118
118
  const save = action(function* (item) {
@@ -120,6 +120,10 @@ const save = action(function* (item) {
120
120
  });
121
121
  ```
122
122
 
123
+ Navigation-shaped updates don't need an action. A plain setter call is enough — reads pull the async, downstream async computeds hold their previous values until new ones are ready, and `isPending`/`latest` expose the in-flight state. Reach for `action` when writes happen *after* async work (pairing with optimistic primitives for tentative state that reverts on failure), not when the async is merely downstream of a synchronous write.
124
+
125
+ Framework-level actions (router form actions, server actions) are specializations of this primitive: the same transactional semantics with form binding, serialization, and submission tracking layered on top.
126
+
123
127
  ## Optimistic Updates
124
128
 
125
129
  Optimistic signals show an immediate value while async work is pending, then automatically revert when it settles:
package/dist/dev.js CHANGED
@@ -731,8 +731,12 @@ class GlobalQueue extends Queue {
731
731
  const stashedTransition = activeTransition;
732
732
  runHeap(zombieQueue, GlobalQueue._update);
733
733
  // Detach: the stashed transition keeps its batch; ambient work that
734
- // follows lands in a fresh one.
735
- currentBatch = this._batch = createBatch();
734
+ // follows lands in a fresh one. If the batch is already a separate
735
+ // ambient one action done() restored activeTransition without
736
+ // adopting the batch, and an ordinary write landed there before
737
+ // the scheduled flush (#2916) — keep it: replacing it would strand
738
+ // its queued pending nodes with held _pendingValues forever.
739
+ if (this._batch === stashedTransition) currentBatch = this._batch = createBatch();
736
740
  // Run lane effects immediately (before stashing) - lanes with no pending async
737
741
  if (activeLanes.size) {
738
742
  GlobalQueue._runLaneEffects(EFFECT_RENDER);
@@ -740,7 +744,10 @@ class GlobalQueue extends Queue {
740
744
  }
741
745
  this.stashQueues(stashedTransition._queueStash);
742
746
  clock++;
743
- scheduled = dirtyQueue._max >= dirtyQueue._min;
747
+ // A kept ambient batch may hold pending nodes (#2916): stay
748
+ // scheduled so the outer drain loop commits them via the plain
749
+ // flush path instead of leaving them until the next natural flush.
750
+ scheduled = dirtyQueue._max >= dirtyQueue._min || this._batch._pendingNodes.length > 0;
744
751
  reassignPendingTransition(stashedTransition._pendingNodes);
745
752
  activeTransition = null;
746
753
  // The stash pass (committed-view rerun of plain optimistic signals)
@@ -1156,7 +1163,15 @@ function insertIntoHeap(n, heap) {
1156
1163
  if (flags & (REACTIVE_IN_HEAP | REACTIVE_RECOMPUTING_DEPS | REACTIVE_MANUAL_WRITE)) return;
1157
1164
  if (flags & REACTIVE_CHECK) {
1158
1165
  n._flags = (flags & -4) | REACTIVE_DIRTY | REACTIVE_IN_HEAP;
1159
- } else n._flags = flags | REACTIVE_IN_HEAP;
1166
+ } else {
1167
+ n._flags = flags | REACTIVE_IN_HEAP;
1168
+ // An unmarked node entering a marked heap invalidates the markHeap memo:
1169
+ // `_marked` is only reset by runHeap, so a write between two mid-tick
1170
+ // pulls (read-time markHeap + updateIfNecessary) would otherwise leave
1171
+ // this node unmarked and every downstream pull stale until the next
1172
+ // flush (#2922: the second `latest()` returned the first write's value).
1173
+ if (heap._marked && !(flags & REACTIVE_DIRTY)) heap._marked = false;
1174
+ }
1160
1175
  if (!(flags & REACTIVE_IN_HEAP_HEIGHT)) actualInsertIntoHeap(n, heap);
1161
1176
  }
1162
1177
  function insertIntoHeapHeight(n, heap) {
@@ -3557,6 +3572,19 @@ function latestRead(el) {
3557
3572
  : el._value;
3558
3573
  let value;
3559
3574
  try {
3575
+ // An untracked latest() read has no reading context, so read() never
3576
+ // performs its mid-tick pull — a plain write queued between two latest()
3577
+ // calls left a still-subscribed shadow at its previous speculative value
3578
+ // until the flush (#2922). Mirror the tracked-read pull here: mark the
3579
+ // queued staleness through the graph, then bring the shadow up to date.
3580
+ const queue = queueFor(pendingComputed);
3581
+ if (
3582
+ pendingComputed._height >= queue._min &&
3583
+ !(pendingComputed._flags & (REACTIVE_DISPOSED | REACTIVE_ZOMBIE))
3584
+ ) {
3585
+ markHeap(queue);
3586
+ prepareComputed(pendingComputed, true);
3587
+ }
3560
3588
  value = read(pendingComputed);
3561
3589
  } catch (e) {
3562
3590
  if (e instanceof NotReadyError && (!context || !(el._statusFlags & STATUS_UNINITIALIZED)))
@@ -3573,6 +3601,16 @@ function latestRead(el) {
3573
3601
  return visibleValue;
3574
3602
  }
3575
3603
  }
3604
+ // A shadow recomputed by the pull above (not at creation) holds its fresh
3605
+ // speculative value in _pendingValue; a contextless read() only surfaces
3606
+ // _value. Overrides stay authoritative (A17), and stale readers keep the
3607
+ // other transition's committed view, matching read()'s own selection.
3608
+ if (
3609
+ pendingComputed._pendingValue !== NOT_PENDING &&
3610
+ !hasActiveOverride(pendingComputed) &&
3611
+ !(stale && pendingComputed._transition && activeTransition !== pendingComputed._transition)
3612
+ )
3613
+ return pendingComputed._pendingValue;
3576
3614
  return value;
3577
3615
  }
3578
3616
  /** The isPending()-probe read path, installed as GlobalQueue._pendingCheck. */
@@ -3882,6 +3920,22 @@ function restoreTransition(transition, fn) {
3882
3920
  return result;
3883
3921
  }
3884
3922
  /**
3923
+ * The primitive for mutations: imperative async workflows whose *writes span
3924
+ * an async gap* — optimistic write, server round-trip, reconciling write —
3925
+ * where intermediate state must not leak and failure must revert cleanly
3926
+ * (pair with `createOptimistic` / `createOptimisticStore`).
3927
+ *
3928
+ * Navigation-shaped updates do not need an action. A plain setter call is
3929
+ * enough: reads pull the async, and downstream async computeds hold their
3930
+ * previous values per-node until the new ones are ready (`isPending` /
3931
+ * `latest` expose the in-flight state). Reach for `action` only when writes
3932
+ * happen *after* async work, not merely upstream of it.
3933
+ *
3934
+ * Framework-level actions (router form actions, server actions) are
3935
+ * specializations of this primitive: they are actions in exactly this sense —
3936
+ * the same transactional semantics — with form binding, serialization, and
3937
+ * submission tracking layered on top. The shared name is deliberate.
3938
+ *
3885
3939
  * Wraps a generator function so each invocation runs as a single transaction
3886
3940
  * (a "transition") that batches every signal/store write between yields. The
3887
3941
  * surrounding UI sees one atomic update per yielded step; nothing is committed
@@ -3980,11 +4034,33 @@ function action(genFn) {
3980
4034
  };
3981
4035
  const run = r => {
3982
4036
  if (r.done) return done(r.value);
3983
- if (isThenable(r.value))
3984
- return void r.value.then(
3985
- v => restoreTransition(ctx, () => step(v)),
3986
- e => restoreTransition(ctx, () => step(e, true))
3987
- );
4037
+ // Thenable assimilation can itself throw synchronously (a `then`
4038
+ // getter, or a `then()` method that throws — #2918). Match `await`
4039
+ // semantics: the failure is thrown back into the generator at the
4040
+ // yield point (catchable there); if uncaught, step()'s guard settles
4041
+ // the action so its iterator never leaks in the transition. The
4042
+ // settled flag implements A+ 2.3.3.3.4.1: a throw after the thenable
4043
+ // already called a callback is ignored.
4044
+ let settled = false;
4045
+ try {
4046
+ if (isThenable(r.value))
4047
+ return void r.value.then(
4048
+ v => {
4049
+ if (settled) return;
4050
+ settled = true;
4051
+ restoreTransition(ctx, () => step(v));
4052
+ },
4053
+ e => {
4054
+ if (settled) return;
4055
+ settled = true;
4056
+ restoreTransition(ctx, () => step(e, true));
4057
+ }
4058
+ );
4059
+ } catch (e) {
4060
+ if (settled) return;
4061
+ settled = true;
4062
+ return void restoreTransition(ctx, () => step(e, true));
4063
+ }
3988
4064
  restoreTransition(ctx, () => step(r.value));
3989
4065
  };
3990
4066
  step();
package/dist/node.cjs CHANGED
@@ -634,8 +634,12 @@ class GlobalQueue extends Queue {
634
634
  const e = activeTransition;
635
635
  runHeap(zombieQueue, GlobalQueue.X);
636
636
  // Detach: the stashed transition keeps its batch; ambient work that
637
- // follows lands in a fresh one.
638
- currentBatch = this.D = createBatch();
637
+ // follows lands in a fresh one. If the batch is already a separate
638
+ // ambient one action done() restored activeTransition without
639
+ // adopting the batch, and an ordinary write landed there before
640
+ // the scheduled flush (#2916) — keep it: replacing it would strand
641
+ // its queued pending nodes with held _pendingValues forever.
642
+ if (this.D === e) currentBatch = this.D = createBatch();
639
643
  // Run lane effects immediately (before stashing) - lanes with no pending async
640
644
  if (activeLanes.size) {
641
645
  GlobalQueue.Ce(EFFECT_RENDER);
@@ -643,7 +647,10 @@ class GlobalQueue extends Queue {
643
647
  }
644
648
  this.stashQueues(e.K);
645
649
  clock++;
646
- scheduled = dirtyQueue.C >= dirtyQueue.P;
650
+ // A kept ambient batch may hold pending nodes (#2916): stay
651
+ // scheduled so the outer drain loop commits them via the plain
652
+ // flush path instead of leaving them until the next natural flush.
653
+ scheduled = dirtyQueue.C >= dirtyQueue.P || this.D.H.length > 0;
647
654
  reassignPendingTransition(e.H);
648
655
  activeTransition = null;
649
656
  // The stash pass (committed-view rerun of plain optimistic signals)
@@ -1038,7 +1045,15 @@ function insertIntoHeap(e, t) {
1038
1045
  if (n & (REACTIVE_IN_HEAP | REACTIVE_RECOMPUTING_DEPS | REACTIVE_MANUAL_WRITE)) return;
1039
1046
  if (n & REACTIVE_CHECK) {
1040
1047
  e.Qe = n & -4 | REACTIVE_DIRTY | REACTIVE_IN_HEAP;
1041
- } else e.Qe = n | REACTIVE_IN_HEAP;
1048
+ } else {
1049
+ e.Qe = n | REACTIVE_IN_HEAP;
1050
+ // An unmarked node entering a marked heap invalidates the markHeap memo:
1051
+ // `_marked` is only reset by runHeap, so a write between two mid-tick
1052
+ // pulls (read-time markHeap + updateIfNecessary) would otherwise leave
1053
+ // this node unmarked and every downstream pull stale until the next
1054
+ // flush (#2922: the second `latest()` returned the first write's value).
1055
+ if (t.N && !(n & REACTIVE_DIRTY)) t.N = false;
1056
+ }
1042
1057
  if (!(n & REACTIVE_IN_HEAP_HEIGHT)) actualInsertIntoHeap(e, t);
1043
1058
  }
1044
1059
 
@@ -3174,6 +3189,16 @@ function getLatestValueComputed(e) {
3174
3189
  const r = e.A !== undefined && e.A !== NOT_PENDING ? unwrapOverride(e.A) : e.Me;
3175
3190
  let i;
3176
3191
  try {
3192
+ // An untracked latest() read has no reading context, so read() never
3193
+ // performs its mid-tick pull — a plain write queued between two latest()
3194
+ // calls left a still-subscribed shadow at its previous speculative value
3195
+ // until the flush (#2922). Mirror the tracked-read pull here: mark the
3196
+ // queued staleness through the graph, then bring the shadow up to date.
3197
+ const e = queueFor(t);
3198
+ if (t.nt >= e.P && !(t.Qe & (REACTIVE_DISPOSED | REACTIVE_ZOMBIE))) {
3199
+ markHeap(e);
3200
+ prepareComputed(t, true);
3201
+ }
3177
3202
  i = read(t);
3178
3203
  } catch (t) {
3179
3204
  if (t instanceof NotReadyError && (!context || !(e.$e & STATUS_UNINITIALIZED))) return r;
@@ -3189,6 +3214,11 @@ function getLatestValueComputed(e) {
3189
3214
  return r;
3190
3215
  }
3191
3216
  }
3217
+ // A shadow recomputed by the pull above (not at creation) holds its fresh
3218
+ // speculative value in _pendingValue; a contextless read() only surfaces
3219
+ // _value. Overrides stay authoritative (A17), and stale readers keep the
3220
+ // other transition's committed view, matching read()'s own selection.
3221
+ if (t.V !== NOT_PENDING && !hasActiveOverride(t) && !(stale && t.T && activeTransition !== t.T)) return t.V;
3192
3222
  return i;
3193
3223
  }
3194
3224
 
@@ -3432,6 +3462,22 @@ function restoreTransition(e, t) {
3432
3462
  }
3433
3463
 
3434
3464
  /**
3465
+ * The primitive for mutations: imperative async workflows whose *writes span
3466
+ * an async gap* — optimistic write, server round-trip, reconciling write —
3467
+ * where intermediate state must not leak and failure must revert cleanly
3468
+ * (pair with `createOptimistic` / `createOptimisticStore`).
3469
+ *
3470
+ * Navigation-shaped updates do not need an action. A plain setter call is
3471
+ * enough: reads pull the async, and downstream async computeds hold their
3472
+ * previous values per-node until the new ones are ready (`isPending` /
3473
+ * `latest` expose the in-flight state). Reach for `action` only when writes
3474
+ * happen *after* async work, not merely upstream of it.
3475
+ *
3476
+ * Framework-level actions (router form actions, server actions) are
3477
+ * specializations of this primitive: they are actions in exactly this sense —
3478
+ * the same transactional semantics — with form binding, serialization, and
3479
+ * submission tracking layered on top. The shared name is deliberate.
3480
+ *
3435
3481
  * Wraps a generator function so each invocation runs as a single transaction
3436
3482
  * (a "transition") that batches every signal/store write between yields. The
3437
3483
  * surrounding UI sees one atomic update per yielded step; nothing is committed
@@ -3506,7 +3552,29 @@ function restoreTransition(e, t) {
3506
3552
  };
3507
3553
  const run = e => {
3508
3554
  if (e.done) return done(e.value);
3509
- if (isThenable(e.value)) return void e.value.then(e => restoreTransition(o, () => step(e)), e => restoreTransition(o, () => step(e, true)));
3555
+ // Thenable assimilation can itself throw synchronously (a `then`
3556
+ // getter, or a `then()` method that throws — #2918). Match `await`
3557
+ // semantics: the failure is thrown back into the generator at the
3558
+ // yield point (catchable there); if uncaught, step()'s guard settles
3559
+ // the action so its iterator never leaks in the transition. The
3560
+ // settled flag implements A+ 2.3.3.3.4.1: a throw after the thenable
3561
+ // already called a callback is ignored.
3562
+ let t = false;
3563
+ try {
3564
+ if (isThenable(e.value)) return void e.value.then(e => {
3565
+ if (t) return;
3566
+ t = true;
3567
+ restoreTransition(o, () => step(e));
3568
+ }, e => {
3569
+ if (t) return;
3570
+ t = true;
3571
+ restoreTransition(o, () => step(e, true));
3572
+ });
3573
+ } catch (e) {
3574
+ if (t) return;
3575
+ t = true;
3576
+ return void restoreTransition(o, () => step(e, true));
3577
+ }
3510
3578
  restoreTransition(o, () => step(e.value));
3511
3579
  };
3512
3580
  step();
@@ -4,14 +4,30 @@ import { isThenable } from "./async.js";
4
4
 
5
5
  import "./core.js";
6
6
 
7
- function restoreTransition(e, n) {
7
+ function restoreTransition(e, r) {
8
8
  globalQueue.initTransition(e);
9
- const t = n();
9
+ const t = r();
10
10
  flush();
11
11
  return t;
12
12
  }
13
13
 
14
14
  /**
15
+ * The primitive for mutations: imperative async workflows whose *writes span
16
+ * an async gap* — optimistic write, server round-trip, reconciling write —
17
+ * where intermediate state must not leak and failure must revert cleanly
18
+ * (pair with `createOptimistic` / `createOptimisticStore`).
19
+ *
20
+ * Navigation-shaped updates do not need an action. A plain setter call is
21
+ * enough: reads pull the async, and downstream async computeds hold their
22
+ * previous values per-node until the new ones are ready (`isPending` /
23
+ * `latest` expose the in-flight state). Reach for `action` only when writes
24
+ * happen *after* async work, not merely upstream of it.
25
+ *
26
+ * Framework-level actions (router form actions, server actions) are
27
+ * specializations of this primitive: they are actions in exactly this sense —
28
+ * the same transactional semantics — with form binding, serialization, and
29
+ * submission tracking layered on top. The shared name is deliberate.
30
+ *
15
31
  * Wraps a generator function so each invocation runs as a single transaction
16
32
  * (a "transition") that batches every signal/store write between yields. The
17
33
  * surrounding UI sees one atomic update per yielded step; nothing is committed
@@ -58,23 +74,23 @@ function restoreTransition(e, n) {
58
74
  * await addTodo("buy milk");
59
75
  * ```
60
76
  */ function action(e) {
61
- return (...n) => new Promise((t, r) => {
62
- const i = e(...n);
77
+ return (...r) => new Promise((t, n) => {
78
+ const i = e(...r);
63
79
  globalQueue.initTransition();
64
80
  let o = activeTransition;
65
81
  o.Te.push(i);
66
- const done = (e, n, s = false) => {
82
+ const done = (e, r, s = false) => {
67
83
  o = currentTransition(o);
68
84
  const u = o.Te.indexOf(i);
69
85
  if (u >= 0) o.Te.splice(u, 1);
70
86
  setActiveTransition(o);
71
87
  schedule();
72
- s ? r(n) : t(e);
88
+ s ? n(r) : t(e);
73
89
  };
74
- const step = (e, n) => {
90
+ const step = (e, r) => {
75
91
  let t;
76
92
  try {
77
- t = n ? i.throw(e) : i.next(e);
93
+ t = r ? i.throw(e) : i.next(e);
78
94
  } catch (e) {
79
95
  return done(undefined, e, true);
80
96
  }
@@ -86,7 +102,29 @@ function restoreTransition(e, n) {
86
102
  };
87
103
  const run = e => {
88
104
  if (e.done) return done(e.value);
89
- if (isThenable(e.value)) return void e.value.then(e => restoreTransition(o, () => step(e)), e => restoreTransition(o, () => step(e, true)));
105
+ // Thenable assimilation can itself throw synchronously (a `then`
106
+ // getter, or a `then()` method that throws — #2918). Match `await`
107
+ // semantics: the failure is thrown back into the generator at the
108
+ // yield point (catchable there); if uncaught, step()'s guard settles
109
+ // the action so its iterator never leaks in the transition. The
110
+ // settled flag implements A+ 2.3.3.3.4.1: a throw after the thenable
111
+ // already called a callback is ignored.
112
+ let r = false;
113
+ try {
114
+ if (isThenable(e.value)) return void e.value.then(e => {
115
+ if (r) return;
116
+ r = true;
117
+ restoreTransition(o, () => step(e));
118
+ }, e => {
119
+ if (r) return;
120
+ r = true;
121
+ restoreTransition(o, () => step(e, true));
122
+ });
123
+ } catch (e) {
124
+ if (r) return;
125
+ r = true;
126
+ return void restoreTransition(o, () => step(e, true));
127
+ }
90
128
  restoreTransition(o, () => step(e.value));
91
129
  };
92
130
  step();
@@ -1,6 +1,6 @@
1
1
  import { handleAsync, clearStatus, notifyStatus } from "./async.js";
2
2
 
3
- import { EFFECT_TRACKED, REACTIVE_OPTIMISTIC_DIRTY, NOT_PENDING, STATUS_UNINITIALIZED, REACTIVE_REASK, REACTIVE_RECOMPUTING_DEPS, CONFIG_SYNC, STATUS_PENDING, STATUS_ERROR, REACTIVE_NONE, REACTIVE_SNAPSHOT_STALE, unwrapOverride, OVERRIDE_UNDEFINED, CONFIG_AUTO_DISPOSE, REACTIVE_LAZY, REACTIVE_DISPOSED, REACTIVE_CHECK, REACTIVE_DIRTY, REACTIVE_IN_HEAP, REACTIVE_IN_HEAP_HEIGHT, defaultContext, CONFIG_IN_SNAPSHOT_SCOPE, CONFIG_TRANSPARENT, CONFIG_OWNED_WRITE, CONFIG_NO_SNAPSHOT, $REFRESH, REACTIVE_MANUAL_WRITE, NO_SNAPSHOT, STORE_SNAPSHOT_PROPS, EFFECT_USER } from "./constants.js";
3
+ import { EFFECT_TRACKED, REACTIVE_OPTIMISTIC_DIRTY, NOT_PENDING, STATUS_UNINITIALIZED, REACTIVE_REASK, REACTIVE_RECOMPUTING_DEPS, CONFIG_SYNC, STATUS_PENDING, STATUS_ERROR, REACTIVE_NONE, REACTIVE_SNAPSHOT_STALE, unwrapOverride, OVERRIDE_UNDEFINED, REACTIVE_LAZY, REACTIVE_DISPOSED, CONFIG_AUTO_DISPOSE, REACTIVE_CHECK, REACTIVE_DIRTY, REACTIVE_IN_HEAP, REACTIVE_IN_HEAP_HEIGHT, defaultContext, CONFIG_IN_SNAPSHOT_SCOPE, CONFIG_TRANSPARENT, CONFIG_OWNED_WRITE, CONFIG_NO_SNAPSHOT, $REFRESH, REACTIVE_MANUAL_WRITE, NO_SNAPSHOT, STORE_SNAPSHOT_PROPS, EFFECT_USER } from "./constants.js";
4
4
 
5
5
  import { NotReadyError } from "./error.js";
6
6
 
@@ -43,7 +43,15 @@ function insertIntoHeap(e, E) {
43
43
  if (t & (REACTIVE_IN_HEAP | REACTIVE_RECOMPUTING_DEPS | REACTIVE_MANUAL_WRITE)) return;
44
44
  if (t & REACTIVE_CHECK) {
45
45
  e.u = t & -4 | REACTIVE_DIRTY | REACTIVE_IN_HEAP;
46
- } else e.u = t | REACTIVE_IN_HEAP;
46
+ } else {
47
+ e.u = t | REACTIVE_IN_HEAP;
48
+ // An unmarked node entering a marked heap invalidates the markHeap memo:
49
+ // `_marked` is only reset by runHeap, so a write between two mid-tick
50
+ // pulls (read-time markHeap + updateIfNecessary) would otherwise leave
51
+ // this node unmarked and every downstream pull stale until the next
52
+ // flush (#2922: the second `latest()` returned the first write's value).
53
+ if (E.tE && !(t & REACTIVE_DIRTY)) E.tE = false;
54
+ }
47
55
  if (!(t & REACTIVE_IN_HEAP_HEIGHT)) actualInsertIntoHeap(e, E);
48
56
  }
49
57
 
@@ -323,8 +323,12 @@ class GlobalQueue extends Queue {
323
323
  const e = activeTransition;
324
324
  runHeap(zombieQueue, GlobalQueue.Ce);
325
325
  // Detach: the stashed transition keeps its batch; ambient work that
326
- // follows lands in a fresh one.
327
- currentBatch = this.N = createBatch();
326
+ // follows lands in a fresh one. If the batch is already a separate
327
+ // ambient one action done() restored activeTransition without
328
+ // adopting the batch, and an ordinary write landed there before
329
+ // the scheduled flush (#2916) — keep it: replacing it would strand
330
+ // its queued pending nodes with held _pendingValues forever.
331
+ if (this.N === e) currentBatch = this.N = createBatch();
328
332
  // Run lane effects immediately (before stashing) - lanes with no pending async
329
333
  if (activeLanes.size) {
330
334
  GlobalQueue.On(EFFECT_RENDER);
@@ -332,7 +336,10 @@ class GlobalQueue extends Queue {
332
336
  }
333
337
  this.stashQueues(e.Bt);
334
338
  clock++;
335
- scheduled = dirtyQueue.EE >= dirtyQueue.Le;
339
+ // A kept ambient batch may hold pending nodes (#2916): stay
340
+ // scheduled so the outer drain loop commits them via the plain
341
+ // flush path instead of leaving them until the next natural flush.
342
+ scheduled = dirtyQueue.EE >= dirtyQueue.Le || this.N.yt.length > 0;
336
343
  reassignPendingTransition(e.yt);
337
344
  activeTransition = null;
338
345
  // The stash pass (committed-view rerun of plain optimistic signals)
@@ -1,12 +1,12 @@
1
- import { NOT_PENDING, REACTIVE_DISPOSED, REACTIVE_DIRTY, REACTIVE_CHECK, unwrapOverride, STATUS_UNINITIALIZED, STATUS_PENDING, REACTIVE_MANUAL_WRITE } from "./constants.js";
1
+ import { NOT_PENDING, REACTIVE_DISPOSED, REACTIVE_DIRTY, REACTIVE_CHECK, unwrapOverride, REACTIVE_ZOMBIE, STATUS_UNINITIALIZED, STATUS_PENDING, REACTIVE_MANUAL_WRITE } from "./constants.js";
2
2
 
3
- import { setSignal, read, context, stale, currentOptimisticLane, prepareComputed, tracking, setLatestReadActive, setContextInternal, optimisticComputed, setPendingCheckActive, latestReadActive, optimisticSignal, pendingCheckActive } from "./core.js";
3
+ import { setSignal, prepareComputed, read, context, stale, currentOptimisticLane, tracking, setLatestReadActive, setContextInternal, optimisticComputed, setPendingCheckActive, latestReadActive, optimisticSignal, pendingCheckActive } from "./core.js";
4
4
 
5
5
  import { NotReadyError } from "./error.js";
6
6
 
7
7
  import { link } from "./graph.js";
8
8
 
9
- import { insertIntoHeap, queueFor } from "./heap.js";
9
+ import { insertIntoHeap, queueFor, markHeap } from "./heap.js";
10
10
 
11
11
  import "./invariants.js";
12
12
 
@@ -14,7 +14,7 @@ import { findLane, hasActiveOverride } from "./lanes.js";
14
14
 
15
15
  import { installOptimisticEngine } from "./optimistic.js";
16
16
 
17
- import { GlobalQueue, clock, insertSubs, schedule } from "./scheduler.js";
17
+ import { GlobalQueue, clock, insertSubs, schedule, activeTransition } from "./scheduler.js";
18
18
 
19
19
  /**
20
20
  * The isPending()/latest() verdict layer, moved out of core.ts. Importing this
@@ -175,6 +175,16 @@ function getLatestValueComputed(e) {
175
175
  const i = e.be !== undefined && e.be !== NOT_PENDING ? unwrapOverride(e.be) : e.Ue;
176
176
  let r;
177
177
  try {
178
+ // An untracked latest() read has no reading context, so read() never
179
+ // performs its mid-tick pull — a plain write queued between two latest()
180
+ // calls left a still-subscribed shadow at its previous speculative value
181
+ // until the flush (#2922). Mirror the tracked-read pull here: mark the
182
+ // queued staleness through the graph, then bring the shadow up to date.
183
+ const e = queueFor(t);
184
+ if (t.qe >= e.Le && !(t.u & (REACTIVE_DISPOSED | REACTIVE_ZOMBIE))) {
185
+ markHeap(e);
186
+ prepareComputed(t, true);
187
+ }
178
188
  r = read(t);
179
189
  } catch (t) {
180
190
  if (t instanceof NotReadyError && (!context || !(e.i & STATUS_UNINITIALIZED))) return i;
@@ -190,6 +200,11 @@ function getLatestValueComputed(e) {
190
200
  return i;
191
201
  }
192
202
  }
203
+ // A shadow recomputed by the pull above (not at creation) holds its fresh
204
+ // speculative value in _pendingValue; a contextless read() only surfaces
205
+ // _value. Overrides stay authoritative (A17), and stale readers keep the
206
+ // other transition's committed view, matching read()'s own selection.
207
+ if (t.ge !== NOT_PENDING && !hasActiveOverride(t) && !(stale && t.ve && activeTransition !== t.ve)) return t.ge;
193
208
  return r;
194
209
  }
195
210
 
@@ -1,4 +1,20 @@
1
1
  /**
2
+ * The primitive for mutations: imperative async workflows whose *writes span
3
+ * an async gap* — optimistic write, server round-trip, reconciling write —
4
+ * where intermediate state must not leak and failure must revert cleanly
5
+ * (pair with `createOptimistic` / `createOptimisticStore`).
6
+ *
7
+ * Navigation-shaped updates do not need an action. A plain setter call is
8
+ * enough: reads pull the async, and downstream async computeds hold their
9
+ * previous values per-node until the new ones are ready (`isPending` /
10
+ * `latest` expose the in-flight state). Reach for `action` only when writes
11
+ * happen *after* async work, not merely upstream of it.
12
+ *
13
+ * Framework-level actions (router form actions, server actions) are
14
+ * specializations of this primitive: they are actions in exactly this sense —
15
+ * the same transactional semantics — with form binding, serialization, and
16
+ * submission tracking layered on top. The shared name is deliberate.
17
+ *
2
18
  * Wraps a generator function so each invocation runs as a single transaction
3
19
  * (a "transition") that batches every signal/store write between yields. The
4
20
  * surrounding UI sees one atomic update per yielded step; nothing is committed
@@ -1,4 +1,20 @@
1
1
  /**
2
+ * The primitive for mutations: imperative async workflows whose *writes span
3
+ * an async gap* — optimistic write, server round-trip, reconciling write —
4
+ * where intermediate state must not leak and failure must revert cleanly
5
+ * (pair with `createOptimistic` / `createOptimisticStore`).
6
+ *
7
+ * Navigation-shaped updates do not need an action. A plain setter call is
8
+ * enough: reads pull the async, and downstream async computeds hold their
9
+ * previous values per-node until the new ones are ready (`isPending` /
10
+ * `latest` expose the in-flight state). Reach for `action` only when writes
11
+ * happen *after* async work, not merely upstream of it.
12
+ *
13
+ * Framework-level actions (router form actions, server actions) are
14
+ * specializations of this primitive: they are actions in exactly this sense —
15
+ * the same transactional semantics — with form binding, serialization, and
16
+ * submission tracking layered on top. The shared name is deliberate.
17
+ *
2
18
  * Wraps a generator function so each invocation runs as a single transaction
3
19
  * (a "transition") that batches every signal/store write between yields. The
4
20
  * surrounding UI sees one atomic update per yielded step; nothing is committed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidjs/signals",
3
- "version": "2.0.0-beta.20",
3
+ "version": "2.0.0-beta.21",
4
4
  "description": "Solid's reactive primitives: signals, memos, effects, stores, and async-aware computations.",
5
5
  "author": "Ryan Carniato",
6
6
  "license": "MIT",