@esportsplus/reactivity 0.36.7 → 0.37.1

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.
@@ -2,79 +2,26 @@ import { ts } from '@esportsplus/typescript';
2
2
  import type { ReplacementIntent } from '@esportsplus/typescript/compiler';
3
3
  import { COMPOUND_OPERATORS, NAMESPACE, TYPES } from './constants';
4
4
  import type { Bindings, IsReactiveCall } from './types';
5
+ import scope from './bindings';
5
6
 
6
7
 
7
- interface ScopeBinding {
8
- depth: number;
9
- name: string;
10
- scope: ts.Node;
11
- type: TYPES;
12
- }
8
+ type PrimitivesTransformResult = {
9
+ calls: ts.CallExpression[];
10
+ replacements: ReplacementIntent[];
11
+ };
13
12
 
14
- interface TransformContext {
13
+ type TransformContext = {
15
14
  bindings: Bindings;
16
15
  calls: ts.CallExpression[];
17
16
  isReactiveCall: IsReactiveCall;
18
17
  replacements: ReplacementIntent[];
19
- scopedBindings: ScopeBinding[];
20
- sourceFile: ts.SourceFile;
21
18
  tmpCounter: number;
22
- }
23
-
24
- type PrimitivesTransformResult = {
25
- calls: ts.CallExpression[];
26
- replacements: ReplacementIntent[];
27
19
  };
28
20
 
29
21
 
30
- function inScope(reference: ts.Node, binding: ScopeBinding): boolean {
31
- let current: ts.Node | undefined = reference;
32
-
33
- while (current) {
34
- if (current === binding.scope) {
35
- return true;
36
- }
37
-
38
- current = current.parent;
39
- }
40
-
41
- return false;
42
- }
43
-
44
- function isScope(node: ts.Node): boolean {
45
- return ts.isArrowFunction(node) ||
46
- ts.isBlock(node) ||
47
- ts.isCatchClause(node) ||
48
- ts.isForInStatement(node) ||
49
- ts.isForOfStatement(node) ||
50
- ts.isForStatement(node) ||
51
- ts.isFunctionDeclaration(node) ||
52
- ts.isFunctionExpression(node) ||
53
- ts.isSourceFile(node);
54
- }
55
-
56
- // Innermost enclosing scope plus its nesting depth, so shadowed names resolve to the closest binding
57
- function scopeOf(node: ts.Node): { depth: number; scope: ts.Node } {
58
- let current: ts.Node | undefined = node.parent,
59
- depth = 0,
60
- scope: ts.Node = node.getSourceFile();
61
-
62
- while (current) {
63
- if (isScope(current)) {
64
- if (scope === node.getSourceFile() && !ts.isSourceFile(current)) {
65
- scope = current;
66
- }
67
-
68
- depth++;
69
- }
70
-
71
- current = current.parent;
72
- }
73
-
74
- return { depth, scope };
75
- }
76
-
77
- function visit(ctx: TransformContext, node: ts.Node): void {
22
+ // Pass 1: record every reactive() call and the variable each signal/computed is bound to, before
23
+ // any reference is rewritten — a use that precedes its declaration in source order still resolves
24
+ function declare(ctx: TransformContext, node: ts.Node): void {
78
25
  if (ctx.isReactiveCall(node)) {
79
26
  let call = node;
80
27
 
@@ -99,160 +46,217 @@ function visit(ctx: TransformContext, node: ts.Node): void {
99
46
  }
100
47
  // Dynamic expression - use runtime reactive via namespace
101
48
  else if (ts.isCallExpression(unwrapped) || ts.isIdentifier(unwrapped)) {
49
+ classification = null;
102
50
  ctx.replacements.push({
103
51
  generate: () => `${NAMESPACE}.reactive`,
104
52
  node: call.expression
105
53
  });
106
- node.forEachChild(n => visit(ctx, n));
107
- return;
108
54
  }
109
55
  }
110
56
 
111
- if (classification) {
112
- let varname: string | null = null;
57
+ if (classification !== null) {
58
+ let target = targetOf(call),
59
+ type = classification;
113
60
 
114
- if (call.parent && ts.isVariableDeclaration(call.parent) && ts.isIdentifier(call.parent.name)) {
115
- varname = call.parent.name.text;
116
- }
117
- else if (
118
- call.parent &&
119
- ts.isBinaryExpression(call.parent) &&
120
- call.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
121
- ts.isIdentifier(call.parent.left)
122
- ) {
123
- varname = call.parent.left.text;
124
- }
125
-
126
- if (varname) {
127
- let { depth, scope } = scopeOf(call);
128
-
129
- ctx.bindings.set(varname, classification);
130
- ctx.scopedBindings.push({ depth, name: varname, scope, type: classification });
61
+ if (target) {
62
+ scope.declare(ctx.bindings, target, type);
131
63
  }
132
64
 
133
65
  // Replace just the 'reactive' identifier with the appropriate namespace function
134
66
  ctx.replacements.push({
135
- generate: () => classification === TYPES.Computed
67
+ generate: () => type === TYPES.Computed
136
68
  ? `${NAMESPACE}.computed`
137
69
  : `${NAMESPACE}.signal`,
138
70
  node: call.expression
139
71
  });
140
-
141
- // Continue visiting children - inner identifiers will get their own ReplacementIntents
142
72
  }
143
73
  }
144
74
  }
145
75
 
76
+ node.forEachChild(child => declare(ctx, child));
77
+ }
78
+
79
+ // A name slot rather than a value reference: a declaration's or member's own name, a property
80
+ // name (`o.count`, `{ count: 1 }`, `{ count: local } = o`), or a label. A shorthand property's
81
+ // name doubles as a read of the variable, so it is not a name slot. (TS 7's `isDeclarationName`
82
+ // only tests the node kind, so it is true for every identifier and cannot be used here.)
83
+ function isName(node: ts.Identifier, parent: ts.Node): boolean {
84
+ if (ts.isShorthandPropertyAssignment(parent)) {
85
+ return false;
86
+ }
87
+
88
+ let slots = parent as { label?: ts.Node; name?: ts.Node; propertyName?: ts.Node };
89
+
90
+ return slots.name === node || slots.propertyName === node || slots.label === node;
91
+ }
92
+
93
+ // Destructuring assignment targets (`({ count } = o)`, `[count] = xs`) write through a pattern
94
+ // that has no per-signal rewrite, so they are left untouched
95
+ function isPatternTarget(node: ts.Node): boolean {
96
+ let current = node;
97
+
98
+ while (
99
+ current.parent &&
100
+ (
101
+ ts.isShorthandPropertyAssignment(current.parent) ||
102
+ ts.isPropertyAssignment(current.parent) ||
103
+ ts.isSpreadAssignment(current.parent) ||
104
+ ts.isSpreadElement(current.parent) ||
105
+ ts.isObjectLiteralExpression(current.parent) ||
106
+ ts.isArrayLiteralExpression(current.parent)
107
+ )
108
+ ) {
109
+ current = current.parent;
110
+ }
111
+
112
+ let parent = current.parent;
113
+
114
+ if (current === node || !parent) {
115
+ return false;
116
+ }
117
+
118
+ return (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.left === current) ||
119
+ ((ts.isForOfStatement(parent) || ts.isForInStatement(parent)) && parent.initializer === current);
120
+ }
121
+
122
+ function reference(ctx: TransformContext, node: ts.Identifier): void {
123
+ let parent = node.parent;
124
+
125
+ if (!parent || ts.isExportSpecifier(parent) || ts.isImportSpecifier(parent) || isName(node, parent)) {
126
+ return;
127
+ }
128
+
129
+ let binding = scope.resolve(ctx.bindings, node);
130
+
131
+ if (!binding || binding.type === TYPES.Array) {
132
+ return;
133
+ }
134
+
146
135
  if (
147
- ts.isIdentifier(node) &&
148
- node.parent &&
149
- !(ts.isVariableDeclaration(node.parent) && node.parent.name === node)
136
+ (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.left === node && ctx.isReactiveCall(parent.right)) ||
137
+ (ts.isTypeOfExpression(parent) && parent.expression === node) ||
138
+ isPatternTarget(node)
150
139
  ) {
151
- if (ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) {
152
- node.forEachChild(n => visit(ctx, n));
153
- return;
154
- }
140
+ return;
141
+ }
155
142
 
156
- let bindings = ctx.scopedBindings,
157
- binding,
158
- name = node.text;
143
+ let name = node.text;
159
144
 
160
- for (let i = 0, n = bindings.length; i < n; i++) {
161
- let b = bindings[i];
145
+ if (ts.isShorthandPropertyAssignment(parent)) {
146
+ ctx.replacements.push({
147
+ generate: () => `${name}: ${NAMESPACE}.read(${name})`,
148
+ node: parent
149
+ });
162
150
 
163
- if (b.name === name && (!binding || b.depth >= binding.depth) && inScope(node, b)) {
164
- binding = b;
165
- }
151
+ return;
152
+ }
153
+
154
+ let writeCtx: 'compound' | 'increment' | 'simple' | undefined;
155
+
156
+ if (ts.isBinaryExpression(parent) && parent.left === node) {
157
+ let op = parent.operatorToken.kind;
158
+
159
+ if (op === ts.SyntaxKind.EqualsToken) {
160
+ writeCtx = 'simple';
161
+ }
162
+ else if (COMPOUND_OPERATORS.has(op)) {
163
+ writeCtx = 'compound';
166
164
  }
165
+ }
166
+ else if (ts.isPostfixUnaryExpression(parent) || ts.isPrefixUnaryExpression(parent)) {
167
+ let op = parent.operator;
167
168
 
168
- if (binding && node.parent) {
169
- let parent = node.parent;
170
-
171
- if (
172
- !(
173
- ts.isBinaryExpression(parent) &&
174
- parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
175
- ctx.isReactiveCall(parent.right)
176
- ) &&
177
- !(ts.isTypeOfExpression(parent) && parent.expression === node)
178
- ) {
179
- let writeCtx;
180
-
181
- if (ts.isBinaryExpression(parent) && parent.left === node) {
182
- let op = parent.operatorToken.kind;
183
-
184
- if (op === ts.SyntaxKind.EqualsToken) {
185
- writeCtx = 'simple';
186
- }
187
- else if (COMPOUND_OPERATORS.has(op)) {
188
- writeCtx = 'compound';
189
- }
190
- }
191
- else if (ts.isPostfixUnaryExpression(parent) || ts.isPrefixUnaryExpression(parent)) {
192
- let op = parent.operator;
169
+ if (op === ts.SyntaxKind.MinusMinusToken || op === ts.SyntaxKind.PlusPlusToken) {
170
+ writeCtx = 'increment';
171
+ }
172
+ }
193
173
 
194
- if (op === ts.SyntaxKind.MinusMinusToken || op === ts.SyntaxKind.PlusPlusToken) {
195
- writeCtx = 'increment';
196
- }
197
- }
174
+ if (!writeCtx) {
175
+ ctx.replacements.push({
176
+ generate: () => `${NAMESPACE}.read(${name})`,
177
+ node
178
+ });
198
179
 
199
- if (writeCtx) {
200
- if (binding.type !== TYPES.Computed) {
201
- if (writeCtx === 'simple' && ts.isBinaryExpression(parent)) {
202
- let right = parent.right;
203
-
204
- ctx.replacements.push({
205
- generate: (sf) => `${NAMESPACE}.write(${name}, ${right.getText(sf)})`,
206
- node: parent
207
- });
208
- }
209
- else if (writeCtx === 'compound' && ts.isBinaryExpression(parent)) {
210
- let op = COMPOUND_OPERATORS.get(parent.operatorToken.kind) ?? '+',
211
- right = parent.right;
212
-
213
- ctx.replacements.push({
214
- generate: (sf) => `${NAMESPACE}.write(${name}, ${name}.value ${op} ${right.getText(sf)})`,
215
- node: parent
216
- });
217
- }
218
- else if (writeCtx === 'increment') {
219
- let delta = (parent as ts.PostfixUnaryExpression | ts.PrefixUnaryExpression).operator === ts.SyntaxKind.PlusPlusToken ? '+ 1' : '- 1',
220
- isPrefix = ts.isPrefixUnaryExpression(parent);
221
-
222
- if (ts.isExpressionStatement(parent.parent)) {
223
- ctx.replacements.push({
224
- generate: () => `${NAMESPACE}.write(${name}, ${name}.value ${delta})`,
225
- node: parent
226
- });
227
- }
228
- else if (isPrefix) {
229
- ctx.replacements.push({
230
- generate: () => `(${NAMESPACE}.write(${name}, ${name}.value ${delta}), ${name}.value)`,
231
- node: parent
232
- });
233
- }
234
- else {
235
- let tmp = `_t${ctx.tmpCounter++}`;
236
-
237
- ctx.replacements.push({
238
- generate: () => `((${tmp}) => (${NAMESPACE}.write(${name}, ${tmp} ${delta}), ${tmp}))(${name}.value)`,
239
- node: parent
240
- });
241
- }
242
- }
243
- }
244
- }
245
- else {
246
- ctx.replacements.push({
247
- generate: () => `${NAMESPACE}.read(${name})`,
248
- node
249
- });
250
- }
251
- }
180
+ return;
181
+ }
182
+
183
+ if (binding.type === TYPES.Computed) {
184
+ return;
185
+ }
186
+
187
+ if (writeCtx === 'simple' && ts.isBinaryExpression(parent)) {
188
+ let right = parent.right;
189
+
190
+ ctx.replacements.push({
191
+ generate: (sf) => `${NAMESPACE}.write(${name}, ${right.getText(sf)})`,
192
+ node: parent
193
+ });
194
+ }
195
+ else if (writeCtx === 'compound' && ts.isBinaryExpression(parent)) {
196
+ let op = COMPOUND_OPERATORS.get(parent.operatorToken.kind) ?? '+',
197
+ right = parent.right;
198
+
199
+ ctx.replacements.push({
200
+ generate: (sf) => `${NAMESPACE}.write(${name}, ${name}.value ${op} ${right.getText(sf)})`,
201
+ node: parent
202
+ });
203
+ }
204
+ else if (writeCtx === 'increment') {
205
+ let unary = parent as ts.PostfixUnaryExpression | ts.PrefixUnaryExpression,
206
+ delta = unary.operator === ts.SyntaxKind.PlusPlusToken ? '+ 1' : '- 1';
207
+
208
+ if (ts.isExpressionStatement(unary.parent)) {
209
+ ctx.replacements.push({
210
+ generate: () => `${NAMESPACE}.write(${name}, ${name}.value ${delta})`,
211
+ node: unary
212
+ });
213
+ }
214
+ else if (ts.isPrefixUnaryExpression(unary)) {
215
+ ctx.replacements.push({
216
+ generate: () => `(${NAMESPACE}.write(${name}, ${name}.value ${delta}), ${name}.value)`,
217
+ node: unary
218
+ });
252
219
  }
220
+ else {
221
+ let tmp = `_t${ctx.tmpCounter++}`;
222
+
223
+ ctx.replacements.push({
224
+ generate: () => `((${tmp}) => (${NAMESPACE}.write(${name}, ${tmp} ${delta}), ${tmp}))(${name}.value)`,
225
+ node: unary
226
+ });
227
+ }
228
+ }
229
+ }
230
+
231
+ // Pass 2: rewrite reads and writes of every signal/computed binding. Type positions never read a
232
+ // value, so they are not walked at all.
233
+ function rewrite(ctx: TransformContext, node: ts.Node): void {
234
+ if (ts.isTypeNode(node)) {
235
+ return;
236
+ }
237
+
238
+ if (ts.isIdentifier(node)) {
239
+ reference(ctx, node);
240
+
241
+ return;
242
+ }
243
+
244
+ node.forEachChild(child => rewrite(ctx, child));
245
+ }
246
+
247
+ // The variable a reactive() call initializes: `let x = reactive(...)` or `x = reactive(...)`
248
+ function targetOf(call: ts.CallExpression): ts.Identifier | null {
249
+ let parent = call.parent;
250
+
251
+ if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
252
+ return parent.name;
253
+ }
254
+
255
+ if (parent && ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(parent.left)) {
256
+ return parent.left;
253
257
  }
254
258
 
255
- node.forEachChild(n => visit(ctx, n));
259
+ return null;
256
260
  }
257
261
 
258
262
 
@@ -262,12 +266,11 @@ export default (sourceFile: ts.SourceFile, bindings: Bindings, isReactiveCall: I
262
266
  calls: [],
263
267
  isReactiveCall,
264
268
  replacements: [],
265
- scopedBindings: [],
266
- sourceFile,
267
269
  tmpCounter: 0
268
270
  };
269
271
 
270
- visit(ctx, sourceFile);
272
+ declare(ctx, sourceFile);
273
+ rewrite(ctx, sourceFile);
271
274
 
272
275
  return { calls: ctx.calls, replacements: ctx.replacements };
273
276
  };
@@ -1,8 +1,13 @@
1
1
  import type { ts } from '@esportsplus/typescript';
2
- import { TYPES } from './constants';
2
+ import type { TYPES } from './constants';
3
3
 
4
4
 
5
- type Bindings = Map<string, TYPES>;
5
+ type Bindings = {
6
+ checker: ts.Checker;
7
+ names: Set<string>;
8
+ paths: Map<ts.Symbol, Set<string>>;
9
+ symbols: Map<ts.Symbol, TYPES>;
10
+ };
6
11
 
7
12
  type IsReactiveCall = (node: ts.Node) => node is ts.CallExpression;
8
13
 
package/src/constants.ts CHANGED
@@ -1,12 +1,12 @@
1
- const COMPUTED = Symbol('reactivity.computed');
2
-
3
1
  const PACKAGE_NAME = '@esportsplus/reactivity';
4
2
 
5
- const REACTIVE_ARRAY = Symbol('reactivity.reactive.array');
3
+ const COMPUTED = Symbol.for(`${PACKAGE_NAME}/computed`);
4
+
5
+ const REACTIVE_ARRAY = Symbol.for(`${PACKAGE_NAME}/reactive.array`);
6
6
 
7
- const REACTIVE_OBJECT = Symbol('reactivity.reactive.object');
7
+ const REACTIVE_OBJECT = Symbol.for(`${PACKAGE_NAME}/reactive.object`);
8
8
 
9
- const SIGNAL = Symbol('reactivity.signal');
9
+ const SIGNAL = Symbol.for(`${PACKAGE_NAME}/signal`);
10
10
 
11
11
  const STABILIZER_DEFERRED = 4;
12
12
 
@@ -240,14 +240,16 @@ class ReactiveArray<T> extends Array<T> {
240
240
  }
241
241
 
242
242
  pop() {
243
- let item = super.pop();
244
-
245
- if (item !== undefined) {
246
- dispose(item);
247
- write(this._length, this.length);
248
- this.dispatch('pop', { item });
243
+ if (this.length === 0) {
244
+ return undefined;
249
245
  }
250
246
 
247
+ let item = super.pop() as T;
248
+
249
+ dispose(item);
250
+ write(this._length, this.length);
251
+ this.dispatch('pop', { item });
252
+
251
253
  return item;
252
254
  }
253
255
 
@@ -273,14 +275,16 @@ class ReactiveArray<T> extends Array<T> {
273
275
  }
274
276
 
275
277
  shift() {
276
- let item = super.shift();
277
-
278
- if (item !== undefined) {
279
- dispose(item);
280
- write(this._length, this.length);
281
- this.dispatch('shift', { item });
278
+ if (this.length === 0) {
279
+ return undefined;
282
280
  }
283
281
 
282
+ let item = super.shift() as T;
283
+
284
+ dispose(item);
285
+ write(this._length, this.length);
286
+ this.dispatch('shift', { item });
287
+
284
288
  return item;
285
289
  }
286
290
 
@@ -326,7 +330,8 @@ class ReactiveArray<T> extends Array<T> {
326
330
  }
327
331
 
328
332
  splice(start: number, deleteCount: number = this.length, ...items: T[]) {
329
- let removed = super.splice(start, deleteCount, ...items);
333
+ let length = this.length,
334
+ removed = super.splice(start, deleteCount, ...items);
330
335
 
331
336
  if (items.length > 0 || removed.length > 0) {
332
337
  write(this._length, this.length);
@@ -335,7 +340,15 @@ class ReactiveArray<T> extends Array<T> {
335
340
  dispose(removed[i]);
336
341
  }
337
342
 
338
- this.dispatch('splice', { deleteCount, items, start });
343
+ // Listeners receive the position and count Array.prototype.splice actually applied
344
+ // (negative, fractional and out-of-range arguments resolved), never the raw arguments
345
+ let index = Math.trunc(start) || 0;
346
+
347
+ this.dispatch('splice', {
348
+ deleteCount: removed.length,
349
+ items,
350
+ start: index < 0 ? Math.max(length + index, 0) : Math.min(index, length)
351
+ });
339
352
  }
340
353
 
341
354
  return removed;
package/src/system.ts CHANGED
@@ -680,8 +680,10 @@ function makeAsyncComputed<T>(factory: Computed<Promise<T> | AsyncIterable<T> |
680
680
  else if (result != null && typeof (result as AsyncIterable<T>)[Symbol.asyncIterator] === 'function') {
681
681
  let it = (result as AsyncIterable<T>)[Symbol.asyncIterator]();
682
682
 
683
+ // Disposal leaves no consumer to observe a failing iterator cleanup; left unhandled, the
684
+ // rejection would crash hosts that treat unhandled rejections as fatal (Node's default)
683
685
  onCleanup(() => {
684
- it.return?.();
686
+ it.return?.().then(undefined, () => {});
685
687
  });
686
688
 
687
689
  let step = (r: IteratorResult<T>) => {
@@ -218,4 +218,46 @@ describe('asyncComputed AsyncIterable support', () => {
218
218
 
219
219
  expect(() => read(node)).toThrow('iterable step boom');
220
220
  });
221
+
222
+ it('a rejecting return() during disposal is not an unhandled rejection', async () => {
223
+ let unhandled: unknown[] = [],
224
+ listener = (reason: unknown) => {
225
+ unhandled.push(reason);
226
+ };
227
+
228
+ process.on('unhandledRejection', listener);
229
+
230
+ try {
231
+ let iterable = {
232
+ [Symbol.asyncIterator]: () => ({
233
+ next: () => new Promise<IteratorResult<number>>(() => {}),
234
+ return: () => Promise.reject(new Error('cleanup boom'))
235
+ })
236
+ },
237
+ node!: Computed<number | undefined>,
238
+ stopRoot!: VoidFunction;
239
+
240
+ root((dispose) => {
241
+ stopRoot = dispose;
242
+ node = computed(() => iterable);
243
+ });
244
+
245
+ let keeper = effect(() => {
246
+ read(node);
247
+ });
248
+
249
+ await tick();
250
+
251
+ keeper();
252
+ stopRoot();
253
+
254
+ await tick();
255
+ await new Promise((resolve) => setTimeout(resolve, 0));
256
+
257
+ expect(unhandled).toEqual([]);
258
+ }
259
+ finally {
260
+ process.off('unhandledRejection', listener);
261
+ }
262
+ });
221
263
  });