@esportsplus/reactivity 0.38.1 → 0.39.0

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.
@@ -158,10 +158,11 @@ class ReactiveArray<T> extends Array<T> {
158
158
  let dirty = false,
159
159
  errors: unknown[] | null = null;
160
160
 
161
+ // n is fixed: a listener unsubscribed mid-dispatch leaves a null (or, once trimmed, a missing) slot
161
162
  for (let i = 0, n = listeners.length; i < n; i++) {
162
163
  let listener = listeners[i];
163
164
 
164
- if (listener === null) {
165
+ if (listener == null) {
165
166
  continue;
166
167
  }
167
168
 
@@ -181,9 +182,7 @@ class ReactiveArray<T> extends Array<T> {
181
182
  }
182
183
 
183
184
  if (dirty) {
184
- while (listeners.length && listeners[listeners.length - 1] === null) {
185
- listeners.pop();
186
- }
185
+ this.prune(event, listeners);
187
186
  }
188
187
 
189
188
  if (errors !== null) {
@@ -205,7 +204,9 @@ class ReactiveArray<T> extends Array<T> {
205
204
  write(this._length, 0);
206
205
  }
207
206
 
208
- on<K extends keyof Events<T>>(event: K, listener: Listener<Events<T>[K]>) {
207
+ // Returns the unsubscribe. A listener already registered for the event is not added twice, and either
208
+ // registration's unsubscribe removes it.
209
+ on<K extends keyof Events<T>>(event: K, listener: Listener<Events<T>[K]>): VoidFunction {
209
210
  let entry = listener as AnyListener<T>,
210
211
  listeners = this.listeners[event];
211
212
 
@@ -213,30 +214,58 @@ class ReactiveArray<T> extends Array<T> {
213
214
  this.listeners[event] = [entry];
214
215
  }
215
216
  else {
216
- let hole = listeners.length;
217
+ let hole = listeners.length,
218
+ registered = false;
217
219
 
218
220
  for (let i = 0, n = hole; i < n; i++) {
219
221
  let l = listeners[i];
220
222
 
221
223
  if (l === entry) {
222
- return;
224
+ registered = true;
225
+ break;
223
226
  }
224
227
  else if (l === null && hole === n) {
225
228
  hole = i;
226
229
  }
227
230
  }
228
231
 
229
- listeners[hole] = entry;
230
-
231
- while (listeners.length && listeners[listeners.length - 1] === null) {
232
- listeners.pop();
232
+ if (!registered) {
233
+ listeners[hole] = entry;
234
+ this.prune(event, listeners);
233
235
  }
234
236
  }
237
+
238
+ return () => {
239
+ let current = this.listeners[event];
240
+
241
+ if (current === undefined) {
242
+ return;
243
+ }
244
+
245
+ let i = current.indexOf(entry);
246
+
247
+ if (i !== -1) {
248
+ current[i] = null;
249
+ this.prune(event, current);
250
+ }
251
+ };
235
252
  }
236
253
 
237
- once<K extends keyof Events<T>>(event: K, listener: Listener<Events<T>[K]>) {
254
+ once<K extends keyof Events<T>>(event: K, listener: Listener<Events<T>[K]>): VoidFunction {
238
255
  listener.once = true;
239
- this.on(event, listener);
256
+
257
+ return this.on(event, listener);
258
+ }
259
+
260
+ // Trims trailing empty slots; an event left with no listeners is deleted, so e.g. sort() skips computing its order
261
+ private prune<K extends keyof Events<T>>(event: K, listeners: (AnyListener<T> | null)[]) {
262
+ while (listeners.length && listeners[listeners.length - 1] === null) {
263
+ listeners.pop();
264
+ }
265
+
266
+ if (listeners.length === 0 && this.listeners[event] === listeners) {
267
+ delete this.listeners[event];
268
+ }
240
269
  }
241
270
 
242
271
  pop() {
@@ -1,6 +1,6 @@
1
- import { onCleanup, root } from '@esportsplus/reactivity';
2
1
  import { isArray, isObject } from '@esportsplus/utilities';
3
2
  import { PACKAGE_NAME } from '~/constants';
3
+ import { onCleanup, root } from '~/system';
4
4
  import type { Reactive } from '~/types';
5
5
  import { ReactiveArray } from './array';
6
6
  import { ReactiveObject } from './object';
package/src/system.ts CHANGED
@@ -16,7 +16,8 @@ type Walk = {
16
16
  prev: Walk | null;
17
17
  };
18
18
 
19
- let asyncMeta = new WeakMap<Computed<unknown>, { factory: Computed<unknown> }>(),
19
+ // Async wrapper -> the factory computed it settles, so invalidate() can redirect a refetch to it
20
+ let asyncFactory = new WeakMap<Computed<unknown>, Computed<unknown>>(),
20
21
  depth = 0,
21
22
  disposeHead: Walk | null = null,
22
23
  draining = false,
@@ -67,36 +68,46 @@ function walkPush(computed: Computed<unknown> | null, link: Link | null, prev: W
67
68
  }
68
69
 
69
70
 
71
+ // Owned children merge with the user cleanups by ownerSlot, so both run in registration order. Each
72
+ // child is taken from the live list head and disowned before its dispose, so a cleanup that disposes
73
+ // a later sibling just shortens the list.
70
74
  function cleanup<T>(computed: Computed<T>): void {
71
- if (!computed.cleanup) {
75
+ let child = computed.owned,
76
+ value = computed.cleanup;
77
+
78
+ computed.cleanup = null;
79
+
80
+ if (child === null && typeof value === 'function') {
81
+ value();
72
82
  return;
73
83
  }
74
84
 
75
- let errors: unknown[] = [],
76
- value = computed.cleanup;
85
+ let errors: unknown[] | null = null,
86
+ n = value === null ? 0 : typeof value === 'function' ? 1 : value.length;
77
87
 
78
- computed.cleanup = null;
88
+ for (let i = 0; i <= n; i++) {
89
+ while ((child = computed.owned) !== null && (i === n || child.ownerSlot <= i)) {
90
+ disown(child);
79
91
 
80
- if (typeof value === 'function') {
81
- try {
82
- value();
83
- }
84
- catch (e) {
85
- errors.push(e);
92
+ try {
93
+ dispose(child);
94
+ }
95
+ catch (e) {
96
+ (errors ??= []).push(e);
97
+ }
86
98
  }
87
- }
88
- else {
89
- for (let i = 0, n = value.length; i < n; i++) {
99
+
100
+ if (i < n) {
90
101
  try {
91
- value[i]();
102
+ (typeof value === 'function' ? value : value![i])();
92
103
  }
93
104
  catch (e) {
94
- errors.push(e);
105
+ (errors ??= []).push(e);
95
106
  }
96
107
  }
97
108
  }
98
109
 
99
- if (errors.length) {
110
+ if (errors !== null) {
100
111
  throw errors.length === 1 ? errors[0] : new AggregateError(errors, `${PACKAGE_NAME}: cleanup produced multiple errors`);
101
112
  }
102
113
  }
@@ -134,6 +145,27 @@ function deleteFromHeap<T>(computed: Computed<T>) {
134
145
  computed.prevHeap = computed;
135
146
  }
136
147
 
148
+ // O(1) removal from the owner's owned list; the head's prevOwned is the tail, as in the heap buckets
149
+ function disown(child: Computed<unknown>) {
150
+ let next = child.nextOwned,
151
+ owner = child.owner!,
152
+ prev = child.prevOwned!;
153
+
154
+ if (owner.owned === child) {
155
+ owner.owned = next;
156
+
157
+ if (next !== null) {
158
+ next.prevOwned = prev;
159
+ }
160
+ }
161
+ else {
162
+ prev.nextOwned = next;
163
+ (next ?? owner.owned!).prevOwned = prev;
164
+ }
165
+
166
+ child.nextOwned = child.owner = child.prevOwned = null;
167
+ }
168
+
137
169
  // N writes to one signal queue it once, so each subscriber is heap-inserted once. A self-linked
138
170
  // nextPending marks the tail.
139
171
  function drainPending() {
@@ -313,6 +345,29 @@ function notify<T>(computed: Computed<T>, newState: number) {
313
345
  }
314
346
  }
315
347
 
348
+ // Child owners (a root with a dispose param, a computed/effect created under an owner) link into the
349
+ // owner's list instead of registering a dispose closure, so an early dispose unlinks in O(1).
350
+ // ownerSlot snapshots how many user cleanups precede the child for cleanup()'s ordering merge.
351
+ function own(owner: Computed<unknown>, child: Computed<unknown>) {
352
+ let head = owner.owned,
353
+ value = owner.cleanup;
354
+
355
+ child.owner = owner;
356
+ child.ownerSlot = value === null ? 0 : typeof value === 'function' ? 1 : value.length;
357
+
358
+ if (head === null) {
359
+ owner.owned = child;
360
+ child.prevOwned = child;
361
+ }
362
+ else {
363
+ let tail = head.prevOwned!;
364
+
365
+ tail.nextOwned = child;
366
+ child.prevOwned = tail;
367
+ head.prevOwned = child;
368
+ }
369
+ }
370
+
316
371
  // Shared by read()'s tracked pull and peek()'s untracked pull. observer is nulled around update()
317
372
  // so a recompute triggered here tracks into the node's own scope, never the caller's. pulled/puller
318
373
  // let propagate() skip re-queueing the caller: it receives the fresh value when this returns.
@@ -371,7 +426,7 @@ function recompute<T>(computed: Computed<T>) {
371
426
  deleteFromHeap(computed);
372
427
  }
373
428
 
374
- if (computed.cleanup) {
429
+ if (computed.cleanup !== null || computed.owned !== null) {
375
430
  // A failing PREVIOUS generation's teardown must not poison this recompute or the stabilize pass
376
431
  try {
377
432
  cleanup(computed);
@@ -727,7 +782,7 @@ function makeAsyncComputed<T>(factory: Computed<Promise<T> | AsyncIterable<T> |
727
782
 
728
783
  wrapper.pending = pending;
729
784
 
730
- asyncMeta.set(wrapper as Computed<unknown>, { factory: factory as Computed<unknown> });
785
+ asyncFactory.set(wrapper as Computed<unknown>, factory as Computed<unknown>);
731
786
  wrapper.disposal = stop;
732
787
 
733
788
  return wrapper;
@@ -755,13 +810,13 @@ function makeComputed<T>(fn: Computed<T>['fn'], eager: boolean = false): Compute
755
810
  }
756
811
 
757
812
  link(self, observer);
758
- onCleanup(() => dispose(self));
813
+ own(observer, self as Computed<unknown>);
759
814
  }
760
815
  else {
761
816
  recompute(self);
762
817
 
763
818
  if (scope) {
764
- onCleanup(() => dispose(self));
819
+ own(scope, self as Computed<unknown>);
765
820
  }
766
821
  }
767
822
 
@@ -782,8 +837,13 @@ function makeNode<T>(fn: Computed<T>['fn']): Computed<T> {
782
837
  gv: 0,
783
838
  height: 0,
784
839
  nextHeap: undefined,
840
+ nextOwned: null,
841
+ owned: null,
842
+ owner: null,
843
+ ownerSlot: 0,
785
844
  pending: null,
786
845
  prevHeap: null as unknown as Computed<unknown>,
846
+ prevOwned: null,
787
847
  rv: 0,
788
848
  state: STATE_COMPUTED,
789
849
  subs: null,
@@ -824,14 +884,17 @@ const computed = <T>(fn: Computed<T>['fn'], equals: ((a: Settled<T>, b: Settled<
824
884
  value = self.value;
825
885
 
826
886
  if (isPromise(value) || (value != null && typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function')) {
827
- // Built untracked: makeAsyncComputed's polling effect + wrapper take makeComputed's
887
+ // Built in a detached root: makeAsyncComputed's polling effect + wrapper take makeComputed's
828
888
  // observer-null path (the proven top-level flow) instead of entangling with the enclosing
829
889
  // observer's recompute, which deadlocks the scheduler. The probe is already linked+owned by
830
- // the parent as the factory; the wrapper needs its own teardown tied to that same scope.
831
- self = untrack(() => makeAsyncComputed(self as Computed<Promise<unknown> | AsyncIterable<unknown>>));
890
+ // the parent as the factory; the wrapper (whose disposal stops the polling effect) is owned
891
+ // by that same owner.
892
+ let owner = o || scope;
832
893
 
833
- if (o) {
834
- onCleanup(() => dispose(self));
894
+ self = root(() => makeAsyncComputed(self as Computed<Promise<unknown> | AsyncIterable<unknown>>));
895
+
896
+ if (owner !== null) {
897
+ own(owner, self);
835
898
  }
836
899
  }
837
900
 
@@ -844,10 +907,10 @@ const computed = <T>(fn: Computed<T>['fn'], equals: ((a: Settled<T>, b: Settled<
844
907
  // node cannot skip the forced re-run via update()'s clean-graph fast path; an async computed's wrapper
845
908
  // redirects to its factory so the promise re-dispatches (a refetch).
846
909
  computed.invalidate = <T>(c: Computed<T>): void => {
847
- let meta = asyncMeta.get(c);
910
+ let factory = asyncFactory.get(c as Computed<unknown>);
848
911
 
849
- if (meta) {
850
- computed.invalidate(meta.factory);
912
+ if (factory) {
913
+ computed.invalidate(factory);
851
914
  return;
852
915
  }
853
916
 
@@ -860,7 +923,8 @@ computed.invalidate = <T>(c: Computed<T>): void => {
860
923
  // Teardown runs as an iterative LIFO drain, not recursion: a recursive dispose→unlink→dispose cascade
861
924
  // overflows the call stack on deep chains. A dispose issued while a drain runs enqueues and returns,
862
925
  // so the running drain picks it up. Field-nulling keeps the drain exactly-once, so a double dispose
863
- // stays a no-op. The try/finally guarantees `draining` resets even if a cleanup/disposal callback throws.
926
+ // stays a no-op. Cleanup/disposal errors are collected and rethrown once the drain empties, so a
927
+ // throwing cleanup never strands queued nodes; the try/finally still guarantees `draining` resets.
864
928
  const dispose = <T>(computed: Computed<T>): void => {
865
929
  // A dispose issued mid-drain only enqueues; the running drain picks it up, so the first node is
866
930
  // processed inline (no worklist node) and the pool is touched only for re-entrant deep cascades.
@@ -871,7 +935,8 @@ const dispose = <T>(computed: Computed<T>): void => {
871
935
 
872
936
  draining = true;
873
937
 
874
- let node: Computed<unknown> = computed as Computed<unknown>;
938
+ let errors: unknown[] | null = null,
939
+ node: Computed<unknown> = computed as Computed<unknown>;
875
940
 
876
941
  try {
877
942
  for (;;) {
@@ -885,15 +950,30 @@ const dispose = <T>(computed: Computed<T>): void => {
885
950
 
886
951
  node.deps = null;
887
952
 
888
- if (node.cleanup) {
889
- cleanup(node);
953
+ if (node.owner !== null) {
954
+ disown(node);
955
+ }
956
+
957
+ if (node.cleanup !== null || node.owned !== null) {
958
+ try {
959
+ cleanup(node);
960
+ }
961
+ catch (e) {
962
+ (errors ??= []).push(e);
963
+ }
890
964
  }
891
965
 
892
966
  if (node.disposal) {
893
967
  let d = node.disposal;
894
968
 
895
969
  node.disposal = null;
896
- d();
970
+
971
+ try {
972
+ d();
973
+ }
974
+ catch (e) {
975
+ (errors ??= []).push(e);
976
+ }
897
977
  }
898
978
 
899
979
  if (disposeHead === null) {
@@ -907,6 +987,10 @@ const dispose = <T>(computed: Computed<T>): void => {
907
987
  finally {
908
988
  draining = false;
909
989
  }
990
+
991
+ if (errors !== null) {
992
+ throw errors.length === 1 ? errors[0] : new AggregateError(errors, `${PACKAGE_NAME}: dispose produced multiple errors`);
993
+ }
910
994
  };
911
995
 
912
996
  const effect = <T>(fn: Computed<T>['fn'], apply?: (value: T, prev: T | undefined) => void) => {
@@ -948,6 +1032,9 @@ const flush = (): void => {
948
1032
  }
949
1033
  };
950
1034
 
1035
+ // Whether an onCleanup() made now would land on an owner (a running computed/effect or a root's scope)
1036
+ const hasOwner = (): boolean => observer !== null || scope !== null;
1037
+
951
1038
  const isComputed = (value: unknown): value is Computed<unknown> => {
952
1039
  return isObject(value) && !!((value as unknown as Computed<unknown>).state & STATE_COMPUTED);
953
1040
  };
@@ -1028,11 +1115,11 @@ const read = <T>(node: Signal<T> | Computed<T>): T => {
1028
1115
  return node.value;
1029
1116
  };
1030
1117
 
1118
+ // Owned before fn runs (nothing can register on the owner meanwhile, so ownerSlot is unchanged): a
1119
+ // root disposed inside its own fn then unlinks instead of being parked on the owner as a dead child.
1031
1120
  const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T)) => {
1032
- let c,
1033
- o = observer,
1121
+ let o = observer,
1034
1122
  s = scope,
1035
- self: Computed<unknown> | null = null,
1036
1123
  tracking = fn.length,
1037
1124
  value: T;
1038
1125
 
@@ -1040,8 +1127,15 @@ const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T)) => {
1040
1127
 
1041
1128
  try {
1042
1129
  if (tracking) {
1043
- scope = self = makeNode(noop);
1044
- value = (fn as (dispose: VoidFunction) => T)(c = () => dispose(self!));
1130
+ let self = makeNode(noop),
1131
+ owner = o || s;
1132
+
1133
+ if (owner !== null) {
1134
+ own(owner, self);
1135
+ }
1136
+
1137
+ scope = self;
1138
+ value = (fn as (dispose: VoidFunction) => T)(() => dispose(self));
1045
1139
  }
1046
1140
  else {
1047
1141
  scope = null;
@@ -1053,10 +1147,6 @@ const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T)) => {
1053
1147
  scope = s;
1054
1148
  }
1055
1149
 
1056
- if (c) {
1057
- onCleanup(c);
1058
- }
1059
-
1060
1150
  return value;
1061
1151
  };
1062
1152
 
@@ -1166,6 +1256,7 @@ export {
1166
1256
  dispose,
1167
1257
  effect,
1168
1258
  flush,
1259
+ hasOwner,
1169
1260
  isComputed, isSignal,
1170
1261
  onCleanup,
1171
1262
  peek,
package/src/types.ts CHANGED
@@ -13,8 +13,13 @@ interface Computed<T> {
13
13
  gv: number;
14
14
  height: number;
15
15
  nextHeap: Computed<unknown> | undefined;
16
+ nextOwned: Computed<unknown> | null;
17
+ owned: Computed<unknown> | null;
18
+ owner: Computed<unknown> | null;
19
+ ownerSlot: number;
16
20
  pending: Signal<boolean> | null;
17
21
  prevHeap: Computed<unknown>;
22
+ prevOwned: Computed<unknown> | null;
18
23
  rv: number;
19
24
  state: number;
20
25
  subs: Link | null;