@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.
- package/README.md +12 -6
- package/bench/compiler.bench.ts +115 -0
- package/bench/kairo.bench.ts +1 -1
- package/bench/reactive/array.bench.ts +1 -0
- package/bench/reactive/reactive-object.bench.ts +3 -2
- package/build/compiler/array.js +28 -25
- package/build/compiler/bindings.d.ts +3 -1
- package/build/compiler/bindings.js +32 -9
- package/build/compiler/index.d.ts +3 -2
- package/build/compiler/index.js +21 -13
- package/build/compiler/object.js +12 -13
- package/build/compiler/plugins/vite.d.ts +13 -4
- package/build/compiler/primitives.js +9 -9
- package/build/constants.d.ts +2 -1
- package/build/constants.js +2 -1
- package/build/index.d.ts +1 -0
- package/build/reactive/array.d.ts +40 -1
- package/build/reactive/array.js +82 -16
- package/build/reactive/index.d.ts +1 -1
- package/build/reactive/object.d.ts +3 -3
- package/build/reactive/object.js +1 -1
- package/build/system.d.ts +3 -2
- package/build/system.js +10 -5
- package/oxlintrc.json +3 -0
- package/package.json +15 -7
- package/pnpm-workspace.yaml +4 -7
- package/src/compiler/array.ts +30 -27
- package/src/compiler/bindings.ts +49 -10
- package/src/compiler/index.ts +37 -20
- package/src/compiler/object.ts +14 -16
- package/src/compiler/primitives.ts +13 -10
- package/src/constants.ts +4 -1
- package/src/index.ts +1 -0
- package/src/reactive/array.ts +133 -22
- package/src/reactive/index.ts +3 -3
- package/src/reactive/object.ts +4 -4
- package/src/system.ts +20 -5
- package/test/compiler/__snapshots__/output.test.ts.snap +157 -0
- package/test/compiler/compiler.test.ts +51 -9
- package/test/compiler/composition.test.ts +110 -0
- package/test/compiler/coverage.test.ts +22 -22
- package/test/compiler/origins.test.ts +156 -0
- package/test/compiler/output.test.ts +116 -0
- package/test/ownership.test.ts +100 -1
- package/test/reactive/array.test.ts +189 -8
- package/test/reactive/reactive.test.ts +4 -4
- package/test/system.test.ts +3 -4
- package/test/untrack.test.ts +1 -1
- package/tsconfig.build.json +3 -0
- package/tsconfig.json +2 -2
- package/vitest.config.ts +2 -2
- package/bench/tsconfig.json +0 -16
- package/test/tsconfig.json +0 -16
package/src/compiler/index.ts
CHANGED
|
@@ -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
|
|
83
|
-
// (a barrel's rename, a default re-export, a namespace), so every module
|
|
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.
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
if (!entry && scope.kind(bindings, found) === null) {
|
|
107
|
+
if (!scope.holds(bindings, found)) {
|
|
98
108
|
continue;
|
|
99
109
|
}
|
|
100
110
|
|
|
101
|
-
|
|
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
|
-
|
|
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(
|
|
199
|
+
generate: (_sourceFile, text) => `${NAMESPACE}.reactive(${call.arguments.map(text).join(', ')})`,
|
|
183
200
|
node: call
|
|
184
201
|
});
|
|
185
202
|
}
|
package/src/compiler/object.ts
CHANGED
|
@@ -9,7 +9,7 @@ interface AnalyzedProperty {
|
|
|
9
9
|
isStatic: boolean;
|
|
10
10
|
key: string;
|
|
11
11
|
type: TYPES;
|
|
12
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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: () => `${
|
|
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(${
|
|
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: (
|
|
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: (
|
|
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(${
|
|
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(${
|
|
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(${
|
|
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';
|
package/src/reactive/array.ts
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import { isArray } from '@esportsplus/utilities';
|
|
2
|
-
import { PACKAGE_NAME, REACTIVE_ARRAY } from '
|
|
3
|
-
import { read, signal, write } from '
|
|
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
|
-
|
|
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 };
|
package/src/reactive/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { isArray, isObject } from '@esportsplus/utilities';
|
|
2
|
-
import { PACKAGE_NAME } from '
|
|
3
|
-
import { onCleanup, root } from '
|
|
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> =
|
package/src/reactive/object.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { defineProperty, isArray } from '@esportsplus/utilities';
|
|
2
|
-
import { COMPUTED, REACTIVE_ARRAY, REACTIVE_OBJECT, SIGNAL } from '
|
|
3
|
-
import { computed, dispose, read, root, signal, write } from '
|
|
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
|
}
|