@esportsplus/reactivity 0.39.2 → 0.41.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.
Files changed (53) hide show
  1. package/README.md +12 -6
  2. package/bench/compiler.bench.ts +115 -0
  3. package/bench/kairo.bench.ts +1 -1
  4. package/bench/reactive/array.bench.ts +1 -0
  5. package/bench/reactive/reactive-object.bench.ts +3 -2
  6. package/build/compiler/array.js +28 -25
  7. package/build/compiler/bindings.d.ts +3 -1
  8. package/build/compiler/bindings.js +32 -9
  9. package/build/compiler/index.d.ts +3 -2
  10. package/build/compiler/index.js +21 -13
  11. package/build/compiler/object.js +12 -13
  12. package/build/compiler/plugins/vite.d.ts +13 -4
  13. package/build/compiler/primitives.js +9 -9
  14. package/build/constants.d.ts +2 -1
  15. package/build/constants.js +2 -1
  16. package/build/index.d.ts +1 -0
  17. package/build/reactive/array.d.ts +40 -1
  18. package/build/reactive/array.js +82 -16
  19. package/build/reactive/index.d.ts +1 -1
  20. package/build/reactive/object.d.ts +3 -3
  21. package/build/reactive/object.js +1 -1
  22. package/build/system.d.ts +3 -2
  23. package/build/system.js +10 -5
  24. package/oxlintrc.json +3 -0
  25. package/package.json +15 -7
  26. package/pnpm-workspace.yaml +4 -7
  27. package/src/compiler/array.ts +30 -27
  28. package/src/compiler/bindings.ts +49 -10
  29. package/src/compiler/index.ts +37 -20
  30. package/src/compiler/object.ts +14 -16
  31. package/src/compiler/primitives.ts +13 -10
  32. package/src/constants.ts +4 -1
  33. package/src/index.ts +1 -0
  34. package/src/reactive/array.ts +133 -22
  35. package/src/reactive/index.ts +3 -3
  36. package/src/reactive/object.ts +4 -4
  37. package/src/system.ts +20 -5
  38. package/test/compiler/__snapshots__/output.test.ts.snap +157 -0
  39. package/test/compiler/compiler.test.ts +51 -9
  40. package/test/compiler/composition.test.ts +110 -0
  41. package/test/compiler/coverage.test.ts +22 -22
  42. package/test/compiler/origins.test.ts +156 -0
  43. package/test/compiler/output.test.ts +116 -0
  44. package/test/ownership.test.ts +100 -1
  45. package/test/reactive/array.test.ts +189 -8
  46. package/test/reactive/reactive.test.ts +4 -4
  47. package/test/system.test.ts +3 -4
  48. package/test/untrack.test.ts +1 -1
  49. package/tsconfig.build.json +3 -0
  50. package/tsconfig.json +2 -2
  51. package/vitest.config.ts +2 -2
  52. package/bench/tsconfig.json +0 -16
  53. package/test/tsconfig.json +0 -16
@@ -9,6 +9,11 @@ import primitives from './primitives';
9
9
  import scope from './bindings';
10
10
 
11
11
 
12
+ // A binding from this package is only ever reached through an import, so a module without one has
13
+ // nothing to compile and is not resolved at all
14
+ const REGEX_IMPORT = /\bimport\b|\brequire\s*\(/;
15
+
16
+
12
17
  // Every use the compiler cannot lower fails the build, all of them reported at once: shipping one
13
18
  // would leave an uncompiled reactive() or an unread signal in the output
14
19
  function failed(sourceFile: ts.SourceFile, failures: Failure[]): Error {
@@ -64,13 +69,14 @@ function isLowered(site: ts.Node): boolean {
64
69
  }
65
70
 
66
71
 
67
- // `reactive`, or the member access naming it (`ns.reactive`, `ns['reactive']`)
72
+ // `reactive`, or the member access naming it (`ns.reactive`, `ns['reactive']`); in `list[reactive]`
73
+ // the key is the use
68
74
  function siteOf(identifier: ts.Node): ts.Node {
69
75
  let parent = identifier.parent!;
70
76
 
71
77
  if (
72
78
  (ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
73
- (ts.isElementAccessExpression(parent) && parent.argumentExpression === identifier)
79
+ (ts.isElementAccessExpression(parent) && parent.argumentExpression === identifier && (ts.isStringLiteral(identifier) || ts.isNoSubstitutionTemplateLiteral(identifier)))
74
80
  ) {
75
81
  return parent;
76
82
  }
@@ -79,34 +85,30 @@ function siteOf(identifier: ts.Node): ts.Node {
79
85
  }
80
86
 
81
87
 
82
- // No text patterns: a module can reach `reactive`, or read a reactive binding, under any name
83
- // (a barrel's rename, a default re-export, a namespace), so every module is resolved against the
84
- // program and compiled exactly where its values resolve to reactive declarations
88
+ // No name patterns: a module can reach `reactive`, or read a reactive binding, under any name
89
+ // (a barrel's rename, a default re-export, a namespace), so every module that imports anything is
90
+ // resolved against the program and compiled exactly where its values resolve to reactive declarations
85
91
  export default {
92
+ name: PACKAGE_NAME,
86
93
  transform: (ctx: TransformContext) => {
94
+ if (!REGEX_IMPORT.test(ctx.code)) {
95
+ return {};
96
+ }
97
+
87
98
  let { checker, program, sourceFile } = ctx,
88
99
  bindings = scope.create(checker, program, sourceFile),
89
- calls = new Set<ts.Node>(),
100
+ calls = new Set<ts.CallExpression>(),
90
101
  dependencies = new Set<string>(),
102
+ entries = new Set<ts.Node>(),
91
103
  failures: Failure[] = [],
92
104
  self = sourceFile.fileName.toLowerCase();
93
105
 
94
106
  for (let [identifier, found] of bindings.origins) {
95
- let entry = scope.holds(bindings, found);
96
-
97
- if (!entry && scope.kind(bindings, found) === null) {
107
+ if (!scope.holds(bindings, found)) {
98
108
  continue;
99
109
  }
100
110
 
101
- for (let file of [...found.through, found.declaration.path]) {
102
- if (file.toLowerCase() !== self) {
103
- dependencies.add(file);
104
- }
105
- }
106
-
107
- if (!entry) {
108
- continue;
109
- }
111
+ entries.add(identifier);
110
112
 
111
113
  let site = siteOf(identifier),
112
114
  parent = site.parent;
@@ -126,7 +128,22 @@ export default {
126
128
  }
127
129
  }
128
130
 
129
- let isReactiveCall = (node: ts.Node): node is ts.CallExpression => calls.has(node),
131
+ // Every call is known before any binding is classified, so their argument types arrive in one request
132
+ scope.prefetch(bindings, [...calls]);
133
+
134
+ for (let [identifier, found] of bindings.origins) {
135
+ if (!entries.has(identifier) && scope.kind(bindings, found) === null) {
136
+ continue;
137
+ }
138
+
139
+ for (let file of [...found.through, found.declaration.path]) {
140
+ if (file.toLowerCase() !== self) {
141
+ dependencies.add(file);
142
+ }
143
+ }
144
+ }
145
+
146
+ let isReactiveCall = (node: ts.Node): node is ts.CallExpression => calls.has(node as ts.CallExpression),
130
147
  intents = {
131
148
  imports: [] as ImportIntent[],
132
149
  prepend: [] as string[],
@@ -179,7 +196,7 @@ export default {
179
196
  }
180
197
 
181
198
  intents.replacements.push({
182
- generate: () => `${NAMESPACE}.reactive(${call.arguments.map(a => a.getText(sourceFile)).join(', ')})`,
199
+ generate: (_sourceFile, text) => `${NAMESPACE}.reactive(${call.arguments.map(text).join(', ')})`,
183
200
  node: call
184
201
  });
185
202
  }
@@ -9,7 +9,7 @@ interface AnalyzedProperty {
9
9
  isStatic: boolean;
10
10
  key: string;
11
11
  type: TYPES;
12
- valueText: string;
12
+ value: ts.Expression;
13
13
  }
14
14
 
15
15
  type ObjectTransformResult = {
@@ -34,8 +34,6 @@ interface VisitContext {
34
34
 
35
35
 
36
36
  function analyzeProperty(prop: ts.ObjectLiteralElementLike, bindings: Bindings): AnalyzedProperty | null {
37
- let sourceFile = bindings.sourceFile;
38
-
39
37
  if (!ts.isPropertyAssignment(prop)) {
40
38
  return null;
41
39
  }
@@ -50,8 +48,7 @@ function analyzeProperty(prop: ts.ObjectLiteralElementLike, bindings: Bindings):
50
48
  }
51
49
 
52
50
  let unwrapped = prop.initializer,
53
- value = unwrapped,
54
- valueText = value.getText(sourceFile);
51
+ value = unwrapped;
55
52
 
56
53
  while (ts.isAsExpression(unwrapped) || ts.isTypeAssertion(unwrapped) || ts.isParenthesizedExpression(unwrapped)) {
57
54
  unwrapped = unwrapped.expression;
@@ -68,12 +65,12 @@ function analyzeProperty(prop: ts.ObjectLiteralElementLike, bindings: Bindings):
68
65
  type.typeName.text === 'Array'
69
66
  )
70
67
  ) {
71
- return { isStatic: false, key, type: TYPES.Array, valueText };
68
+ return { isStatic: false, key, type: TYPES.Array, value };
72
69
  }
73
70
  }
74
71
 
75
72
  if (ts.isArrowFunction(unwrapped) || ts.isFunctionExpression(unwrapped)) {
76
- return { isStatic: false, key, type: TYPES.Computed, valueText };
73
+ return { isStatic: false, key, type: TYPES.Computed, value };
77
74
  }
78
75
 
79
76
  if (ts.isArrayLiteralExpression(unwrapped)) {
@@ -86,7 +83,7 @@ function analyzeProperty(prop: ts.ObjectLiteralElementLike, bindings: Bindings):
86
83
  }
87
84
  }
88
85
 
89
- return { isStatic, key, type: TYPES.Array, valueText };
86
+ return { isStatic, key, type: TYPES.Array, value };
90
87
  }
91
88
 
92
89
  // A non-literal array is typed Reactive<T[]> on the object, so it must become a ReactiveArray
@@ -94,18 +91,18 @@ function analyzeProperty(prop: ts.ObjectLiteralElementLike, bindings: Bindings):
94
91
  let type = isStaticValue(unwrapped) ? undefined : bindings.checker.getTypeAtLocation(value);
95
92
 
96
93
  if (type && (bindings.checker.isArrayType(type) || bindings.checker.isTupleType(type))) {
97
- return { isStatic: false, key, type: TYPES.Array, valueText };
94
+ return { isStatic: false, key, type: TYPES.Array, value };
98
95
  }
99
96
 
100
97
  return {
101
98
  isStatic: isStaticValue(value),
102
99
  key,
103
100
  type: TYPES.Signal,
104
- valueText
101
+ value
105
102
  };
106
103
  }
107
104
 
108
- function buildClassCode(classname: string, properties: AnalyzedProperty[], typehint: string | null): string {
105
+ function buildClassCode(classname: string, properties: AnalyzedProperty[], sourceFile: ts.SourceFile, typehint: string | null): string {
109
106
  let accessors: string[] = [],
110
107
  body: string[] = [],
111
108
  constraint: string[] = [],
@@ -115,7 +112,7 @@ function buildClassCode(classname: string, properties: AnalyzedProperty[], typeh
115
112
  setters = 0;
116
113
 
117
114
  for (let i = 0, n = properties.length; i < n; i++) {
118
- let { isStatic, key, type, valueText } = properties[i],
115
+ let { isStatic, key, type, value: initializer } = properties[i],
119
116
  generic = typehint ? `T['${key}']` : `T${parameters.length}`,
120
117
  parameter = `_p${parameters.length}`;
121
118
 
@@ -137,7 +134,8 @@ function buildClassCode(classname: string, properties: AnalyzedProperty[], typeh
137
134
  ${NAMESPACE}.write(this.#${key}, ${value});
138
135
  }
139
136
  `);
140
- fields.push(`#${key} = this[${NAMESPACE}.SIGNAL](${valueText});`);
137
+ // A static value is a literal, which no plugin rewrites, so its source is the output
138
+ fields.push(`#${key} = this[${NAMESPACE}.SIGNAL](${initializer.getText(sourceFile)});`);
141
139
  }
142
140
  else {
143
141
  accessors.push(`
@@ -286,13 +284,13 @@ export default (sourceFile: ts.SourceFile, bindings: Bindings, isReactiveCall: I
286
284
  let call = ctx.calls[i],
287
285
  typehint = call.typehint;
288
286
 
289
- prepend.push(buildClassCode(call.classname, call.properties, typehint));
287
+ prepend.push(buildClassCode(call.classname, call.properties, sourceFile, typehint));
290
288
  replacements.push({
291
- generate: () => {
289
+ generate: (_sourceFile, text) => {
292
290
  // Every property but a static signal (baked into its field) is a constructor parameter
293
291
  let args = call.properties
294
292
  .filter(({ isStatic, type }) => typehint || !isStatic || type !== TYPES.Signal)
295
- .map(p => p.valueText)
293
+ .map(p => text(p.value))
296
294
  .join(', ');
297
295
 
298
296
  return typehint
@@ -144,13 +144,14 @@ function isPatternTarget(node: ts.Node): boolean {
144
144
  ((ts.isForOfStatement(parent) || ts.isForInStatement(parent)) && parent.initializer === current);
145
145
  }
146
146
 
147
- // A binding read as `ns.count` / `ns['count']` is rewritten as a whole
147
+ // A binding read as `ns.count` / `ns['count']` is rewritten as a whole; in `list[count]` the key is
148
+ // the read
148
149
  function readNode(identifier: ts.Node): ts.Expression {
149
150
  let parent = identifier.parent!;
150
151
 
151
152
  if (
152
153
  (ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
153
- (ts.isElementAccessExpression(parent) && parent.argumentExpression === identifier)
154
+ (ts.isElementAccessExpression(parent) && parent.argumentExpression === identifier && (ts.isStringLiteral(identifier) || ts.isNoSubstitutionTemplateLiteral(identifier)))
154
155
  ) {
155
156
  return parent;
156
157
  }
@@ -175,11 +176,13 @@ function reference(ctx: TransformContext, node: ts.Expression, kind: TYPES): voi
175
176
  return;
176
177
  }
177
178
 
178
- let text = node.getText(ctx.bindings.sourceFile);
179
+ // The binding itself (`count`, `ns.count`, `ns['count']`) holds nothing another rewrite replaces,
180
+ // so its source is copied as is: text() walks the file's intents on every call
181
+ let binding = node.getText(ctx.bindings.sourceFile);
179
182
 
180
183
  if (ts.isShorthandPropertyAssignment(parent)) {
181
184
  ctx.replacements.push({
182
- generate: () => `${text}: ${NAMESPACE}.read(${text})`,
185
+ generate: () => `${binding}: ${NAMESPACE}.read(${binding})`,
183
186
  node: parent
184
187
  });
185
188
 
@@ -208,7 +211,7 @@ function reference(ctx: TransformContext, node: ts.Expression, kind: TYPES): voi
208
211
 
209
212
  if (!writeCtx) {
210
213
  ctx.replacements.push({
211
- generate: () => `${NAMESPACE}.read(${text})`,
214
+ generate: () => `${NAMESPACE}.read(${binding})`,
212
215
  node
213
216
  });
214
217
 
@@ -224,7 +227,7 @@ function reference(ctx: TransformContext, node: ts.Expression, kind: TYPES): voi
224
227
  let right = parent.right;
225
228
 
226
229
  ctx.replacements.push({
227
- generate: (sf) => `${NAMESPACE}.write(${text}, ${right.getText(sf)})`,
230
+ generate: (_sourceFile, text) => `${NAMESPACE}.write(${binding}, ${text(right)})`,
228
231
  node: parent
229
232
  });
230
233
  }
@@ -233,7 +236,7 @@ function reference(ctx: TransformContext, node: ts.Expression, kind: TYPES): voi
233
236
  right = parent.right;
234
237
 
235
238
  ctx.replacements.push({
236
- generate: (sf) => `${NAMESPACE}.write(${text}, ${text}.value ${op} ${right.getText(sf)})`,
239
+ generate: (_sourceFile, text) => `${NAMESPACE}.write(${binding}, ${binding}.value ${op} ${text(right)})`,
237
240
  node: parent
238
241
  });
239
242
  }
@@ -243,13 +246,13 @@ function reference(ctx: TransformContext, node: ts.Expression, kind: TYPES): voi
243
246
 
244
247
  if (ts.isExpressionStatement(unary.parent)) {
245
248
  ctx.replacements.push({
246
- generate: () => `${NAMESPACE}.write(${text}, ${text}.value ${delta})`,
249
+ generate: () => `${NAMESPACE}.write(${binding}, ${binding}.value ${delta})`,
247
250
  node: unary
248
251
  });
249
252
  }
250
253
  else if (ts.isPrefixUnaryExpression(unary)) {
251
254
  ctx.replacements.push({
252
- generate: () => `(${NAMESPACE}.write(${text}, ${text}.value ${delta}), ${text}.value)`,
255
+ generate: () => `(${NAMESPACE}.write(${binding}, ${binding}.value ${delta}), ${binding}.value)`,
253
256
  node: unary
254
257
  });
255
258
  }
@@ -257,7 +260,7 @@ function reference(ctx: TransformContext, node: ts.Expression, kind: TYPES): voi
257
260
  let tmp = `_t${ctx.tmpCounter++}`;
258
261
 
259
262
  ctx.replacements.push({
260
- generate: () => `((${tmp}) => (${NAMESPACE}.write(${text}, ${tmp} ${delta}), ${tmp}))(${text}.value)`,
263
+ generate: () => `((${tmp}) => (${NAMESPACE}.write(${binding}, ${tmp} ${delta}), ${tmp}))(${binding}.value)`,
261
264
  node: unary
262
265
  });
263
266
  }
package/src/constants.ts CHANGED
@@ -32,6 +32,9 @@ const STATE_ERROR = 1 << 5;
32
32
 
33
33
  const STATE_EFFECT = 1 << 6;
34
34
 
35
+ // Set by dispose(): a node that can never run or own anything again
36
+ const STATE_DISPOSED = 1 << 7;
37
+
35
38
  const STATE_NOTIFY_MASK = (STATE_CHECK | STATE_DIRTY);
36
39
 
37
40
 
@@ -41,5 +44,5 @@ export {
41
44
  REACTIVE_ARRAY, REACTIVE_OBJECT,
42
45
  SIGNAL,
43
46
  STABILIZER_DEFERRED, STABILIZER_IDLE, STABILIZER_RESCHEDULE, STABILIZER_RUNNING, STABILIZER_SCHEDULED,
44
- STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING
47
+ STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_DISPOSED, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING
45
48
  };
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { isPromise } from '@esportsplus/utilities';
2
2
  export { COMPUTED, REACTIVE_ARRAY, REACTIVE_OBJECT, SIGNAL } from './constants';
3
3
  export { default as reactive, ReactiveArray, ReactiveObject } from './reactive';
4
+ export type { ArrayOp } from './reactive/array';
4
5
  export * from './system';
5
6
  export * from './types';
@@ -1,11 +1,27 @@
1
1
  import { isArray } from '@esportsplus/utilities';
2
- import { PACKAGE_NAME, REACTIVE_ARRAY } from '~/constants';
3
- import { read, signal, write } from '~/system';
4
- import type { Signal } from '~/types';
2
+ import { PACKAGE_NAME, REACTIVE_ARRAY } from '../constants';
3
+ import { effect, read, root, signal, write } from '../system';
5
4
  import { isReactiveObject } from './object';
6
-
5
+ import type { Signal } from '../types';
6
+
7
+
8
+ // One mutation as the batch event reports it: the matching event's payload, named by 'op'
9
+ type ArrayOp<T> =
10
+ | { items: T[]; op: 'concat' }
11
+ | { deleteCount: number; items: T[]; op: 'splice'; start: number }
12
+ | { items: T[]; op: 'push' }
13
+ | { index: number; item: T; op: 'set' }
14
+ | { items: T[]; op: 'unshift' }
15
+ | { op: 'clear' }
16
+ | { item: T; op: 'pop' }
17
+ | { op: 'reverse' }
18
+ | { item: T; op: 'shift' }
19
+ | { op: 'sort'; order: number[] };
7
20
 
8
21
  type Events<T> = {
22
+ // Every mutation of a task, in order, delivered once inside the reactivity pass that follows it (flush() settles
23
+ // it); a clear drops the mutations before it
24
+ batch: ArrayOp<T>[];
9
25
  clear: undefined,
10
26
  concat: {
11
27
  items: T[];
@@ -60,6 +76,11 @@ class ReactiveArray<T> extends Array<T> {
60
76
 
61
77
  private _length: Signal<number>;
62
78
 
79
+ // The batch listeners' delivery: the mutations not yet delivered, and the effect that delivers them
80
+ private batched: { ops: ArrayOp<T>[] | null; stop: VoidFunction; tick: Signal<number> } | null = null;
81
+
82
+ private delivering = false;
83
+
63
84
  listeners: Listeners<T> = {};
64
85
 
65
86
 
@@ -110,14 +131,95 @@ class ReactiveArray<T> extends Array<T> {
110
131
  }
111
132
 
112
133
  this.dispatch('set', { index: i, item: value });
134
+ this.record({ index: i, item: value, op: 'set' });
113
135
  dispose(prev);
114
136
  }
115
137
 
116
138
 
139
+ // Starts delivering batches: an effect of its own, outside any owner, since the array outlives whoever listens
140
+ private batch() {
141
+ let tick = signal(0);
142
+
143
+ this.batched = {
144
+ ops: null,
145
+ stop: root(() => effect(() => {
146
+ read(tick);
147
+
148
+ let batched = this.batched,
149
+ ops = batched?.ops;
150
+
151
+ if (!ops) {
152
+ return;
153
+ }
154
+
155
+ batched!.ops = null;
156
+ this.delivering = true;
157
+
158
+ try {
159
+ // What listeners build belongs to them, not to this effect
160
+ root(() => this.dispatch('batch', ops));
161
+ }
162
+ finally {
163
+ this.delivering = false;
164
+ }
165
+
166
+ // Disposing this effect inside its own run corrupts its dependency links, so the stop waits for the
167
+ // run to end; a listener that joins meanwhile keeps it
168
+ if (this.listeners.batch === undefined) {
169
+ queueMicrotask(() => {
170
+ if (this.listeners.batch === undefined) {
171
+ this.unbatch();
172
+ }
173
+ });
174
+ }
175
+ })),
176
+ tick
177
+ };
178
+ }
179
+
180
+ // Trims trailing empty slots; an event left with no listeners is deleted, so e.g. sort() skips computing its order
181
+ private prune<K extends keyof Events<T>>(event: K, listeners: (AnyListener<T> | null)[]) {
182
+ while (listeners.length && listeners[listeners.length - 1] === null) {
183
+ listeners.pop();
184
+ }
185
+
186
+ if (listeners.length === 0 && this.listeners[event] === listeners) {
187
+ delete this.listeners[event];
188
+ }
189
+ }
190
+
191
+ private record(op: ArrayOp<T>) {
192
+ let batched = this.batched;
193
+
194
+ if (batched === null) {
195
+ return;
196
+ }
197
+
198
+ if (op.op === 'clear' || batched.ops === null) {
199
+ batched.ops = [op];
200
+ }
201
+ else {
202
+ batched.ops.push(op);
203
+ }
204
+
205
+ write(batched.tick, batched.tick.value + 1);
206
+ }
207
+
208
+ private unbatch() {
209
+ let batched = this.batched;
210
+
211
+ if (batched !== null) {
212
+ this.batched = null;
213
+ batched.stop();
214
+ }
215
+ }
216
+
217
+
117
218
  clear() {
118
219
  this.dispose();
119
220
  write(this._length, 0);
120
221
  this.dispatch('clear');
222
+ this.record({ op: 'clear' });
121
223
  }
122
224
 
123
225
  concat(...items: ConcatArray<T>[]): ReactiveArray<T>;
@@ -143,6 +245,7 @@ class ReactiveArray<T> extends Array<T> {
143
245
  if (added.length) {
144
246
  write(this._length, this.length);
145
247
  this.dispatch('concat', { items: added });
248
+ this.record({ items: added, op: 'concat' });
146
249
  }
147
250
 
148
251
  return this;
@@ -185,6 +288,10 @@ class ReactiveArray<T> extends Array<T> {
185
288
  this.prune(event, listeners);
186
289
  }
187
290
 
291
+ if (event === 'batch' && !this.delivering && this.listeners.batch === undefined) {
292
+ this.unbatch();
293
+ }
294
+
188
295
  if (errors !== null) {
189
296
  let error = errors.length === 1
190
297
  ? errors[0]
@@ -210,6 +317,10 @@ class ReactiveArray<T> extends Array<T> {
210
317
  let entry = listener as AnyListener<T>,
211
318
  listeners = this.listeners[event];
212
319
 
320
+ if (event === 'batch' && this.batched === null) {
321
+ this.batch();
322
+ }
323
+
213
324
  if (listeners === undefined) {
214
325
  this.listeners[event] = [entry];
215
326
  }
@@ -248,6 +359,10 @@ class ReactiveArray<T> extends Array<T> {
248
359
  current[i] = null;
249
360
  this.prune(event, current);
250
361
  }
362
+
363
+ if (event === 'batch' && !this.delivering && this.listeners.batch === undefined) {
364
+ this.unbatch();
365
+ }
251
366
  };
252
367
  }
253
368
 
@@ -257,17 +372,6 @@ class ReactiveArray<T> extends Array<T> {
257
372
  return this.on(event, listener);
258
373
  }
259
374
 
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
- }
269
- }
270
-
271
375
  pop() {
272
376
  if (this.length === 0) {
273
377
  return undefined;
@@ -278,6 +382,7 @@ class ReactiveArray<T> extends Array<T> {
278
382
  dispose(item);
279
383
  write(this._length, this.length);
280
384
  this.dispatch('pop', { item });
385
+ this.record({ item, op: 'pop' });
281
386
 
282
387
  return item;
283
388
  }
@@ -292,6 +397,7 @@ class ReactiveArray<T> extends Array<T> {
292
397
  write(this._length, length);
293
398
 
294
399
  this.dispatch('push', { items });
400
+ this.record({ items, op: 'push' });
295
401
 
296
402
  return length;
297
403
  }
@@ -299,6 +405,7 @@ class ReactiveArray<T> extends Array<T> {
299
405
  reverse() {
300
406
  super.reverse();
301
407
  this.dispatch('reverse');
408
+ this.record({ op: 'reverse' });
302
409
 
303
410
  return this;
304
411
  }
@@ -313,16 +420,18 @@ class ReactiveArray<T> extends Array<T> {
313
420
  dispose(item);
314
421
  write(this._length, this.length);
315
422
  this.dispatch('shift', { item });
423
+ this.record({ item, op: 'shift' });
316
424
 
317
425
  return item;
318
426
  }
319
427
 
320
428
  sort(fn?: (a: T, b: T) => number) {
321
- if (this.listeners.sort === undefined) {
429
+ if (this.listeners.sort === undefined && this.batched === null) {
322
430
  super.sort(fn);
323
431
  }
324
432
  else {
325
433
  let n = this.length,
434
+ // oxlint-disable-next-line unicorn/no-new-array -- preallocated to n, every slot is written next
326
435
  before = new Array(n) as T[];
327
436
 
328
437
  for (let i = 0; i < n; i++) {
@@ -332,6 +441,7 @@ class ReactiveArray<T> extends Array<T> {
332
441
  super.sort(fn);
333
442
 
334
443
  let buckets = new Map<T, { indices: number[]; next: number }>(),
444
+ // oxlint-disable-next-line unicorn/no-new-array -- preallocated to n, every slot is written next
335
445
  order = new Array(n);
336
446
 
337
447
  for (let i = 0; i < n; i++) {
@@ -353,6 +463,7 @@ class ReactiveArray<T> extends Array<T> {
353
463
  }
354
464
 
355
465
  this.dispatch('sort', { order });
466
+ this.record({ op: 'sort', order });
356
467
  }
357
468
 
358
469
  return this;
@@ -371,13 +482,11 @@ class ReactiveArray<T> extends Array<T> {
371
482
 
372
483
  // Listeners receive the position and count Array.prototype.splice actually applied
373
484
  // (negative, fractional and out-of-range arguments resolved), never the raw arguments
374
- let index = Math.trunc(start) || 0;
485
+ let index = Math.trunc(start) || 0,
486
+ resolved = index < 0 ? Math.max(length + index, 0) : Math.min(index, length);
375
487
 
376
- this.dispatch('splice', {
377
- deleteCount: removed.length,
378
- items,
379
- start: index < 0 ? Math.max(length + index, 0) : Math.min(index, length)
380
- });
488
+ this.dispatch('splice', { deleteCount: removed.length, items, start: resolved });
489
+ this.record({ deleteCount: removed.length, items, op: 'splice', start: resolved });
381
490
  }
382
491
 
383
492
  return removed;
@@ -392,6 +501,7 @@ class ReactiveArray<T> extends Array<T> {
392
501
 
393
502
  write(this._length, length);
394
503
  this.dispatch('unshift', { items });
504
+ this.record({ items, op: 'unshift' });
395
505
 
396
506
  return length;
397
507
  }
@@ -401,3 +511,4 @@ Object.defineProperty(ReactiveArray.prototype, REACTIVE_ARRAY, { value: true });
401
511
 
402
512
 
403
513
  export { ReactiveArray };
514
+ export type { ArrayOp };
@@ -1,9 +1,9 @@
1
1
  import { isArray, isObject } from '@esportsplus/utilities';
2
- import { PACKAGE_NAME } from '~/constants';
3
- import { onCleanup, root } from '~/system';
4
- import type { Reactive } from '~/types';
2
+ import { PACKAGE_NAME } from '../constants';
3
+ import { onCleanup, root } from '../system';
5
4
  import { ReactiveArray } from './array';
6
5
  import { ReactiveObject } from './object';
6
+ import type { Reactive } from '../types';
7
7
 
8
8
 
9
9
  type Guard<T> =
@@ -1,8 +1,8 @@
1
1
  import { defineProperty, isArray } from '@esportsplus/utilities';
2
- import { COMPUTED, REACTIVE_ARRAY, REACTIVE_OBJECT, SIGNAL } from '~/constants';
3
- import { computed, dispose, read, root, signal, write } from '~/system';
4
- import { Computed } from '~/types';
2
+ import { COMPUTED, REACTIVE_ARRAY, REACTIVE_OBJECT, SIGNAL } from '../constants';
3
+ import { computed, dispose, read, root, signal, write } from '../system';
5
4
  import { ReactiveArray } from './array';
5
+ import type { Computed } from '../types';
6
6
 
7
7
 
8
8
  class ReactiveObject<T extends Record<PropertyKey, unknown>> {
@@ -87,7 +87,7 @@ class ReactiveObject<T extends Record<PropertyKey, unknown>> {
87
87
  return;
88
88
  }
89
89
 
90
- while (disposer = disposers.pop()) {
90
+ while ((disposer = disposers.pop())) {
91
91
  disposer();
92
92
  }
93
93
  }