@esportsplus/reactivity 0.22.3 → 0.23.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 (56) hide show
  1. package/build/index.d.ts +1 -1
  2. package/build/index.js +1 -1
  3. package/build/reactive/array.d.ts +3 -0
  4. package/build/reactive/array.js +32 -2
  5. package/build/reactive/index.d.ts +17 -14
  6. package/build/reactive/index.js +7 -25
  7. package/build/system.js +1 -1
  8. package/build/transformer/core/detector.d.ts +2 -0
  9. package/build/transformer/core/detector.js +6 -0
  10. package/build/transformer/core/index.d.ts +10 -0
  11. package/build/transformer/core/index.js +55 -0
  12. package/build/transformer/core/transforms/auto-dispose.d.ts +3 -0
  13. package/build/transformer/core/transforms/auto-dispose.js +116 -0
  14. package/build/transformer/core/transforms/reactive-array.d.ts +4 -0
  15. package/build/transformer/core/transforms/reactive-array.js +89 -0
  16. package/build/transformer/core/transforms/reactive-object.d.ts +4 -0
  17. package/build/transformer/core/transforms/reactive-object.js +155 -0
  18. package/build/transformer/core/transforms/reactive-primitives.d.ts +4 -0
  19. package/build/transformer/core/transforms/reactive-primitives.js +325 -0
  20. package/build/transformer/core/transforms/utilities.d.ts +9 -0
  21. package/build/transformer/core/transforms/utilities.js +57 -0
  22. package/build/transformer/plugins/esbuild.d.ts +5 -0
  23. package/build/transformer/plugins/esbuild.js +30 -0
  24. package/build/transformer/plugins/tsc.d.ts +3 -0
  25. package/build/transformer/plugins/tsc.js +4 -0
  26. package/build/transformer/plugins/vite.d.ts +5 -0
  27. package/build/transformer/plugins/vite.js +28 -0
  28. package/build/types.d.ts +14 -4
  29. package/package.json +34 -3
  30. package/readme.md +276 -2
  31. package/src/constants.ts +1 -1
  32. package/src/index.ts +1 -1
  33. package/src/reactive/array.ts +49 -2
  34. package/src/reactive/index.ts +33 -57
  35. package/src/system.ts +14 -5
  36. package/src/transformer/core/detector.ts +12 -0
  37. package/src/transformer/core/index.ts +82 -0
  38. package/src/transformer/core/transforms/auto-dispose.ts +194 -0
  39. package/src/transformer/core/transforms/reactive-array.ts +140 -0
  40. package/src/transformer/core/transforms/reactive-object.ts +244 -0
  41. package/src/transformer/core/transforms/reactive-primitives.ts +459 -0
  42. package/src/transformer/core/transforms/utilities.ts +95 -0
  43. package/src/transformer/plugins/esbuild.ts +46 -0
  44. package/src/transformer/plugins/tsc.ts +8 -0
  45. package/src/transformer/plugins/vite.ts +41 -0
  46. package/src/types.ts +24 -5
  47. package/test/arrays.ts +146 -0
  48. package/test/effects.ts +168 -0
  49. package/test/index.ts +8 -0
  50. package/test/nested.ts +201 -0
  51. package/test/objects.ts +106 -0
  52. package/test/primitives.ts +171 -0
  53. package/test/vite.config.ts +40 -0
  54. package/build/reactive/object.d.ts +0 -7
  55. package/build/reactive/object.js +0 -79
  56. package/src/reactive/object.ts +0 -116
@@ -0,0 +1,194 @@
1
+ import { uid } from '@esportsplus/typescript/transformer';
2
+ import ts from 'typescript';
3
+ import { applyReplacements, Replacement } from './utilities';
4
+
5
+
6
+ const TRAILING_SEMICOLON = /;$/;
7
+
8
+
9
+ interface Disposable {
10
+ name: string;
11
+ type: 'effect' | 'reactive';
12
+ }
13
+
14
+ interface FunctionEdit {
15
+ cleanupBodyEnd: number;
16
+ cleanupBodyStart: number;
17
+ disposeCode: string;
18
+ effectsToCapture: { end: number; name: string; start: number }[];
19
+ }
20
+
21
+ interface VisitResult {
22
+ disposables: Disposable[];
23
+ effectsToCapture: { end: number; name: string; start: number }[];
24
+ returnStatement: ts.ReturnStatement | null;
25
+ }
26
+
27
+
28
+ function visitFunctionBody(
29
+ body: ts.Block,
30
+ parentBody: ts.Node
31
+ ): VisitResult {
32
+ let disposables: Disposable[] = [],
33
+ effectsToCapture: { end: number; name: string; start: number }[] = [],
34
+ returnStatement: ts.ReturnStatement | null = null;
35
+
36
+ function visit(n: ts.Node): void {
37
+ if (
38
+ ts.isVariableDeclaration(n) &&
39
+ ts.isIdentifier(n.name) &&
40
+ n.initializer &&
41
+ ts.isCallExpression(n.initializer) &&
42
+ ts.isIdentifier(n.initializer.expression) &&
43
+ n.initializer.expression.text === 'reactive'
44
+ ) {
45
+ disposables.push({ name: n.name.text, type: 'reactive' });
46
+ }
47
+
48
+ if (
49
+ ts.isCallExpression(n) &&
50
+ ts.isIdentifier(n.expression) &&
51
+ n.expression.text === 'effect'
52
+ ) {
53
+ if (ts.isVariableDeclaration(n.parent) && ts.isIdentifier(n.parent.name)) {
54
+ disposables.push({ name: n.parent.name.text, type: 'effect' });
55
+ }
56
+ else if (ts.isExpressionStatement(n.parent)) {
57
+ let name = uid('effect');
58
+
59
+ effectsToCapture.push({
60
+ end: n.parent.end,
61
+ name,
62
+ start: n.parent.pos
63
+ });
64
+
65
+ disposables.push({ name, type: 'effect' });
66
+ }
67
+ }
68
+
69
+ if (
70
+ ts.isReturnStatement(n) &&
71
+ n.expression &&
72
+ (ts.isArrowFunction(n.expression) || ts.isFunctionExpression(n.expression)) &&
73
+ n.parent === parentBody
74
+ ) {
75
+ returnStatement = n;
76
+ }
77
+
78
+ ts.forEachChild(n, visit);
79
+ }
80
+
81
+ visit(body);
82
+
83
+ return { disposables, effectsToCapture, returnStatement };
84
+ }
85
+
86
+ function processFunction(
87
+ node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
88
+ sourceFile: ts.SourceFile,
89
+ edits: FunctionEdit[]
90
+ ): void {
91
+ if (!node.body || !ts.isBlock(node.body)) {
92
+ return;
93
+ }
94
+
95
+ let result = visitFunctionBody(node.body, node.body),
96
+ disposables = result.disposables,
97
+ effectsToCapture = result.effectsToCapture,
98
+ returnStatement = result.returnStatement;
99
+
100
+ if (disposables.length === 0 || !returnStatement || !returnStatement.expression) {
101
+ return;
102
+ }
103
+
104
+ let cleanupFn = returnStatement.expression as ts.ArrowFunction | ts.FunctionExpression;
105
+
106
+ if (!cleanupFn.body) {
107
+ return;
108
+ }
109
+
110
+ let disposeStatements: string[] = [];
111
+
112
+ for (let i = disposables.length - 1; i >= 0; i--) {
113
+ let d = disposables[i];
114
+
115
+ if (d.type === 'reactive') {
116
+ disposeStatements.push(`${d.name}.dispose();`);
117
+ }
118
+ else {
119
+ disposeStatements.push(`${d.name}();`);
120
+ }
121
+ }
122
+
123
+ let disposeCode = disposeStatements.join('\n');
124
+
125
+ if (ts.isBlock(cleanupFn.body)) {
126
+ edits.push({
127
+ cleanupBodyEnd: cleanupFn.body.statements[0]?.pos ?? cleanupFn.body.end - 1,
128
+ cleanupBodyStart: cleanupFn.body.pos + 1,
129
+ disposeCode,
130
+ effectsToCapture
131
+ });
132
+ }
133
+ else {
134
+ edits.push({
135
+ cleanupBodyEnd: cleanupFn.body.end,
136
+ cleanupBodyStart: cleanupFn.body.pos,
137
+ disposeCode: `{ ${disposeCode}\n return ${cleanupFn.body.getText(sourceFile)}; }`,
138
+ effectsToCapture
139
+ });
140
+ }
141
+ }
142
+
143
+
144
+ const injectAutoDispose = (sourceFile: ts.SourceFile): string => {
145
+ let code = sourceFile.getFullText(),
146
+ edits: FunctionEdit[] = [];
147
+
148
+ function visit(node: ts.Node): void {
149
+ if (
150
+ ts.isFunctionDeclaration(node) ||
151
+ ts.isFunctionExpression(node) ||
152
+ ts.isArrowFunction(node)
153
+ ) {
154
+ processFunction(node, sourceFile, edits);
155
+ }
156
+
157
+ ts.forEachChild(node, visit);
158
+ }
159
+
160
+ visit(sourceFile);
161
+
162
+ if (edits.length === 0) {
163
+ return code;
164
+ }
165
+
166
+ let replacements: Replacement[] = [];
167
+
168
+ for (let i = 0, n = edits.length; i < n; i++) {
169
+ let edit = edits[i],
170
+ effects = edit.effectsToCapture;
171
+
172
+ for (let j = 0, m = effects.length; j < m; j++) {
173
+ let effect = effects[j],
174
+ original = code.substring(effect.start, effect.end).trim();
175
+
176
+ replacements.push({
177
+ end: effect.end,
178
+ newText: `const ${effect.name} = ${original.replace(TRAILING_SEMICOLON, '')}`,
179
+ start: effect.start
180
+ });
181
+ }
182
+
183
+ replacements.push({
184
+ end: edit.cleanupBodyEnd,
185
+ newText: `\n${edit.disposeCode}`,
186
+ start: edit.cleanupBodyStart
187
+ });
188
+ }
189
+
190
+ return applyReplacements(code, replacements);
191
+ };
192
+
193
+
194
+ export { injectAutoDispose };
@@ -0,0 +1,140 @@
1
+ import type { Bindings } from '~/types';
2
+ import ts from 'typescript';
3
+ import { applyReplacements, Replacement } from './utilities';
4
+
5
+
6
+ function getPropertyPath(node: ts.PropertyAccessExpression): string | null {
7
+ let current: ts.Node = node,
8
+ parts: string[] = [];
9
+
10
+ while (ts.isPropertyAccessExpression(current)) {
11
+ parts.unshift(current.name.text);
12
+ current = current.expression;
13
+ }
14
+
15
+ if (ts.isIdentifier(current)) {
16
+ parts.unshift(current.text);
17
+ return parts.join('.');
18
+ }
19
+
20
+ return null;
21
+ }
22
+
23
+ function getExpressionName(node: ts.Expression): string | null {
24
+ if (ts.isIdentifier(node)) {
25
+ return node.text;
26
+ }
27
+
28
+ if (ts.isPropertyAccessExpression(node)) {
29
+ return getPropertyPath(node);
30
+ }
31
+
32
+ return null;
33
+ }
34
+
35
+ function isAssignmentTarget(node: ts.Node): boolean {
36
+ let parent = node.parent;
37
+
38
+ if (
39
+ (ts.isBinaryExpression(parent) && parent.left === node) ||
40
+ ts.isPostfixUnaryExpression(parent) ||
41
+ ts.isPrefixUnaryExpression(parent)
42
+ ) {
43
+ return true;
44
+ }
45
+
46
+ return false;
47
+ }
48
+
49
+
50
+ const transformReactiveArrays = (
51
+ sourceFile: ts.SourceFile,
52
+ bindings: Bindings
53
+ ): string => {
54
+ let code = sourceFile.getFullText(),
55
+ replacements: Replacement[] = [];
56
+
57
+ // Single-pass visitor: collect bindings and find replacements together
58
+ function visit(node: ts.Node): void {
59
+ // Collect array bindings from variable declarations
60
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
61
+ if (ts.isIdentifier(node.initializer) && bindings.get(node.initializer.text) === 'array') {
62
+ bindings.set(node.name.text, 'array');
63
+ }
64
+
65
+ if (ts.isPropertyAccessExpression(node.initializer)) {
66
+ let path = getPropertyPath(node.initializer);
67
+
68
+ if (path && bindings.get(path) === 'array') {
69
+ bindings.set(node.name.text, 'array');
70
+ }
71
+ }
72
+ }
73
+
74
+ // Collect array bindings from function parameters
75
+ if ((ts.isFunctionDeclaration(node) || ts.isArrowFunction(node)) && node.parameters) {
76
+ for (let i = 0, n = node.parameters.length; i < n; i++) {
77
+ let param = node.parameters[i];
78
+
79
+ if (
80
+ (ts.isIdentifier(param.name) && param.type) &&
81
+ ts.isTypeReferenceNode(param.type) &&
82
+ ts.isIdentifier(param.type.typeName) &&
83
+ param.type.typeName.text === 'ReactiveArray'
84
+ ) {
85
+ bindings.set(param.name.text, 'array');
86
+ }
87
+ }
88
+ }
89
+
90
+ // Find .length access replacements
91
+ if (
92
+ ts.isPropertyAccessExpression(node) &&
93
+ node.name.text === 'length' &&
94
+ !isAssignmentTarget(node)
95
+ ) {
96
+ let objName = getExpressionName(node.expression);
97
+
98
+ if (objName && bindings.get(objName) === 'array') {
99
+ let objText = node.expression.getText(sourceFile);
100
+
101
+ replacements.push({
102
+ end: node.end,
103
+ newText: `${objText}.$length()`,
104
+ start: node.pos
105
+ });
106
+ }
107
+ }
108
+
109
+ // Find array[i] = value replacements
110
+ if (
111
+ ts.isBinaryExpression(node) &&
112
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
113
+ ts.isElementAccessExpression(node.left)
114
+ ) {
115
+ let elemAccess = node.left,
116
+ objName = getExpressionName(elemAccess.expression);
117
+
118
+ if (objName && bindings.get(objName) === 'array') {
119
+ let indexText = elemAccess.argumentExpression.getText(sourceFile),
120
+ objText = elemAccess.expression.getText(sourceFile),
121
+ valueText = node.right.getText(sourceFile);
122
+
123
+ replacements.push({
124
+ end: node.end,
125
+ newText: `${objText}.$set(${indexText}, ${valueText})`,
126
+ start: node.pos
127
+ });
128
+ }
129
+ }
130
+
131
+ ts.forEachChild(node, visit);
132
+ }
133
+
134
+ visit(sourceFile);
135
+
136
+ return applyReplacements(code, replacements);
137
+ };
138
+
139
+
140
+ export { transformReactiveArrays };
@@ -0,0 +1,244 @@
1
+ import type { Bindings } from '~/types';
2
+ import { uid } from '@esportsplus/typescript/transformer';
3
+ import ts from 'typescript';
4
+ import { addMissingImports, applyReplacements, ExtraImport, Replacement } from './utilities';
5
+
6
+
7
+ const CLASS_NAME_REGEX = /class (\w+)/;
8
+
9
+ const EXTRA_IMPORTS: ExtraImport[] = [
10
+ { module: '@esportsplus/reactivity/constants', specifier: 'REACTIVE_OBJECT' },
11
+ { module: '@esportsplus/reactivity/reactive/array', specifier: 'ReactiveArray' }
12
+ ];
13
+
14
+
15
+ interface AnalyzedProperty {
16
+ key: string;
17
+ type: 'array' | 'computed' | 'signal';
18
+ valueText: string;
19
+ }
20
+
21
+ interface ReactiveObjectCall {
22
+ end: number;
23
+ generatedClass: string;
24
+ needsImports: Set<string>;
25
+ start: number;
26
+ varName: string | null;
27
+ }
28
+
29
+
30
+
31
+ function analyzeProperty(prop: ts.ObjectLiteralElementLike, sourceFile: ts.SourceFile): AnalyzedProperty | null {
32
+ if (!ts.isPropertyAssignment(prop)) {
33
+ return null;
34
+ }
35
+
36
+ let key: string;
37
+
38
+ if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) {
39
+ key = prop.name.text;
40
+ }
41
+ else {
42
+ return null;
43
+ }
44
+
45
+ let value = prop.initializer,
46
+ valueText = value.getText(sourceFile);
47
+
48
+ if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) {
49
+ return { key, type: 'computed', valueText };
50
+ }
51
+
52
+ if (ts.isArrayLiteralExpression(value)) {
53
+ return { key, type: 'array', valueText };
54
+ }
55
+
56
+ return { key, type: 'signal', valueText };
57
+ }
58
+
59
+ function buildClassCode(className: string, properties: AnalyzedProperty[]): string {
60
+ let accessors: string[] = [],
61
+ disposeStatements: string[] = [],
62
+ fields: string[] = [];
63
+
64
+ fields.push(`[REACTIVE_OBJECT] = true;`);
65
+
66
+ for (let i = 0, n = properties.length; i < n; i++) {
67
+ let { key, type, valueText } = properties[i];
68
+
69
+ if (type === 'signal') {
70
+ let param = uid('v');
71
+
72
+ fields.push(`#${key} = signal(${valueText});`);
73
+ accessors.push(`get ${key}() { return read(this.#${key}); }`);
74
+ accessors.push(`set ${key}(${param}) { set(this.#${key}, ${param}); }`);
75
+ }
76
+ else if (type === 'array') {
77
+ let elements = valueText.slice(1, -1);
78
+
79
+ fields.push(`${key} = new ReactiveArray(${elements});`);
80
+ disposeStatements.push(`this.${key}.dispose();`);
81
+ }
82
+ else if (type === 'computed') {
83
+ fields.push(`#${key}: Computed<unknown> | null = null;`);
84
+ accessors.push(`get ${key}() { return read(this.#${key} ??= computed(${valueText})); }`);
85
+ disposeStatements.push(`if (this.#${key}) dispose(this.#${key});`);
86
+ }
87
+ }
88
+
89
+ let disposeBody = disposeStatements.length > 0 ? disposeStatements.join('\n') : '';
90
+
91
+ return `
92
+ class ${className} {
93
+ ${fields.join('\n')}
94
+ ${accessors.join('\n')}
95
+
96
+ dispose() {
97
+ ${disposeBody}
98
+ }
99
+ }
100
+ `;
101
+ }
102
+
103
+
104
+ const transformReactiveObjects = (sourceFile: ts.SourceFile, bindings: Bindings): string => {
105
+ let allNeededImports = new Set<string>(),
106
+ calls: ReactiveObjectCall[] = [],
107
+ code = sourceFile.getFullText(),
108
+ hasReactiveImport = false,
109
+ lastImportEnd = 0;
110
+
111
+ function visit(node: ts.Node): void {
112
+ // Track imports (always at top of file)
113
+ if (ts.isImportDeclaration(node)) {
114
+ lastImportEnd = node.end;
115
+
116
+ if (
117
+ ts.isStringLiteral(node.moduleSpecifier) &&
118
+ node.moduleSpecifier.text.includes('@esportsplus/reactivity')
119
+ ) {
120
+ let clause = node.importClause;
121
+
122
+ if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
123
+ let elements = clause.namedBindings.elements;
124
+
125
+ for (let i = 0, n = elements.length; i < n; i++) {
126
+ if (elements[i].name.text === 'reactive') {
127
+ hasReactiveImport = true;
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ // Process reactive() calls (only if import was found)
136
+ if (
137
+ hasReactiveImport &&
138
+ ts.isCallExpression(node) &&
139
+ ts.isIdentifier(node.expression) &&
140
+ node.expression.text === 'reactive'
141
+ ) {
142
+
143
+ let arg = node.arguments[0];
144
+
145
+ if (arg && ts.isObjectLiteralExpression(arg)) {
146
+ let varName: string | null = null;
147
+
148
+ if (ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)) {
149
+ varName = node.parent.name.text;
150
+ bindings.set(varName, 'object');
151
+ }
152
+
153
+ let needsImports = new Set<string>(),
154
+ properties: AnalyzedProperty[] = [];
155
+
156
+ needsImports.add('REACTIVE_OBJECT');
157
+
158
+ let props = arg.properties;
159
+
160
+ for (let i = 0, n = props.length; i < n; i++) {
161
+ let prop = props[i];
162
+
163
+ if (ts.isSpreadAssignment(prop)) {
164
+ return;
165
+ }
166
+
167
+ let analyzed = analyzeProperty(prop, sourceFile);
168
+
169
+ if (!analyzed) {
170
+ return;
171
+ }
172
+
173
+ properties.push(analyzed);
174
+
175
+ if (analyzed.type === 'signal') {
176
+ needsImports.add('read');
177
+ needsImports.add('set');
178
+ needsImports.add('signal');
179
+ }
180
+ else if (analyzed.type === 'array') {
181
+ needsImports.add('ReactiveArray');
182
+
183
+ if (varName) {
184
+ bindings.set(`${varName}.${analyzed.key}`, 'array');
185
+ }
186
+ }
187
+ else if (analyzed.type === 'computed') {
188
+ needsImports.add('computed');
189
+ needsImports.add('dispose');
190
+ needsImports.add('read');
191
+ }
192
+ }
193
+
194
+ needsImports.forEach(imp => allNeededImports.add(imp));
195
+
196
+ calls.push({
197
+ end: node.end,
198
+ generatedClass: buildClassCode(uid('ReactiveObject'), properties),
199
+ needsImports,
200
+ start: node.pos,
201
+ varName
202
+ });
203
+ }
204
+ }
205
+
206
+ ts.forEachChild(node, visit);
207
+ }
208
+
209
+ visit(sourceFile);
210
+
211
+ if (calls.length === 0) {
212
+ return code;
213
+ }
214
+
215
+ let replacements: Replacement[] = [];
216
+
217
+ // Insert generated classes after imports
218
+ replacements.push({
219
+ end: lastImportEnd,
220
+ newText: code.substring(0, lastImportEnd) + '\n' + calls.map(c => c.generatedClass).join('\n') + '\n',
221
+ start: 0
222
+ });
223
+
224
+ // Replace each reactive() call with new ClassName()
225
+ for (let i = 0, n = calls.length; i < n; i++) {
226
+ let call = calls[i],
227
+ classMatch = call.generatedClass.match(CLASS_NAME_REGEX);
228
+
229
+ replacements.push({
230
+ end: call.end,
231
+ newText: ` new ${classMatch ? classMatch[1] : 'ReactiveObject'}()`,
232
+ start: call.start
233
+ });
234
+ }
235
+
236
+ return addMissingImports(
237
+ applyReplacements(code, replacements),
238
+ allNeededImports,
239
+ EXTRA_IMPORTS
240
+ );
241
+ };
242
+
243
+
244
+ export { transformReactiveObjects };