ripple 0.2.44 → 0.2.45

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Ripple is a TypeScript UI framework for the web",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.2.44",
6
+ "version": "0.2.45",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index.js",
9
9
  "main": "src/runtime/index.js",
@@ -38,6 +38,82 @@ function RipplePlugin(config) {
38
38
  return null;
39
39
  }
40
40
 
41
+ // Override getTokenFromCode to handle @ as an identifier prefix
42
+ getTokenFromCode(code) {
43
+ if (code === 64) { // '@' character
44
+ // Look ahead to see if this is followed by a valid identifier character
45
+ if (this.pos + 1 < this.input.length) {
46
+ const nextChar = this.input.charCodeAt(this.pos + 1);
47
+ // Check if the next character can start an identifier
48
+ if ((nextChar >= 65 && nextChar <= 90) || // A-Z
49
+ (nextChar >= 97 && nextChar <= 122) || // a-z
50
+ nextChar === 95 || nextChar === 36) { // _ or $
51
+
52
+ // Check if we're in an expression context
53
+ // In JSX expressions, inside parentheses, assignments, etc.
54
+ // we want to treat @ as an identifier prefix rather than decorator
55
+ const currentType = this.type;
56
+ const inExpression = this.exprAllowed ||
57
+ currentType === tt.braceL || // Inside { }
58
+ currentType === tt.parenL || // Inside ( )
59
+ currentType === tt.eq || // After =
60
+ currentType === tt.comma || // After ,
61
+ currentType === tt.colon || // After :
62
+ currentType === tt.question || // After ?
63
+ currentType === tt.logicalOR || // After ||
64
+ currentType === tt.logicalAND; // After &&
65
+
66
+ if (inExpression) {
67
+ return this.readAtIdentifier();
68
+ }
69
+ }
70
+ }
71
+ }
72
+ return super.getTokenFromCode(code);
73
+ }
74
+
75
+ // Read an @ prefixed identifier
76
+ readAtIdentifier() {
77
+ const start = this.pos;
78
+ this.pos++; // skip '@'
79
+
80
+ // Read the identifier part manually
81
+ let word = '';
82
+ while (this.pos < this.input.length) {
83
+ const ch = this.input.charCodeAt(this.pos);
84
+ if ((ch >= 65 && ch <= 90) || // A-Z
85
+ (ch >= 97 && ch <= 122) || // a-z
86
+ (ch >= 48 && ch <= 57) || // 0-9
87
+ ch === 95 || ch === 36) { // _ or $
88
+ word += this.input[this.pos++];
89
+ } else {
90
+ break;
91
+ }
92
+ }
93
+
94
+ if (word === '') {
95
+ this.raise(start, 'Invalid @ identifier');
96
+ }
97
+
98
+ // Return the full identifier including @
99
+ return this.finishToken(tt.name, '@' + word);
100
+ }
101
+
102
+ // Override parseIdent to mark @ identifiers as tracked
103
+ parseIdent(liberal) {
104
+ const node = super.parseIdent(liberal);
105
+ if (node.name && node.name.startsWith('@')) {
106
+ node.name = node.name.slice(1); // Remove the '@' for internal use
107
+ node.tracked = true;
108
+ node.start++;
109
+ const prev_pos = this.pos;
110
+ this.pos = node.start;
111
+ node.loc.start = this.curPosition();
112
+ this.pos = prev_pos;
113
+ }
114
+ return node;
115
+ }
116
+
41
117
  parseExportDefaultDeclaration() {
42
118
  // Check if this is "export default component"
43
119
  if (this.value === 'component') {
@@ -118,12 +118,34 @@ const visitors = {
118
118
  if (
119
119
  is_reference(node, /** @type {Node} */ (parent)) &&
120
120
  context.state.metadata?.tracking === false &&
121
- is_tracked_name(node.name) &&
121
+ is_tracked_name(node) &&
122
122
  binding?.node !== node
123
123
  ) {
124
124
  context.state.metadata.tracking = true;
125
125
  }
126
126
 
127
+ if (
128
+ is_reference(node, /** @type {Node} */ (parent)) &&
129
+ node.tracked &&
130
+ binding?.node !== node
131
+ ) {
132
+ if (context.state.metadata?.tracking === false) {
133
+ context.state.metadata.tracking = true;
134
+ }
135
+ binding.transform = {
136
+ read_tracked: (node) => b.call('$.get_tracked', node),
137
+ assign_tracked: (node, value) => b.call('$.set', node, value, b.id('__block')),
138
+ update_tracked: (node) => {
139
+ return b.call(
140
+ node.prefix ? '$.update_pre' : '$.update',
141
+ node.argument,
142
+ b.id('__block'),
143
+ node.operator === '--' && b.literal(-1),
144
+ );
145
+ },
146
+ };
147
+ }
148
+
127
149
  context.next();
128
150
  },
129
151
 
@@ -405,7 +405,9 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
405
405
  if (attribute.type === 'SpreadAttribute') return true;
406
406
 
407
407
  if (attribute.type !== 'Attribute') continue;
408
- if (attribute.name.name.toLowerCase() !== name.toLowerCase()) continue;
408
+
409
+ const lowerCaseName = name.toLowerCase();
410
+ if (![lowerCaseName, `$${lowerCaseName}`].includes(attribute.name.name.toLowerCase())) continue;
409
411
 
410
412
  if (expected_value === null) return true;
411
413
 
@@ -72,10 +72,15 @@ function build_getter(node, context) {
72
72
 
73
73
  for (let i = context.path.length - 1; i >= 0; i -= 1) {
74
74
  const binding = state.scope.get(node.name);
75
+ const transform = binding?.transform;
75
76
 
76
77
  // don't transform the declaration itself
77
- if (node !== binding?.node && binding?.transform?.read) {
78
- return binding.transform.read(node, context.state?.metadata?.spread, context.visit);
78
+ if (node !== binding?.node) {
79
+ const read_fn = transform?.read || (node.tracked && transform?.read_tracked);
80
+
81
+ if (read_fn) {
82
+ return read_fn(node, context.state?.metadata?.spread, context.visit);
83
+ }
79
84
  }
80
85
  }
81
86
 
@@ -100,7 +105,7 @@ const visitors = {
100
105
  const binding = context.state.scope.get(node.name);
101
106
  if (
102
107
  context.state.metadata?.tracking === false &&
103
- is_tracked_name(node.name) &&
108
+ (is_tracked_name(node.name) || node.tracked) &&
104
109
  binding?.node !== node
105
110
  ) {
106
111
  context.state.metadata.tracking = true;
@@ -135,6 +140,18 @@ const visitors = {
135
140
  context.state.metadata.tracking = true;
136
141
  }
137
142
 
143
+ if (
144
+ !context.state.to_ts &&
145
+ callee.type === 'Identifier' &&
146
+ callee.name === 'tracked' &&
147
+ is_ripple_import(callee, context)
148
+ ) {
149
+ return {
150
+ ...node,
151
+ arguments: [...node.arguments.map((arg) => context.visit(arg)), b.id('__block')],
152
+ };
153
+ }
154
+
138
155
  if (
139
156
  !is_inside_component(context, true) ||
140
157
  context.state.to_ts ||
@@ -199,8 +216,8 @@ const visitors = {
199
216
  callee.optional ? b.true : undefined,
200
217
  node.optional ? b.true : undefined,
201
218
  ...node.arguments.map((arg) => context.visit(arg)),
202
- )
203
- )
219
+ ),
220
+ ),
204
221
  );
205
222
  }
206
223
  }
@@ -508,9 +525,10 @@ const visitors = {
508
525
 
509
526
  if (is_spreading) {
510
527
  // For spread attributes, store just the actual value, not the full attribute string
511
- const actual_value = is_boolean_attribute(name) && value === true
512
- ? b.literal(true)
513
- : b.literal(value === true ? '' : value);
528
+ const actual_value =
529
+ is_boolean_attribute(name) && value === true
530
+ ? b.literal(true)
531
+ : b.literal(value === true ? '' : value);
514
532
  spread_attributes.push(b.prop('init', b.literal(name), actual_value));
515
533
  } else {
516
534
  state.template.push(attr_value);
@@ -1002,7 +1020,10 @@ const visitors = {
1002
1020
 
1003
1021
  if (left.type === 'MemberExpression') {
1004
1022
  // need to capture setting length of array to throw a runtime error
1005
- if (left.property.type === 'Identifier' && (is_tracked_name(left.property.name) || left.property.name === 'length')) {
1023
+ if (
1024
+ left.property.type === 'Identifier' &&
1025
+ (is_tracked_name(left.property.name) || left.property.name === 'length')
1026
+ ) {
1006
1027
  if (left.property.name !== '$length') {
1007
1028
  return b.call(
1008
1029
  '$.set_property',
@@ -1058,8 +1079,9 @@ const visitors = {
1058
1079
  const transformers = left && binding?.transform;
1059
1080
 
1060
1081
  if (left === argument) {
1061
- if (transformers?.update) {
1062
- return transformers.update(node);
1082
+ const update_fn = transformers?.update || transformers?.update_tracked;
1083
+ if (update_fn) {
1084
+ return update_fn(node);
1063
1085
  }
1064
1086
  }
1065
1087
 
@@ -1303,7 +1325,7 @@ const visitors = {
1303
1325
  return b.literal(node.quasis[0].value.cooked);
1304
1326
  }
1305
1327
 
1306
- const expressions = node.expressions.map(expr => context.visit(expr));
1328
+ const expressions = node.expressions.map((expr) => context.visit(expr));
1307
1329
  return b.template(node.quasis, expressions);
1308
1330
  },
1309
1331
 
@@ -19,7 +19,7 @@ const VOID_ELEMENT_NAMES = [
19
19
  'param',
20
20
  'source',
21
21
  'track',
22
- 'wbr'
22
+ 'wbr',
23
23
  ];
24
24
 
25
25
  /**
@@ -564,19 +564,21 @@ export function build_assignment(operator, left, right, context) {
564
564
  const transform = binding.transform;
565
565
 
566
566
  // reassignment
567
- if (
568
- (object === left || (left.type === 'MemberExpression' && left.computed && operator === '=')) &&
569
- transform?.assign
570
- ) {
571
- let value = /** @type {Expression} */ (
572
- context.visit(build_assignment_value(operator, left, right))
573
- );
567
+ if (object === left || (left.type === 'MemberExpression' && left.computed && operator === '=')) {
568
+ const assign_fn = transform?.assign || transform?.assign_tracked;
569
+ if (assign_fn) {
570
+ let value = /** @type {Expression} */ (
571
+ context.visit(build_assignment_value(operator, left, right))
572
+ );
574
573
 
575
- return transform.assign(
576
- object,
577
- value,
578
- left.type === 'MemberExpression' && left.computed ? context.visit(left.property) : undefined,
579
- );
574
+ return assign_fn(
575
+ object,
576
+ value,
577
+ left.type === 'MemberExpression' && left.computed
578
+ ? context.visit(left.property)
579
+ : undefined,
580
+ );
581
+ }
580
582
  }
581
583
 
582
584
  // mutation
@@ -1,8 +1,11 @@
1
- import { TRACKED_OBJECT, ARRAY_SET_INDEX_AT } from './internal/client/constants.js';
1
+ import { TRACKED_OBJECT, ARRAY_SET_INDEX_AT, MAX_ARRAY_LENGTH } from './internal/client/constants.js';
2
2
  import { get, safe_scope, set, tracked } from './internal/client/runtime.js';
3
3
  import { is_ripple_array } from './internal/client/utils.js';
4
4
  /** @import { Block, Tracked } from '#client' */
5
5
 
6
+ /** @type {unique symbol} */
7
+ const INIT_AFTER_NEW = Symbol();
8
+
6
9
  /** @type {(symbol | string | any)[]} */
7
10
  const introspect_methods = [
8
11
  'concat',
@@ -36,7 +39,7 @@ const introspect_methods = [
36
39
  'with',
37
40
  ];
38
41
 
39
- let init = false;
42
+ let is_proto_set = false;
40
43
 
41
44
  /**
42
45
  * @template T
@@ -45,6 +48,8 @@ let init = false;
45
48
  export class RippleArray extends Array {
46
49
  /** @type {Array<Tracked>} */
47
50
  #tracked_elements = [];
51
+ /** @type {Tracked} */
52
+ // @ts-expect-error
48
53
  #tracked_index;
49
54
 
50
55
  /**
@@ -55,11 +60,11 @@ export class RippleArray extends Array {
55
60
  * @returns {RippleArray<U>}
56
61
  */
57
62
  static from(arrayLike, mapFn, thisArg) {
58
- return new RippleArray(...(
59
- mapFn ?
63
+ var arr = mapFn ?
60
64
  Array.from(arrayLike, mapFn, thisArg)
61
- : Array.from(arrayLike)
62
- ));
65
+ : Array.from(arrayLike);
66
+
67
+ return get_instance_from_static(arr);
63
68
  }
64
69
 
65
70
  /**
@@ -70,11 +75,30 @@ export class RippleArray extends Array {
70
75
  * @returns {Promise<RippleArray<U>>}
71
76
  */
72
77
  static async fromAsync(arrayLike, mapFn, thisArg) {
73
- return new RippleArray(...(
74
- mapFn ?
78
+ var block = safe_scope();
79
+ // create empty array to get the right scope
80
+ var result = new RippleArray();
81
+
82
+ var arr = mapFn ?
75
83
  await Array.fromAsync(arrayLike, mapFn, thisArg)
76
84
  : await Array.fromAsync(arrayLike)
77
- ));
85
+
86
+ var first = get_first_if_length(arr);
87
+
88
+ if (first) {
89
+ result[0] = first;
90
+ } else {
91
+ result.length = arr.length;
92
+ for (let i = 0; i < arr.length; i++) {
93
+ if (i in arr) {
94
+ result[i] = arr[i];
95
+ }
96
+ }
97
+ }
98
+
99
+ result[INIT_AFTER_NEW](block);
100
+
101
+ return result
78
102
  }
79
103
 
80
104
  /**
@@ -83,7 +107,9 @@ export class RippleArray extends Array {
83
107
  * @returns {RippleArray<U>}
84
108
  */
85
109
  static of(...elements) {
86
- return new RippleArray(...elements);
110
+ var arr = Array.of(...elements);
111
+
112
+ return get_instance_from_static(arr);
87
113
  }
88
114
 
89
115
  /**
@@ -92,24 +118,33 @@ export class RippleArray extends Array {
92
118
  constructor(...elements) {
93
119
  super(...elements);
94
120
 
95
- var block = safe_scope();
96
- var tracked_elements = this.#tracked_elements;
121
+ this[INIT_AFTER_NEW]();
97
122
 
98
- for (var i = 0; i < this.length; i++) {
99
- if (!(i in this)) {
100
- continue;
123
+ if (!is_proto_set) {
124
+ is_proto_set = true;
125
+ this.#set_proto();
126
+ }
127
+ }
128
+
129
+ [INIT_AFTER_NEW](block = safe_scope()) {
130
+ if (this.length !== 0) {
131
+ var tracked_elements = this.#tracked_elements;
132
+ for (var i = 0; i < this.length; i++) {
133
+ if (!(i in this)) {
134
+ continue;
135
+ }
136
+ tracked_elements[i] = tracked(this[i], block);
101
137
  }
102
- tracked_elements[i] = tracked(this[i], block);
103
138
  }
104
- this.#tracked_index = tracked(this.length, block);
105
139
 
106
- if (!init) {
107
- init = true;
108
- this.#init();
140
+ if (!this.#tracked_index) {
141
+ this.#tracked_index = tracked(this.length, block);
142
+ } else if (this.#tracked_index.v !== this.length) {
143
+ set(this.#tracked_index, this.length, block);
109
144
  }
110
145
  }
111
146
 
112
- #init() {
147
+ #set_proto() {
113
148
  var proto = RippleArray.prototype;
114
149
  var array_proto = Array.prototype;
115
150
 
@@ -138,7 +173,7 @@ export class RippleArray extends Array {
138
173
  // the caller reruns on length changes
139
174
  this.$length;
140
175
  // the caller reruns on element changes
141
- get_all_elements(this);
176
+ establish_trackable_deps(this);
142
177
  return result;
143
178
  };
144
179
  }
@@ -564,14 +599,72 @@ export class RippleArray extends Array {
564
599
  export function get_all_elements(array) {
565
600
  /** @type {Tracked[]} */
566
601
  var tracked_elements = /** @type {Tracked[]} */ (array[TRACKED_OBJECT]);
567
- var arr = [];
602
+ // pre-allocate to support holey arrays
603
+ var result = new Array(array.length);
604
+
605
+ for (var i = 0; i < array.length; i++) {
606
+ if (tracked_elements[i] !== undefined) {
607
+ get(tracked_elements[i]);
608
+ }
609
+
610
+ if (i in array) {
611
+ result[i] = array[i];
612
+ }
613
+ }
614
+
615
+ return result;
616
+ }
617
+
618
+ /**
619
+ * @template T
620
+ * @param {RippleArray<T>} array
621
+ * @returns {void}
622
+ */
623
+ function establish_trackable_deps (array) {
624
+ var tracked_elements = array[TRACKED_OBJECT];
568
625
 
569
626
  for (var i = 0; i < tracked_elements.length; i++) {
570
627
  if (tracked_elements[i] !== undefined) {
571
628
  get(tracked_elements[i]);
572
629
  }
573
- arr.push(array[i]);
630
+ }
631
+ }
632
+
633
+ /**
634
+ * @template T
635
+ * @param {T[]} array
636
+ * @returns {RippleArray<T>}
637
+ */
638
+ function get_instance_from_static(array) {
639
+ /** @type RippleArray<T> */
640
+ var result;
641
+ /** @type {T | void} */
642
+ var first = get_first_if_length(array);
643
+
644
+ if (first) {
645
+ result = new RippleArray();
646
+ result[0] = first;
647
+ result[INIT_AFTER_NEW]();
648
+ } else {
649
+ result = new RippleArray(...array);
574
650
  }
575
651
 
576
- return arr;
652
+ return result;
653
+ }
654
+
655
+ /**
656
+ * @template T
657
+ * @param {T[]} array
658
+ * @returns {T | void}
659
+ */
660
+ function get_first_if_length (array) {
661
+ var first = array[0];
662
+
663
+ if (
664
+ array.length === 1 && (0 in array) && Number.isInteger(first)
665
+ && /** @type {number} */ (first) >= 0
666
+ && /** @type {number} */ (first) <= MAX_ARRAY_LENGTH
667
+ ) {
668
+ return first;
669
+ }
577
670
  }
@@ -36,7 +36,7 @@ export function mount(component, options) {
36
36
 
37
37
  export { create_context as createContext } from './internal/client/context.js';
38
38
 
39
- export { flush_sync as flushSync, untrack, deferred } from './internal/client/runtime.js';
39
+ export { flush_sync as flushSync, untrack, deferred, tracked } from './internal/client/runtime.js';
40
40
 
41
41
  export { RippleArray } from './array.js';
42
42
 
@@ -19,8 +19,11 @@ export var DESTROYED = 1 << 17;
19
19
  export var LOGIC_BLOCK = FOR_BLOCK | IF_BLOCK | TRY_BLOCK;
20
20
 
21
21
  export var UNINITIALIZED = Symbol();
22
- export var TRACKED_OBJECT = Symbol();
22
+ /** @type {unique symbol} */
23
+ export const TRACKED_OBJECT = Symbol();
23
24
  export var SPREAD_OBJECT = Symbol();
24
25
  export var COMPUTED_PROPERTY = Symbol();
25
26
  export var REF_PROP = 'ref';
26
- export var ARRAY_SET_INDEX_AT = Symbol();
27
+ /** @type {unique symbol} */
28
+ export const ARRAY_SET_INDEX_AT = Symbol();
29
+ export const MAX_ARRAY_LENGTH = 2**32 - 1;
@@ -260,6 +260,7 @@ export function run_block(block) {
260
260
  * @returns {Tracked}
261
261
  */
262
262
  export function tracked(v, b) {
263
+ // TODO: now we expose tracked, we should likely block access in DEV somehow
263
264
  return {
264
265
  b,
265
266
  c: 0,
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import { mount, flushSync, effect, untrack, RippleArray } from 'ripple';
3
- import { ARRAY_SET_INDEX_AT } from '../src/runtime/internal/client/constants.js';
3
+ import { ARRAY_SET_INDEX_AT, MAX_ARRAY_LENGTH } from '../src/runtime/internal/client/constants.js';
4
4
 
5
5
  describe('RippleArray', () => {
6
6
  let container;
@@ -1244,37 +1244,34 @@ describe('RippleArray', () => {
1244
1244
  expect(container.querySelector('pre').textContent).toBe('Cannot set length on RippleArray, use $length instead');
1245
1245
  });
1246
1246
 
1247
- ('fromAsync' in Array.prototype ? describe : describe.skip)('RippleArray fromAsync', () => {
1247
+ ('fromAsync' in Array.prototype ? describe : describe.skip)('RippleArray fromAsync', async () => {
1248
1248
  it('handles static fromAsync method with reactivity', async () => {
1249
- component ArrayTest() {
1250
- let itemsPromise = RippleArray.fromAsync(Promise.resolve([1, 2, 3]));
1251
- let items = null;
1252
- let error = null;
1253
-
1249
+ component Parent() {
1254
1250
  try {
1255
- items = await itemsPromise;
1256
- } catch (e) {
1257
- error = e.message;
1251
+ <ArrayTest />
1252
+ } async {
1253
+ <div>{'Loading placeholder...'}</div>
1258
1254
  }
1255
+ }
1256
+
1257
+ component ArrayTest() {
1258
+ let items = await RippleArray.fromAsync([1, 2, 3]);
1259
1259
 
1260
1260
  <button onClick={() => {
1261
- if (items) items.push(4);
1261
+ if (items) items.push(4);
1262
1262
  }}>{'add item'}</button>
1263
- <pre>{error ? 'Error: ' + error : 'Loaded'}</pre>
1264
- <pre>{items ? JSON.stringify(items) : 'Loading...'}</pre>
1263
+
1264
+ <pre>{JSON.stringify(items)}</pre>
1265
1265
  }
1266
1266
 
1267
- render(ArrayTest);
1267
+ render(Parent);
1268
1268
 
1269
- // Wait for promise to resolve
1270
- await new Promise(resolve => setTimeout(resolve, 50));
1269
+ await new Promise(resolve => setTimeout(resolve, 0));
1271
1270
  flushSync();
1272
1271
 
1273
1272
  const addButton = container.querySelector('button');
1274
1273
 
1275
- // Check that the promise resolved correctly
1276
- expect(container.querySelectorAll('pre')[0].textContent).toBe('Loaded');
1277
- expect(container.querySelectorAll('pre')[1].textContent).toBe('[1,2,3]');
1274
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('[1,2,3]');
1278
1275
 
1279
1276
  // Test adding an item to the async-created array
1280
1277
  addButton.click();
@@ -1284,33 +1281,35 @@ describe('RippleArray', () => {
1284
1281
  });
1285
1282
 
1286
1283
  it('handles static fromAsync method with mapping function', async () => {
1284
+ component Parent() {
1285
+ try {
1286
+ <ArrayTest />
1287
+ } async {
1288
+ <div>{'Loading placeholder...'}</div>
1289
+ }
1290
+ }
1291
+
1287
1292
  component ArrayTest() {
1288
- let itemsPromise = RippleArray.fromAsync(
1289
- Promise.resolve([1, 2, 3]),
1290
- x => x * 2
1293
+ let items = await RippleArray.fromAsync(
1294
+ [1, 2, 3],
1295
+ x => x * 2
1291
1296
  );
1292
- let items = null;
1293
-
1294
- items = await itemsPromise;
1295
1297
 
1296
1298
  <button onClick={() => {
1297
- if (items) items.push(8);
1299
+ if (items) items.push(8);
1298
1300
  }}>{'add item'}</button>
1299
1301
  <pre>{items ? JSON.stringify(items) : 'Loading...'}</pre>
1300
1302
  }
1301
1303
 
1302
- render(ArrayTest);
1304
+ render(Parent);
1303
1305
 
1304
- // Wait for promise to resolve
1305
- await new Promise(resolve => setTimeout(resolve, 50));
1306
+ await new Promise(resolve => setTimeout(resolve, 0));
1306
1307
  flushSync();
1307
1308
 
1308
1309
  const addButton = container.querySelector('button');
1309
1310
 
1310
- // Check that the promise resolved correctly with mapping applied
1311
1311
  expect(container.querySelector('pre').textContent).toBe('[2,4,6]');
1312
1312
 
1313
- // Test adding an item to the async-created array
1314
1313
  addButton.click();
1315
1314
  flushSync();
1316
1315
 
@@ -1318,13 +1317,20 @@ describe('RippleArray', () => {
1318
1317
  });
1319
1318
 
1320
1319
  it('handles error in fromAsync method', async () => {
1320
+ component Parent() {
1321
+ try {
1322
+ <ArrayTest />
1323
+ } async {
1324
+ <div>{'Loading placeholder...'}</div>
1325
+ }
1326
+ }
1327
+
1321
1328
  component ArrayTest() {
1322
- let itemsPromise = RippleArray.fromAsync(Promise.reject(new Error('Async error')));
1323
1329
  let items = null;
1324
1330
  let error = null;
1325
1331
 
1326
1332
  try {
1327
- items = await itemsPromise;
1333
+ items = await RippleArray.fromAsync(Promise.reject(new Error('Async error')));
1328
1334
  } catch (e) {
1329
1335
  error = e.message;
1330
1336
  }
@@ -1333,13 +1339,11 @@ describe('RippleArray', () => {
1333
1339
  <pre>{items ? JSON.stringify(items) : 'No items'}</pre>
1334
1340
  }
1335
1341
 
1336
- render(ArrayTest);
1342
+ render(Parent);
1337
1343
 
1338
- // Wait for promise to reject
1339
- await new Promise(resolve => setTimeout(resolve, 50));
1344
+ await new Promise(resolve => setTimeout(resolve, 0));
1340
1345
  flushSync();
1341
1346
 
1342
- // Check that the error was caught correctly
1343
1347
  expect(container.querySelectorAll('pre')[0].textContent).toBe('Error: Async error');
1344
1348
  expect(container.querySelectorAll('pre')[1].textContent).toBe('No items');
1345
1349
  });
@@ -1463,6 +1467,90 @@ describe('RippleArray', () => {
1463
1467
  expect(container.querySelectorAll('pre')[5].textContent).toBe('items[4]: 4');
1464
1468
  });
1465
1469
  });
1470
+
1471
+ describe('Creates RippleArray with a single element', () => {
1472
+ it('specifies int', () => {
1473
+ component ArrayTest() {
1474
+ let items = new RippleArray(3);
1475
+ <pre>{JSON.stringify(items)}</pre>
1476
+ <pre>{items.$length}</pre>
1477
+ }
1478
+
1479
+ render(ArrayTest);
1480
+
1481
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('[null,null,null]');
1482
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('3');
1483
+ });
1484
+
1485
+ it('errors on exceeding max array size', () => {
1486
+ component ArrayTest() {
1487
+ let error = null;
1488
+
1489
+ try {
1490
+ new RippleArray(MAX_ARRAY_LENGTH + 1);
1491
+ } catch (e) {
1492
+ error = e.message;
1493
+ }
1494
+
1495
+ <pre>{error}</pre>
1496
+ }
1497
+
1498
+ render(ArrayTest);
1499
+
1500
+ expect(container.querySelector('pre').textContent).toBe('Invalid array length');
1501
+ });
1502
+
1503
+ it('specifies int using static from method', () => {
1504
+ component ArrayTest() {
1505
+ let items = RippleArray.from([4]);
1506
+ <pre>{JSON.stringify(items)}</pre>
1507
+ <pre>{items.$length}</pre>
1508
+ }
1509
+
1510
+ render(ArrayTest);
1511
+
1512
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('[4]');
1513
+ // expect(container.querySelectorAll('pre')[1].textContent).toBe('1');
1514
+ });
1515
+
1516
+ it('specifies int using static of method', () => {
1517
+ component ArrayTest() {
1518
+ let items = RippleArray.of(5);
1519
+ <pre>{JSON.stringify(items)}</pre>
1520
+ <pre>{items.$length}</pre>
1521
+ }
1522
+
1523
+ render(ArrayTest);
1524
+
1525
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('[5]');
1526
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('1');
1527
+ });
1528
+
1529
+ ('fromAsync' in Array.prototype ? it : it.skip)('specifies int using static fromAsync method', async () => {
1530
+ component Parent() {
1531
+ try {
1532
+ <ArrayTest />
1533
+ } async {
1534
+ <div>{'Loading placeholder...'}</div>
1535
+ }
1536
+ }
1537
+
1538
+ component ArrayTest() {
1539
+ const items = await RippleArray.fromAsync([6]);
1540
+
1541
+ <pre>{items ? JSON.stringify(items) : 'Loading...'}</pre>
1542
+ <pre>{items ? items.$length : ''}</pre>
1543
+ }
1544
+
1545
+ render(Parent);
1546
+
1547
+ await new Promise(resolve => setTimeout(resolve, 0));
1548
+ flushSync();
1549
+
1550
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('[6]');
1551
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('1');
1552
+ });
1553
+ });
1466
1554
  });
1467
1555
 
1468
1556
 
@@ -115,6 +115,12 @@ describe('basic', () => {
115
115
 
116
116
  <button onClick={() => $active = !$active}>{'Toggle'}</button>
117
117
  <div $class={$active ? 'active' : 'inactive'}>{'Dynamic Class'}</div>
118
+
119
+ <style>
120
+ .active {
121
+ color: green;
122
+ }
123
+ </style>
118
124
  }
119
125
 
120
126
  render(Basic);
@@ -122,17 +128,17 @@ describe('basic', () => {
122
128
  const button = container.querySelector('button');
123
129
  const div = container.querySelector('div');
124
130
 
125
- expect(div.className).toBe('inactive');
126
-
131
+ expect(Array.from(div.classList).some(className => className.startsWith('ripple-'))).toBe(true);
132
+ expect(div.classList.contains('inactive')).toBe(true);
133
+
127
134
  button.click();
128
135
  flushSync();
129
-
130
- expect(div.className).toBe('active');
136
+ expect(div.classList.contains('active')).toBe(true);
131
137
 
132
138
  button.click();
133
139
  flushSync();
134
140
 
135
- expect(div.className).toBe('inactive');
141
+ expect(div.classList.contains('inactive')).toBe(true);
136
142
  });
137
143
 
138
144
  it('render dynamic id attribute', () => {