ripple 0.3.6 → 0.3.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # ripple
2
2
 
3
+ ## 0.3.7
4
+
5
+ ### Patch Changes
6
+
7
+ - [#832](https://github.com/Ripple-TS/ripple/pull/832)
8
+ [`9ca9310`](https://github.com/Ripple-TS/ripple/commit/9ca9310550a800f4435821ed84b24bdd4f243117)
9
+ Thanks [@trueadm](https://github.com/trueadm)! - Fix lazy array rest
10
+ destructuring for tracked and array-like values by routing rest extraction
11
+ through a shared `array_slice` helper instead of calling `.slice()` directly on
12
+ the source.
13
+
14
+ - [#832](https://github.com/Ripple-TS/ripple/pull/832)
15
+ [`9ca9310`](https://github.com/Ripple-TS/ripple/commit/9ca9310550a800f4435821ed84b24bdd4f243117)
16
+ Thanks [@trueadm](https://github.com/trueadm)! - Allow tracked tuple `.length`
17
+ member access in compiler analysis and simplify tracked direct-access validation
18
+ into a single combined condition.
19
+
20
+ - [#832](https://github.com/Ripple-TS/ripple/pull/832)
21
+ [`9ca9310`](https://github.com/Ripple-TS/ripple/commit/9ca9310550a800f4435821ed84b24bdd4f243117)
22
+ Thanks [@trueadm](https://github.com/trueadm)! - Fix `to_ts` output for lazy
23
+ array destructuring so it keeps direct destructuring syntax for `track()` and
24
+ `trackSplit()` instead of expanding through an intermediate `lazy` variable.
25
+
26
+ - [#832](https://github.com/Ripple-TS/ripple/pull/832)
27
+ [`9ca9310`](https://github.com/Ripple-TS/ripple/commit/9ca9310550a800f4435821ed84b24bdd4f243117)
28
+ Thanks [@trueadm](https://github.com/trueadm)! - Replace tracked `get()`/`set()`
29
+ APIs with a `value` getter/setter across runtime, types, analyzer tracked-access
30
+ rules, and lazy destructuring tests.
31
+
32
+ - Updated dependencies
33
+ [[`9ca9310`](https://github.com/Ripple-TS/ripple/commit/9ca9310550a800f4435821ed84b24bdd4f243117),
34
+ [`9ca9310`](https://github.com/Ripple-TS/ripple/commit/9ca9310550a800f4435821ed84b24bdd4f243117),
35
+ [`9ca9310`](https://github.com/Ripple-TS/ripple/commit/9ca9310550a800f4435821ed84b24bdd4f243117),
36
+ [`9ca9310`](https://github.com/Ripple-TS/ripple/commit/9ca9310550a800f4435821ed84b24bdd4f243117)]:
37
+ - ripple@0.3.7
38
+
3
39
  ## 0.3.6
4
40
 
5
41
  ### Patch Changes
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Ripple is an elegant TypeScript UI framework",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.3.6",
6
+ "version": "0.3.7",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -105,6 +105,6 @@
105
105
  "vscode-languageserver-types": "^3.17.5"
106
106
  },
107
107
  "peerDependencies": {
108
- "ripple": "0.3.6"
108
+ "ripple": "0.3.7"
109
109
  }
110
110
  }
@@ -76,8 +76,16 @@ function mark_control_flow_has_template(path) {
76
76
  * @param {AST.Identifier} source_id - The identifier to access properties on
77
77
  * @param {AnalysisState} state - The analysis state
78
78
  * @param {boolean} writable - Whether assignments/updates should be supported (let vs const)
79
+ * @param {boolean} is_track_call - Whether the RHS is a Ripple track() call
79
80
  */
80
- function setup_lazy_transforms(pattern, source_id, state, writable) {
81
+ function setup_lazy_transforms(pattern, source_id, state, writable, is_track_call) {
82
+ // For ArrayPattern from track() calls, use direct get/set calls as a fast path
83
+ // instead of going through prototype getters source[0]/source[1]
84
+ if (pattern.type === 'ArrayPattern' && is_track_call) {
85
+ setup_lazy_array_transforms(pattern, source_id, state, writable);
86
+ return;
87
+ }
88
+
81
89
  const paths = extract_paths(pattern);
82
90
 
83
91
  for (const path of paths) {
@@ -141,6 +149,185 @@ function setup_lazy_transforms(pattern, source_id, state, writable) {
141
149
  }
142
150
  }
143
151
 
152
+ /**
153
+ * Set up fast-path transforms for lazy array destructuring of tracked values.
154
+ * For index 0 (the value): uses _$_.get/set/update directly instead of source[0] getters.
155
+ * For index 1 (the tracked ref): returns source directly instead of source[1].
156
+ * @param {AST.ArrayPattern} pattern - The array destructuring pattern
157
+ * @param {AST.Identifier} source_id - The identifier for the tracked value
158
+ * @param {AnalysisState} state - The analysis state
159
+ * @param {boolean} writable - Whether assignments/updates should be supported
160
+ */
161
+ function setup_lazy_array_transforms(pattern, source_id, state, writable) {
162
+ for (let i = 0; i < pattern.elements.length; i++) {
163
+ const element = pattern.elements[i];
164
+ if (!element) continue;
165
+
166
+ // Rest elements — fall back to generic source.slice(i)
167
+ if (element.type === 'RestElement') {
168
+ const rest_paths = extract_paths(pattern);
169
+ for (const path of rest_paths) {
170
+ if (!path.is_rest) continue;
171
+ const name = /** @type {AST.Identifier} */ (path.node).name;
172
+ const binding = state.scope.get(name);
173
+ if (binding !== null) {
174
+ binding.kind = path.has_default_value ? 'lazy_fallback' : 'lazy';
175
+ binding.transform = {
176
+ read: (_) => path.expression(source_id),
177
+ };
178
+ }
179
+ }
180
+ continue;
181
+ }
182
+
183
+ const actual = element.type === 'AssignmentPattern' ? element.left : element;
184
+ const has_fallback = element.type === 'AssignmentPattern';
185
+ const fallback_value = has_fallback
186
+ ? /** @type {AST.AssignmentPattern} */ (element).right
187
+ : null;
188
+
189
+ if (actual.type === 'Identifier' && i <= 1) {
190
+ const name = actual.name;
191
+ const binding = state.scope.get(name);
192
+ if (binding === null) continue;
193
+
194
+ binding.kind = has_fallback ? 'lazy_fallback' : 'lazy';
195
+
196
+ if (i === 0) {
197
+ // Fast path for index 0: use _$_.get(source) instead of source[0]
198
+ const read_expr = has_fallback
199
+ ? () => b.call('_$_.fallback', b.call('_$_.get', source_id), fallback_value)
200
+ : () => b.call('_$_.get', source_id);
201
+
202
+ // Signal that read already produces an unwrapped value (calls _$_.get internally)
203
+ binding.read_unwraps = true;
204
+
205
+ binding.transform = {
206
+ read: (_) => read_expr(),
207
+ };
208
+
209
+ if (writable) {
210
+ binding.transform.assign = (_, value) => {
211
+ return b.call('_$_.set', source_id, value);
212
+ };
213
+
214
+ if (has_fallback) {
215
+ binding.transform.update = (node) => {
216
+ const delta = node.operator === '++' ? b.literal(1) : b.literal(-1);
217
+ const temp = b.id('_v');
218
+
219
+ if (node.prefix) {
220
+ // ++count: compute new value and set it, return new value
221
+ return b.call(
222
+ b.arrow(
223
+ [],
224
+ b.block([
225
+ b.var(temp, b.binary('+', read_expr(), delta)),
226
+ b.stmt(b.call('_$_.set', source_id, temp)),
227
+ b.return(temp),
228
+ ]),
229
+ ),
230
+ );
231
+ } else {
232
+ // count++: read old value, set new value, return old value
233
+ return b.call(
234
+ b.arrow(
235
+ [],
236
+ b.block([
237
+ b.var(temp, read_expr()),
238
+ b.stmt(b.call('_$_.set', source_id, b.binary('+', temp, delta))),
239
+ b.return(temp),
240
+ ]),
241
+ ),
242
+ );
243
+ }
244
+ };
245
+ } else {
246
+ binding.transform.update = (node) => {
247
+ const fn_name = node.prefix ? '_$_.update_pre' : '_$_.update';
248
+ const args = [source_id];
249
+ if (node.operator === '--') {
250
+ args.push(b.literal(-1));
251
+ }
252
+ return b.call(fn_name, ...args);
253
+ };
254
+ }
255
+ }
256
+ } else {
257
+ // Fast path for index 1: source itself is the tracked ref
258
+ binding.transform = {
259
+ read: (_) => source_id,
260
+ };
261
+ }
262
+ } else {
263
+ // Nested patterns or indices > 1: fall back to generic source[i] access via extract_paths
264
+ /** @type {(object: AST.Expression) => AST.Expression} */
265
+ const base_expression =
266
+ i === 0
267
+ ? (object) => b.call('_$_.get', object)
268
+ : i === 1
269
+ ? (object) => object
270
+ : (object) => b.member(object, b.literal(i), true);
271
+
272
+ const inner_paths = extract_paths(element);
273
+ for (const path of inner_paths) {
274
+ const name = /** @type {AST.Identifier} */ (path.node).name;
275
+ const binding = state.scope.get(name);
276
+ if (binding === null) continue;
277
+
278
+ binding.kind = path.has_default_value ? 'lazy_fallback' : 'lazy';
279
+
280
+ binding.transform = {
281
+ read: (_) => path.expression(base_expression(source_id)),
282
+ };
283
+
284
+ if (writable) {
285
+ binding.transform.assign = (node, value) => {
286
+ return b.assignment(
287
+ '=',
288
+ /** @type {AST.MemberExpression} */ (
289
+ path.update_expression(base_expression(source_id))
290
+ ),
291
+ value,
292
+ );
293
+ };
294
+
295
+ if (path.has_default_value) {
296
+ binding.transform.update = (node) => {
297
+ const member = path.update_expression(base_expression(source_id));
298
+ const fallback_read = path.expression(base_expression(source_id));
299
+ const delta = node.operator === '++' ? b.literal(1) : b.literal(-1);
300
+
301
+ if (node.prefix) {
302
+ return b.assignment('=', member, b.binary('+', fallback_read, delta));
303
+ } else {
304
+ const temp = b.id('_v');
305
+ return b.call(
306
+ b.arrow(
307
+ [],
308
+ b.block([
309
+ b.var(temp, fallback_read),
310
+ b.stmt(b.assignment('=', member, b.binary('+', temp, delta))),
311
+ b.return(temp),
312
+ ]),
313
+ ),
314
+ );
315
+ }
316
+ };
317
+ } else {
318
+ binding.transform.update = (node) =>
319
+ b.update(
320
+ node.operator,
321
+ path.update_expression(base_expression(source_id)),
322
+ node.prefix,
323
+ );
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ }
330
+
144
331
  /**
145
332
  * @param {AST.Function} node
146
333
  * @param {AnalysisContext} context
@@ -158,7 +345,7 @@ function visit_function(node, context) {
158
345
 
159
346
  if ((param.type === 'ObjectPattern' || param.type === 'ArrayPattern') && param.lazy) {
160
347
  const param_id = b.id(context.state.scope.generate('param'));
161
- setup_lazy_transforms(param, param_id, context.state, true);
348
+ setup_lazy_transforms(param, param_id, context.state, true, false);
162
349
  // Store the generated identifier name on the pattern for the transform phase
163
350
  param.metadata = { ...param.metadata, lazy_id: param_id.name };
164
351
  }
@@ -391,6 +578,21 @@ const visitors = {
391
578
  }
392
579
  }
393
580
 
581
+ // Lazy bindings from track() calls (read_unwraps) are inherently reactive —
582
+ // propagate tracking even without the @ prefix so that control flow (if/for/switch)
583
+ // and early returns create reactive blocks
584
+ if (
585
+ !node.tracked &&
586
+ binding?.read_unwraps &&
587
+ is_reference(node, /** @type {AST.Node} */ (parent)) &&
588
+ binding.node !== node
589
+ ) {
590
+ mark_as_tracked(context.path);
591
+ if (context.state.metadata?.tracking === false) {
592
+ context.state.metadata.tracking = true;
593
+ }
594
+ }
595
+
394
596
  context.next();
395
597
  },
396
598
 
@@ -485,13 +687,27 @@ const visitors = {
485
687
  binding.initial?.type === 'CallExpression' &&
486
688
  is_ripple_track_call(binding.initial.callee, context)
487
689
  ) {
488
- error(
489
- `Accessing a tracked object directly is not allowed, use the \`@\` prefix to read the value inside a tracked object - for example \`@${node.object.name}${node.property.type === 'Identifier' ? `.${node.property.name}` : ''}\``,
490
- context.state.analysis.module.filename,
491
- node.object,
492
- context.state.loose ? context.state.analysis.errors : undefined,
493
- context.state.analysis.comments,
494
- );
690
+ const is_allowed_tracked_access =
691
+ // Allow [0] and [1] indexed access on tracked objects.
692
+ (node.computed &&
693
+ node.property.type === 'Literal' &&
694
+ (node.property.value === 0 || node.property.value === 1)) ||
695
+ // Allow .value and .length property access on tracked objects.
696
+ (!node.computed &&
697
+ node.property.type === 'Identifier' &&
698
+ (node.property.name === 'value' || node.property.name === 'length'));
699
+
700
+ if (is_allowed_tracked_access) {
701
+ // pass through
702
+ } else {
703
+ error(
704
+ `Accessing a tracked object directly is not allowed, use the \`@\` prefix to read the value inside a tracked object - for example \`@${node.object.name}${node.property.type === 'Identifier' ? `.${node.property.name}` : ''}\``,
705
+ context.state.analysis.module.filename,
706
+ node.object,
707
+ context.state.loose ? context.state.analysis.errors : undefined,
708
+ context.state.analysis.comments,
709
+ );
710
+ }
495
711
  }
496
712
  }
497
713
 
@@ -570,7 +786,10 @@ const visitors = {
570
786
  ) {
571
787
  const lazy_id = b.id(state.scope.generate('lazy'));
572
788
  const writable = node.kind !== 'const';
573
- setup_lazy_transforms(declarator.id, lazy_id, state, writable);
789
+ const init_is_track =
790
+ declarator.init?.type === 'CallExpression' &&
791
+ is_ripple_track_call(declarator.init.callee, context) === 'track';
792
+ setup_lazy_transforms(declarator.id, lazy_id, state, writable, !!init_is_track);
574
793
  // Store the generated identifier name on the pattern for the transform phase
575
794
  declarator.id.metadata = { ...declarator.id.metadata, lazy_id: lazy_id.name };
576
795
  }
@@ -651,7 +870,7 @@ const visitors = {
651
870
 
652
871
  if ((props.type === 'ObjectPattern' || props.type === 'ArrayPattern') && props.lazy) {
653
872
  // Lazy destructuring: &{...} or &[...] — set up lazy transforms
654
- setup_lazy_transforms(props, b.id('__props'), context.state, true);
873
+ setup_lazy_transforms(props, b.id('__props'), context.state, true, false);
655
874
  } else if (props.type === 'AssignmentPattern') {
656
875
  error(
657
876
  'Props are always an object, use destructured props with default values instead',
@@ -533,7 +533,7 @@ const visitors = {
533
533
  if (context.state.metadata?.tracking === false) {
534
534
  context.state.metadata.tracking = true;
535
535
  }
536
- if (node.tracked) {
536
+ if (node.tracked && !binding?.read_unwraps) {
537
537
  return b.call('_$_.get', build_getter(node, context));
538
538
  }
539
539
  }
@@ -634,17 +634,9 @@ const visitors = {
634
634
  }
635
635
  }
636
636
 
637
- if (!context.state.to_ts && is_ripple_track_call(callee, context)) {
638
- const track_method_name =
639
- callee.type === 'Identifier'
640
- ? callee.name === 'trackSplit'
641
- ? 'track_split'
642
- : 'track'
643
- : callee.type === 'MemberExpression' && callee.property.type === 'Identifier'
644
- ? callee.property.name === 'trackSplit'
645
- ? 'track_split'
646
- : 'track'
647
- : 'track';
637
+ const matched_track_call = !context.state.to_ts ? is_ripple_track_call(callee, context) : null;
638
+ if (matched_track_call) {
639
+ const track_method_name = matched_track_call === 'trackSplit' ? 'track_split' : 'track';
648
640
 
649
641
  if (callee.type === 'Identifier' && callee.name === 'track') {
650
642
  if (node.arguments.length === 0) {
@@ -943,6 +935,20 @@ const visitors = {
943
935
  }
944
936
  }
945
937
 
938
+ if (context.state.to_ts) {
939
+ for (const declarator of node.declarations) {
940
+ if (
941
+ (declarator.id.type === 'ObjectPattern' || declarator.id.type === 'ArrayPattern') &&
942
+ declarator.id.lazy
943
+ ) {
944
+ declarator.id.lazy = false;
945
+ if (declarator.id.metadata?.lazy_id) {
946
+ delete declarator.id.metadata.lazy_id;
947
+ }
948
+ }
949
+ }
950
+ }
951
+
946
952
  return context.next();
947
953
  },
948
954
 
@@ -2019,6 +2025,24 @@ const visitors = {
2019
2025
 
2020
2026
  const left = node.left;
2021
2027
 
2028
+ // Handle lazy binding assignments (e.g., value = 5 where value is from let &[value] = track(0))
2029
+ // Must come before the left.tracked check to use the binding's transform
2030
+ if (left.type === 'Identifier') {
2031
+ const binding = context.state.scope?.get(left.name);
2032
+ if (binding?.transform?.assign && binding.node !== left) {
2033
+ let value = /** @type {AST.Expression} */ (context.visit(node.right));
2034
+
2035
+ // For compound operators (+=, -=, *=, /=), expand to read + operation
2036
+ if (node.operator !== '=') {
2037
+ const operator = node.operator.slice(0, -1); // '+=' -> '+'
2038
+ const current = binding.transform.read(left);
2039
+ value = b.binary(/** @type {AST.BinaryOperator} */ (operator), current, value);
2040
+ }
2041
+
2042
+ return binding.transform.assign(left, value);
2043
+ }
2044
+ }
2045
+
2022
2046
  if (
2023
2047
  left.type === 'MemberExpression' &&
2024
2048
  (left.tracked || (left.property.type === 'Identifier' && left.property.tracked))
@@ -334,7 +334,7 @@ const visitors = {
334
334
  (binding.kind === 'lazy' || binding.kind === 'lazy_fallback')
335
335
  ) {
336
336
  const transformed = binding.transform.read(node);
337
- if (node.tracked) {
337
+ if (node.tracked && !binding.read_unwraps) {
338
338
  const is_right_side_of_assignment =
339
339
  parent.type === 'AssignmentExpression' && parent.right === node;
340
340
  if (
@@ -520,17 +520,9 @@ const visitors = {
520
520
  }
521
521
  }
522
522
 
523
- if (is_ripple_track_call(callee, context)) {
524
- const track_method_name =
525
- callee.type === 'Identifier'
526
- ? callee.name === 'trackSplit'
527
- ? 'track_split'
528
- : 'track'
529
- : callee.type === 'MemberExpression' && callee.property.type === 'Identifier'
530
- ? callee.property.name === 'trackSplit'
531
- ? 'track_split'
532
- : 'track'
533
- : 'track';
523
+ const track_call_name = is_ripple_track_call(callee, context);
524
+ if (track_call_name) {
525
+ const track_method_name = track_call_name === 'trackSplit' ? 'track_split' : 'track';
534
526
 
535
527
  return {
536
528
  ...node,
@@ -1157,9 +1157,11 @@ export interface Binding {
1157
1157
  /** Transform functions for reading, assigning, and updating this binding */
1158
1158
  transform?: {
1159
1159
  read: (node?: AST.Identifier) => AST.Expression;
1160
- assign?: (node: AST.Pattern, value: AST.Expression) => AST.AssignmentExpression;
1160
+ assign?: (node: AST.Identifier, value: AST.Expression) => AST.Expression;
1161
1161
  update?: (node: AST.UpdateExpression) => AST.Expression;
1162
1162
  };
1163
+ /** Whether the read transform already produces an unwrapped value (calls get() internally) */
1164
+ read_unwraps?: boolean;
1163
1165
  }
1164
1166
 
1165
1167
  /**
@@ -289,27 +289,31 @@ export function is_component_level_function(context) {
289
289
  }
290
290
 
291
291
  /**
292
- * Returns true if callee is a Ripple track call
292
+ * Returns the matched Ripple tracking call name
293
293
  * @param {AST.Expression | AST.Super} callee
294
294
  * @param {CommonContext} context
295
- * @returns {boolean}
295
+ * @returns {'track' | 'trackSplit' | null}
296
296
  */
297
297
  export function is_ripple_track_call(callee, context) {
298
298
  // Super expressions cannot be Ripple track calls
299
- if (callee.type === 'Super') return false;
299
+ if (callee.type === 'Super') return null;
300
300
 
301
301
  if (callee.type === 'Identifier' && (callee.name === 'track' || callee.name === 'trackSplit')) {
302
- return is_ripple_import(callee, context);
302
+ return is_ripple_import(callee, context) ? callee.name : null;
303
303
  }
304
304
 
305
- return (
305
+ if (
306
306
  callee.type === 'MemberExpression' &&
307
307
  callee.object.type === 'Identifier' &&
308
308
  callee.property.type === 'Identifier' &&
309
309
  (callee.property.name === 'track' || callee.property.name === 'trackSplit') &&
310
310
  !callee.computed &&
311
311
  is_ripple_import(callee, context)
312
- );
312
+ ) {
313
+ return callee.property.name;
314
+ }
315
+
316
+ return null;
313
317
  }
314
318
 
315
319
  /**
@@ -80,6 +80,8 @@ export { switch_block as switch } from './switch.js';
80
80
 
81
81
  export { template, append, text } from './template.js';
82
82
 
83
+ export { array_slice } from './utils.js';
84
+
83
85
  export { ripple_array } from '../../array.js';
84
86
 
85
87
  export { ripple_object } from '../../object.js';
@@ -289,6 +289,95 @@ export function run_block(block) {
289
289
 
290
290
  var empty_get_set = { get: undefined, set: undefined };
291
291
 
292
+ class TrackedValue {
293
+ /**
294
+ * @param {any} v
295
+ * @param {Block} block
296
+ * @param {{ get?: Function; set?: Function }} a
297
+ */
298
+ constructor(v, block, a) {
299
+ this.a = a;
300
+ this.b = block;
301
+ this.c = 0;
302
+ this.f = TRACKED;
303
+ this.__v = v;
304
+ }
305
+ get [0]() {
306
+ return get_tracked(/** @type {Tracked} */ (this));
307
+ }
308
+ set [0](v) {
309
+ set(/** @type {Tracked} */ (this), v);
310
+ }
311
+ get [1]() {
312
+ return this;
313
+ }
314
+ get value() {
315
+ return get_tracked(/** @type {Tracked} */ (this));
316
+ }
317
+ /** @param {any} v */
318
+ set value(v) {
319
+ set(/** @type {Tracked} */ (this), v);
320
+ }
321
+ /** @returns {2} */
322
+ get length() {
323
+ return 2;
324
+ }
325
+ *[Symbol.iterator]() {
326
+ yield get_tracked(/** @type {Tracked} */ (this));
327
+ yield this;
328
+ }
329
+ }
330
+
331
+ class DerivedValue {
332
+ /**
333
+ * @param {Function} fn
334
+ * @param {Block} block
335
+ * @param {{ get?: Function; set?: Function }} a
336
+ */
337
+ constructor(fn, block, a) {
338
+ this.a = a;
339
+ this.b = block;
340
+ /** @type {null | Block[]} */
341
+ this.blocks = null;
342
+ this.c = 0;
343
+ this.co = active_component;
344
+ /** @type {null | Dependency} */
345
+ this.d = null;
346
+ this.f = TRACKED | DERIVED;
347
+ this.fn = fn;
348
+ this.__v = UNINITIALIZED;
349
+ }
350
+ get [0]() {
351
+ return get_derived(/** @type {Derived} */ (this));
352
+ }
353
+ set [0](v) {
354
+ set(/** @type {Derived} */ (this), v);
355
+ }
356
+ get [1]() {
357
+ return this;
358
+ }
359
+ get value() {
360
+ return get_derived(/** @type {Derived} */ (this));
361
+ }
362
+ /** @param {any} v */
363
+ set value(v) {
364
+ set(/** @type {Derived} */ (this), v);
365
+ }
366
+ /** @returns {2} */
367
+ get length() {
368
+ return 2;
369
+ }
370
+ *[Symbol.iterator]() {
371
+ yield get_derived(/** @type {Derived} */ (this));
372
+ yield this;
373
+ }
374
+ }
375
+
376
+ if (DEV) {
377
+ define_property(TrackedValue.prototype, 'DO_NOT_ACCESS_THIS_OBJECT_DIRECTLY', { value: true });
378
+ define_property(DerivedValue.prototype, 'DO_NOT_ACCESS_THIS_OBJECT_DIRECTLY', { value: true });
379
+ }
380
+
292
381
  /**
293
382
  *
294
383
  * @param {any} v
@@ -298,25 +387,9 @@ var empty_get_set = { get: undefined, set: undefined };
298
387
  * @returns {Tracked}
299
388
  */
300
389
  export function tracked(v, block, get, set) {
301
- // TODO: now we expose tracked, we should likely block access in DEV somehow
302
- if (DEV) {
303
- return {
304
- DO_NOT_ACCESS_THIS_OBJECT_DIRECTLY: true,
305
- a: get || set ? { get, set } : empty_get_set,
306
- b: block || active_block,
307
- c: 0,
308
- f: TRACKED,
309
- __v: v,
310
- };
311
- }
312
-
313
- return {
314
- a: get || set ? { get, set } : empty_get_set,
315
- b: block || active_block,
316
- c: 0,
317
- f: TRACKED,
318
- __v: v,
319
- };
390
+ return /** @type {Tracked} */ (
391
+ new TrackedValue(v, block || active_block, get || set ? { get, set } : empty_get_set)
392
+ );
320
393
  }
321
394
 
322
395
  /**
@@ -327,32 +400,9 @@ export function tracked(v, block, get, set) {
327
400
  * @returns {Derived}
328
401
  */
329
402
  export function derived(fn, block, get, set) {
330
- if (DEV) {
331
- return {
332
- DO_NOT_ACCESS_THIS_OBJECT_DIRECTLY: true,
333
- a: get || set ? { get, set } : empty_get_set,
334
- b: block || active_block,
335
- blocks: null,
336
- c: 0,
337
- co: active_component,
338
- d: null,
339
- f: TRACKED | DERIVED,
340
- fn,
341
- __v: UNINITIALIZED,
342
- };
343
- }
344
-
345
- return {
346
- a: get || set ? { get, set } : empty_get_set,
347
- b: block || active_block,
348
- blocks: null,
349
- c: 0,
350
- co: active_component,
351
- d: null,
352
- f: TRACKED | DERIVED,
353
- fn,
354
- __v: UNINITIALIZED,
355
- };
403
+ return /** @type {Derived} */ (
404
+ new DerivedValue(fn, block || active_block, get || set ? { get, set } : empty_get_set)
405
+ );
356
406
  }
357
407
 
358
408
  /**
@@ -24,6 +24,11 @@ export type Tracked<V = any> = {
24
24
  c: number;
25
25
  f: number;
26
26
  __v: V;
27
+ readonly [0]: V;
28
+ [1]: Tracked<V>;
29
+ value: V;
30
+ readonly length: 2;
31
+ [Symbol.iterator](): Iterator<V | Tracked<V>>;
27
32
  };
28
33
 
29
34
  export type Derived = {
@@ -37,6 +42,11 @@ export type Derived = {
37
42
  f: number;
38
43
  fn: Function;
39
44
  __v: any;
45
+ readonly [0]: any;
46
+ [1]: Derived;
47
+ value: any;
48
+ readonly length: 2;
49
+ [Symbol.iterator](): Iterator<any | Derived>;
40
50
  };
41
51
 
42
52
  export type Block = {
@@ -27,6 +27,18 @@ export var object_prototype = Object.prototype;
27
27
  /** @type {typeof Array.prototype} */
28
28
  export var array_prototype = Array.prototype;
29
29
 
30
+ /**
31
+ * Slice helper for arrays and array-like values.
32
+ * @param {ArrayLike<any>} array_like
33
+ * @param {...number} args
34
+ * @returns {any[]}
35
+ */
36
+ export function array_slice(array_like, ...args) {
37
+ return is_array(array_like)
38
+ ? array_like.slice(...args)
39
+ : array_prototype.slice.call(array_like, ...args);
40
+ }
41
+
30
42
  /**
31
43
  * Creates a text node that serves as an anchor point in the DOM.
32
44
  * @returns {Text}
@@ -5,7 +5,13 @@
5
5
 
6
6
  import { Readable } from 'stream';
7
7
  import { DERIVED, UNINITIALIZED, TRACKED } from '../client/constants.js';
8
- import { is_ripple_object, get_descriptor, define_property, is_array } from '../client/utils.js';
8
+ import {
9
+ is_ripple_object,
10
+ get_descriptor,
11
+ define_property,
12
+ is_array,
13
+ array_slice,
14
+ } from '../client/utils.js';
9
15
  import { escape } from '../../../utils/escaping.js';
10
16
  import { is_boolean_attribute } from '../../../compiler/utils.js';
11
17
  import { clsx } from 'clsx';
@@ -20,6 +26,7 @@ export { escape };
20
26
  export { register_component_css as register_css } from './css-registry.js';
21
27
  export { hash } from '../../../utils/hashing.js';
22
28
  export { context } from './context.js';
29
+ export { array_slice };
23
30
 
24
31
  /** @type {null | Component} */
25
32
  export let active_component = null;
@@ -607,6 +614,84 @@ export function spread_attrs(attrs, css_hash) {
607
614
 
608
615
  var empty_get_set = { get: undefined, set: undefined };
609
616
 
617
+ class TrackedValue {
618
+ /**
619
+ * @param {any} v
620
+ * @param {{ get?: Function; set?: Function }} a
621
+ */
622
+ constructor(v, a) {
623
+ this.a = a;
624
+ this.c = 0;
625
+ this.f = TRACKED;
626
+ this.v = v;
627
+ }
628
+ get [0]() {
629
+ return get(/** @type {Tracked} */ (this));
630
+ }
631
+ set [0](v) {
632
+ set(/** @type {Tracked} */ (this), v);
633
+ }
634
+ get [1]() {
635
+ return this;
636
+ }
637
+ get value() {
638
+ return get(/** @type {Tracked} */ (this));
639
+ }
640
+ /** @param {any} v */
641
+ set value(v) {
642
+ set(/** @type {Tracked} */ (this), v);
643
+ }
644
+ /** @returns {2} */
645
+ get length() {
646
+ return 2;
647
+ }
648
+ *[Symbol.iterator]() {
649
+ yield get(/** @type {Tracked} */ (this));
650
+ yield this;
651
+ }
652
+ }
653
+
654
+ class DerivedValue {
655
+ /**
656
+ * @param {Function} fn
657
+ * @param {{ get?: Function; set?: Function }} a
658
+ */
659
+ constructor(fn, a) {
660
+ this.a = a;
661
+ this.c = 0;
662
+ this.co = active_component;
663
+ /** @type {null | import('#server').Dependency} */
664
+ this.d = null;
665
+ this.f = TRACKED | DERIVED;
666
+ this.fn = fn;
667
+ this.v = UNINITIALIZED;
668
+ }
669
+ get [0]() {
670
+ return get(/** @type {Derived} */ (this));
671
+ }
672
+ set [0](v) {
673
+ set(/** @type {Derived} */ (this), v);
674
+ }
675
+ get [1]() {
676
+ return this;
677
+ }
678
+ get value() {
679
+ return get(/** @type {Derived} */ (this));
680
+ }
681
+ /** @param {any} v */
682
+ set value(v) {
683
+ set(/** @type {Derived} */ (this), v);
684
+ }
685
+ /** @returns {2} */
686
+ get length() {
687
+ return 2;
688
+ }
689
+ *[Symbol.iterator]() {
690
+ yield get(/** @type {Derived} */ (this));
691
+ yield this;
692
+ }
693
+ }
694
+
610
695
  /**
611
696
  * @param {any} v
612
697
  * @param {(value: any) => any} [get]
@@ -614,12 +699,7 @@ var empty_get_set = { get: undefined, set: undefined };
614
699
  * @returns {Tracked}
615
700
  */
616
701
  function tracked(v, get, set) {
617
- return {
618
- a: get || set ? { get, set } : empty_get_set,
619
- c: 0,
620
- f: TRACKED,
621
- v,
622
- };
702
+ return /** @type {Tracked} */ (new TrackedValue(v, get || set ? { get, set } : empty_get_set));
623
703
  }
624
704
 
625
705
  /**
@@ -636,15 +716,7 @@ export function track(v, get, set) {
636
716
  }
637
717
 
638
718
  if (typeof v === 'function') {
639
- return {
640
- a: get || set ? { get, set } : empty_get_set,
641
- c: 0,
642
- co: active_component,
643
- d: null,
644
- f: TRACKED | DERIVED,
645
- fn: v,
646
- v: UNINITIALIZED,
647
- };
719
+ return /** @type {Derived} */ (new DerivedValue(v, get || set ? { get, set } : empty_get_set));
648
720
  }
649
721
 
650
722
  return tracked(v, get, set);
@@ -678,7 +750,7 @@ export function track_split(v, l) {
678
750
  t = v[key];
679
751
  } else {
680
752
  t = tracked(undefined);
681
- t = define_property(t, '__v', /** @type {PropertyDescriptor} */ (get_descriptor(v, key)));
753
+ t = define_property(t, 'v', /** @type {PropertyDescriptor} */ (get_descriptor(v, key)));
682
754
  }
683
755
  } else {
684
756
  t = tracked(undefined);
@@ -19,6 +19,11 @@ export type Derived = {
19
19
  f: number;
20
20
  fn: Function;
21
21
  v: any;
22
+ readonly [0]: any;
23
+ [1]: Derived;
24
+ value: any;
25
+ readonly length: 2;
26
+ [Symbol.iterator](): Iterator<any | Derived>;
22
27
  };
23
28
 
24
29
  export type Tracked = {
@@ -26,4 +31,9 @@ export type Tracked = {
26
31
  c: number;
27
32
  f: number;
28
33
  v: any;
34
+ readonly [0]: any;
35
+ [1]: Tracked;
36
+ value: any;
37
+ readonly length: 2;
38
+ [Symbol.iterator](): Iterator<any | Tracked>;
29
39
  };
package/src/utils/ast.js CHANGED
@@ -195,7 +195,7 @@ function _extract_paths(assignments = [], param, expression, update_expression,
195
195
  if (element.type === 'RestElement') {
196
196
  /** @type {DestructuredAssignment['expression']} */
197
197
  const rest_expression = (object) =>
198
- b.call(b.member(expression(object), 'slice'), b.literal(i));
198
+ b.call('_$_.array_slice', expression(object), b.literal(i));
199
199
  if (element.argument.type === 'Identifier') {
200
200
  assignments.push({
201
201
  node: element.argument,
@@ -340,6 +340,35 @@ component App() {
340
340
  expect(result).toMatch(/value:\s*value\??\.\['#v'\]/);
341
341
  });
342
342
 
343
+ it('keeps lazy destructuring as plain destructuring in to_ts output', () => {
344
+ const track_split_source = `
345
+ import { trackSplit } from 'ripple';
346
+ component App() {
347
+ const source = { a: 1, b: 2, c: 3 };
348
+ let &[a, b, rest] = trackSplit(source, ['a', 'b']);
349
+ const sum = @a + @b + @rest.c;
350
+ }
351
+ `;
352
+ const track_split_result = compile_to_volar_mappings(track_split_source, 'test.ripple').code;
353
+ expect(track_split_result).toContain('let [a, b, rest] = trackSplit(source, [\'a\', \'b\']);');
354
+ expect(track_split_result).not.toContain('let lazy = trackSplit');
355
+
356
+ const track_source = `
357
+ import { track } from 'ripple';
358
+ component App() {
359
+ let &[value, ...rest] = track(0);
360
+ const x = value;
361
+ }
362
+ `;
363
+ const track_result = compile_to_volar_mappings(track_source, 'test.ripple').code;
364
+ expect(track_result).toContain('let [value, ...rest] = track(0);');
365
+ expect(track_result).toContain('const x = value;');
366
+ expect(track_result).not.toContain('let lazy = track(0)');
367
+ expect(track_result).not.toContain('.slice(');
368
+ expect(track_result).not.toContain('_$_.get(');
369
+ expect(track_result).not.toContain('lazy0');
370
+ });
371
+
343
372
  it('preserves generic type args in interface extends for Volar mappings', () => {
344
373
  const source = `
345
374
  interface PolymorphicProps<T extends keyof HTMLElementTagNameMap> {
@@ -121,4 +121,70 @@ import { get, track } from 'ripple';
121
121
  expect(() => compile(code, 'test.ripple')).not.toThrow();
122
122
  },
123
123
  );
124
+
125
+ it('should allow indexed [0] access on a tracked object', () => {
126
+ const code = `
127
+ import { track } from 'ripple';
128
+ export default component App() {
129
+ let count = track(0);
130
+ console.log(count[0]);
131
+ }
132
+ `;
133
+ expect(() => compile(code, 'test.ripple')).not.toThrow();
134
+ });
135
+
136
+ it('should allow indexed [1] access on a tracked object', () => {
137
+ const code = `
138
+ import { track } from 'ripple';
139
+ export default component App() {
140
+ let count = track(0);
141
+ let raw = count[1];
142
+ }
143
+ `;
144
+ expect(() => compile(code, 'test.ripple')).not.toThrow();
145
+ });
146
+
147
+ it('should allow [0] write on a tracked object', () => {
148
+ const code = `
149
+ import { track } from 'ripple';
150
+ export default component App() {
151
+ let count = track(0);
152
+ count[0] = 5;
153
+ }
154
+ `;
155
+ expect(() => compile(code, 'test.ripple')).not.toThrow();
156
+ });
157
+
158
+ it('should allow .value read access on a tracked object', () => {
159
+ const code = `
160
+ import { track } from 'ripple';
161
+ export default component App() {
162
+ let count = track(0);
163
+ console.log(count.value);
164
+ }
165
+ `;
166
+ expect(() => compile(code, 'test.ripple')).not.toThrow();
167
+ });
168
+
169
+ it('should allow .value assignment on a tracked object', () => {
170
+ const code = `
171
+ import { track } from 'ripple';
172
+ export default component App() {
173
+ let count = track(0);
174
+ count.value = 5;
175
+ }
176
+ `;
177
+ expect(() => compile(code, 'test.ripple')).not.toThrow();
178
+ });
179
+
180
+ it('should allow .length read access on a tracked object', () => {
181
+ const code = `
182
+ import { track } from 'ripple';
183
+ export default component App() {
184
+ let count = track(0);
185
+ console.log(count.length);
186
+ }
187
+ `;
188
+ expect(() => compile(code, 'test.ripple')).not.toThrow();
189
+ });
124
190
  });
@@ -1,6 +1,28 @@
1
- import { flushSync, track } from 'ripple';
1
+ import { flushSync, track, trackSplit } from 'ripple';
2
2
 
3
3
  describe('lazy destructuring', () => {
4
+ it('supports tracked value getter and setter', () => {
5
+ component Test() {
6
+ let count = track(1);
7
+ let doubled = track(() => count.value * 2);
8
+
9
+ <div>{`${count.value}-${doubled.value}`}</div>
10
+ <button
11
+ onClick={() => {
12
+ count.value = 5;
13
+ }}
14
+ >
15
+ {'set'}
16
+ </button>
17
+ }
18
+
19
+ render(Test);
20
+ expect(container.querySelector('div')!.textContent).toBe('1-2');
21
+ container.querySelector('button')!.click();
22
+ flushSync();
23
+ expect(container.querySelector('div')!.textContent).toBe('5-10');
24
+ });
25
+
4
26
  it('lazily accesses object properties with const', () => {
5
27
  component Inner(&{ a, b }: { a: number; b: string }) {
6
28
  <pre>{`${a}-${b}`}</pre>
@@ -183,6 +205,38 @@ describe('lazy destructuring', () => {
183
205
  expect(container.querySelector('pre')!.textContent).toBe('1-99');
184
206
  });
185
207
 
208
+ it('does not apply the track tuple fast-path to trackSplit lazy arrays', () => {
209
+ component Test() {
210
+ const source = { a: 1, b: 2, c: 3 };
211
+ let &[a, b, rest] = trackSplit(source, ['a', 'b']);
212
+ <pre>{`${@a}-${@b}-${@rest.c}`}</pre>
213
+ }
214
+
215
+ render(Test);
216
+ expect(container.querySelector('pre')!.textContent).toBe('1-2-3');
217
+ });
218
+
219
+ it('supports rest destructuring from iterable array-like tracked values', () => {
220
+ component Test() {
221
+ let &[value, ...rest] = track(0);
222
+ <pre>{`${value}-${@rest.length}-${@rest[0] === value}`}</pre>
223
+ }
224
+
225
+ render(Test);
226
+ expect(container.querySelector('pre')!.textContent).toBe('0-1-false');
227
+ });
228
+
229
+ it('supports rest destructuring from length-only array-like sources', () => {
230
+ component Test() {
231
+ const source = { 0: 'x', 1: 'y', 2: 'z', length: 3 };
232
+ const &[first, ...rest] = source;
233
+ <pre>{`${first}-${rest.join(',')}`}</pre>
234
+ }
235
+
236
+ render(Test);
237
+ expect(container.querySelector('pre')!.textContent).toBe('x-y,z');
238
+ });
239
+
186
240
  it('supports update expressions on lazy bindings with default values', () => {
187
241
  component Test() {
188
242
  const obj: { count?: number } = {};
@@ -1,4 +1,20 @@
1
+ import { track, trackSplit } from 'ripple';
2
+
1
3
  describe('lazy destructuring', () => {
4
+ it('supports tracked value getter and setter', async () => {
5
+ component Test() {
6
+ let count = track(1);
7
+ let derived = track(() => count.value * 2);
8
+
9
+ count.value = 3;
10
+
11
+ <pre>{`${count.value}-${derived.value}`}</pre>
12
+ }
13
+
14
+ const { body } = await render(Test);
15
+ expect(body).toBeHtml('<pre>3-6</pre>');
16
+ });
17
+
2
18
  it('lazily accesses object properties', async () => {
3
19
  component Inner(&{ a, b }: { a: number; b: string }) {
4
20
  <pre>{`${a}-${b}`}</pre>
@@ -100,4 +116,37 @@ describe('lazy destructuring', () => {
100
116
  const { body } = await render(Test);
101
117
  expect(body).toBeHtml('<pre>Alice-30</pre>');
102
118
  });
119
+
120
+ it('treats lazy array destructuring of trackSplit as regular array access', async () => {
121
+ component Test() {
122
+ const source = { a: 1, b: 2, c: 3 };
123
+ const &[a, b, rest] = trackSplit(source, ['a', 'b']);
124
+ <pre>{`${@a}-${@b}-${@rest.c}`}</pre>
125
+ }
126
+
127
+ const { body } = await render(Test);
128
+ expect(body).toBeHtml('<pre>1-2-3</pre>');
129
+ });
130
+
131
+ it('supports rest in lazy array destructuring for tracked tuples (iterable)', async () => {
132
+ component Test() {
133
+ let tracked_value = track(0);
134
+ let &[value, ...rest] = tracked_value;
135
+ <pre>{`${value}-${@rest.length}-${@rest[0] === tracked_value}`}</pre>
136
+ }
137
+
138
+ const { body } = await render(Test);
139
+ expect(body).toBeHtml('<pre>0-1-true</pre>');
140
+ });
141
+
142
+ it('supports rest in lazy array destructuring for length-only array-like values', async () => {
143
+ component Test() {
144
+ const array_like = { 0: 'x', 1: 'y', 2: 'z', length: 3 };
145
+ const &[first, ...rest] = array_like;
146
+ <pre>{`${first}-${rest.join('')}`}</pre>
147
+ }
148
+
149
+ const { body } = await render(Test);
150
+ expect(body).toBeHtml('<pre>x-yz</pre>');
151
+ });
103
152
  });
package/types/index.d.ts CHANGED
@@ -145,16 +145,21 @@ declare global {
145
145
  export function createRefKey(): symbol;
146
146
 
147
147
  // Base Tracked interface - all tracked values have a '#v' property containing the actual value
148
- export interface Tracked<V> {
148
+ interface TrackedBase<V> {
149
149
  '#v': V;
150
+ value: V;
150
151
  }
151
152
 
152
153
  // Augment Tracked to be callable when V is a Component
153
154
  // This allows <@Something /> to work in JSX when Something is Tracked<Component>
154
- export interface Tracked<V> {
155
+ interface TrackedCallable<V> {
155
156
  (props: V extends Component<infer P> ? P : never): V extends Component ? void : never;
156
157
  }
157
158
 
159
+ // Supports indexed access: track(0)[0] → value, track(0)[1] → Tracked<V>
160
+ // And destructuring `const [one, two] = track(0);`
161
+ export type Tracked<V> = [V, Tracked<V>] & TrackedBase<V> & TrackedCallable<V>;
162
+
158
163
  // Helper type to infer component type from a function that returns a component
159
164
  // If T is a function returning a Component, extract the Component type itself, not the return type (void)
160
165
  export type InferComponent<T> = T extends () => infer R ? (R extends Component<any> ? R : T) : T;