ripple 0.2.91 → 0.2.93

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.
@@ -32,7 +32,7 @@ import {
32
32
  get_own_property_symbols,
33
33
  is_array,
34
34
  is_tracked_object,
35
- object_keys,
35
+ object_keys,
36
36
  } from './utils.js';
37
37
 
38
38
  const FLUSH_MICROTASK = 0;
@@ -46,6 +46,8 @@ export let active_reaction = null;
46
46
  export let active_scope = null;
47
47
  /** @type {null | Component} */
48
48
  export let active_component = null;
49
+ /** @type {boolean} */
50
+ export let is_mutating_allowed = true;
49
51
 
50
52
  /** @type {Map<Tracked, any>} */
51
53
  var old_values = new Map();
@@ -170,6 +172,7 @@ function run_derived(computed) {
170
172
  var previous_tracking = tracking;
171
173
  var previous_dependency = active_dependency;
172
174
  var previous_component = active_component;
175
+ var previous_is_mutating_allowed = is_mutating_allowed;
173
176
 
174
177
  try {
175
178
  active_block = computed.b;
@@ -177,6 +180,7 @@ function run_derived(computed) {
177
180
  tracking = true;
178
181
  active_dependency = null;
179
182
  active_component = computed.co;
183
+ is_mutating_allowed = false;
180
184
 
181
185
  destroy_computed_children(computed);
182
186
 
@@ -191,6 +195,7 @@ function run_derived(computed) {
191
195
  tracking = previous_tracking;
192
196
  active_dependency = previous_dependency;
193
197
  active_component = previous_component;
198
+ is_mutating_allowed = previous_is_mutating_allowed;
194
199
  }
195
200
  }
196
201
 
@@ -407,7 +412,7 @@ function is_tracking_dirty(tracking) {
407
412
  var tracked = tracking.t;
408
413
 
409
414
  if ((tracked.f & DERIVED) !== 0) {
410
- update_derived(/** @type {Derived} **/ (tracked));
415
+ update_derived(/** @type {Derived} **/(tracked));
411
416
  }
412
417
 
413
418
  if (tracked.c > tracking.c) {
@@ -467,7 +472,7 @@ export function async_computed(fn, block) {
467
472
  }
468
473
 
469
474
  promise.then((v) => {
470
- if (parent && is_destroyed(/** @type {Block} */ (parent))) {
475
+ if (parent && is_destroyed(/** @type {Block} */(parent))) {
471
476
  return;
472
477
  }
473
478
  if (promise === current && t.v !== v) {
@@ -503,6 +508,21 @@ export function async_computed(fn, block) {
503
508
  });
504
509
  }
505
510
 
511
+ /**
512
+ * @template V
513
+ * @param {Function} fn
514
+ * @param {V} v
515
+ */
516
+ function trigger_track_get(fn, v) {
517
+ var previous_is_mutating_allowed = is_mutating_allowed;
518
+ try {
519
+ is_mutating_allowed = false;
520
+ return untrack(() => fn(v));
521
+ } finally {
522
+ is_mutating_allowed = previous_is_mutating_allowed;
523
+ }
524
+ }
525
+
506
526
  /**
507
527
  * @param {() => any} fn
508
528
  * @returns {[any, Tracked[] | null]}
@@ -602,6 +622,13 @@ function flush_queued_root_blocks(root_blocks) {
602
622
  }
603
623
  }
604
624
 
625
+ /**
626
+ * @returns {Promise<void>}
627
+ */
628
+ export async function tick() {
629
+ return new Promise((f) => requestAnimationFrame(() => f()));
630
+ }
631
+
605
632
  /**
606
633
  * @returns {void}
607
634
  */
@@ -702,8 +729,8 @@ export function get_derived(computed) {
702
729
  register_dependency(computed);
703
730
  }
704
731
  var get = computed.a.get;
705
- if (get) {
706
- computed.v = get(computed.v);
732
+ if (get !== undefined) {
733
+ computed.v = trigger_track_get(get, computed.v);
707
734
  }
708
735
 
709
736
  return computed.v;
@@ -719,7 +746,7 @@ export function get(tracked) {
719
746
  }
720
747
 
721
748
  return (tracked.f & DERIVED) !== 0
722
- ? get_derived(/** @type {Derived} */ (tracked))
749
+ ? get_derived(/** @type {Derived} */(tracked))
723
750
  : get_tracked(tracked);
724
751
  }
725
752
 
@@ -735,8 +762,8 @@ export function get_tracked(tracked) {
735
762
  value = old_values.get(tracked);
736
763
  }
737
764
  var get = tracked.a.get;
738
- if (get) {
739
- value = get(value);
765
+ if (get !== undefined) {
766
+ value = trigger_track_get(get, value);
740
767
  }
741
768
  return value;
742
769
  }
@@ -747,6 +774,10 @@ export function get_tracked(tracked) {
747
774
  * @param {Block} block
748
775
  */
749
776
  export function set(tracked, value, block) {
777
+ if (!is_mutating_allowed) {
778
+ throw new Error('Assignments or updates to tracked values are not allowed during computed "track(() => ...)" evaluation');
779
+ }
780
+
750
781
  var old_value = tracked.v;
751
782
 
752
783
  if (value !== old_value) {
@@ -760,9 +791,9 @@ export function set(tracked, value, block) {
760
791
  }
761
792
  }
762
793
 
763
- var set = tracked.a.set;
764
- if (set) {
765
- value = set(value, old_value);
794
+ let set = tracked.a.set;
795
+ if (set !== undefined) {
796
+ value = untrack(() => set(value, old_value));
766
797
  }
767
798
 
768
799
  tracked.v = value;
@@ -838,10 +869,10 @@ export function spread_props(fn, block) {
838
869
  const obj = get_derived(computed);
839
870
  return obj[property];
840
871
  },
841
- has(target, property) {
842
- const obj = get_derived(computed);
843
- return property in obj;
844
- },
872
+ has(target, property) {
873
+ const obj = get_derived(computed);
874
+ return property in obj;
875
+ },
845
876
  ownKeys() {
846
877
  const obj = get_derived(computed);
847
878
  return Reflect.ownKeys(obj);
@@ -1086,15 +1117,15 @@ export function fallback(value, fallback) {
1086
1117
  * @returns {Record<string | symbol, unknown>}
1087
1118
  */
1088
1119
  export function exclude_from_object(obj, exclude_keys) {
1089
- var keys = object_keys(obj);
1120
+ var keys = object_keys(obj);
1090
1121
  /** @type {Record<string | symbol, unknown>} */
1091
1122
  var new_obj = {};
1092
1123
 
1093
- for (const key of keys) {
1094
- if (!exclude_keys.includes(key)) {
1095
- new_obj[key] = obj[key];
1096
- }
1097
- }
1124
+ for (const key of keys) {
1125
+ if (!exclude_keys.includes(key)) {
1126
+ new_obj[key] = obj[key];
1127
+ }
1128
+ }
1098
1129
 
1099
1130
  for (const symbol of get_own_property_symbols(obj)) {
1100
1131
  var ref_fn = obj[symbol];
@@ -1121,7 +1152,7 @@ export async function maybe_tracked(v) {
1121
1152
  } else {
1122
1153
  value = await async_computed(async () => {
1123
1154
  return await get_tracked(v);
1124
- }, /** @type {Block} */ (active_block));
1155
+ }, /** @type {Block} */(active_block));
1125
1156
  }
1126
1157
  } else {
1127
1158
  value = await v;
@@ -0,0 +1,23 @@
1
+ /** @type {Map<string, string>} */
2
+ const normalized_properties_cache = new Map();
3
+
4
+ /**
5
+ * Takes a camelCased string and returns a hyphenated string
6
+ * @param {string} str
7
+ * @returns {string}
8
+ * @example
9
+ * normalize_css_property_name('backgroundColor') // 'background-color'
10
+ */
11
+ export function normalize_css_property_name(str) {
12
+ if (str.startsWith('--')) return str;
13
+
14
+ let normalized_result = normalized_properties_cache.get(str);
15
+ if (normalized_result != null) {
16
+ return normalized_result;
17
+ }
18
+
19
+ normalized_result = str.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
20
+ normalized_properties_cache.set(str, normalized_result);
21
+
22
+ return normalized_result;
23
+ }
@@ -1,10 +1,11 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { mount, flushSync, effect, track, trackSplit } from 'ripple';
2
+ import { mount, flushSync, effect, track, trackSplit, untrack, tick } from 'ripple';
3
3
  import { compile } from 'ripple/compiler';
4
4
  import { TRACKED_ARRAY } from '../../src/runtime/internal/client/constants.js';
5
5
 
6
6
  describe('basic client', () => {
7
7
  let container;
8
+ let error;
8
9
 
9
10
  function render(component) {
10
11
  mount(component, {
@@ -15,11 +16,13 @@ describe('basic client', () => {
15
16
  beforeEach(() => {
16
17
  container = document.createElement('div');
17
18
  document.body.appendChild(container);
19
+ error = undefined;
18
20
  });
19
21
 
20
22
  afterEach(() => {
21
23
  document.body.removeChild(container);
22
24
  container = null;
25
+ error = undefined;
23
26
  });
24
27
 
25
28
  it('render static text', () => {
@@ -266,6 +269,107 @@ describe('basic client', () => {
266
269
  expect(div.style.fontWeight).toBe('bold');
267
270
  });
268
271
 
272
+ it('render style attribute as dynamic object', () => {
273
+ component Basic() {
274
+ let color = track('red');
275
+
276
+ <button onClick={() => { @color = @color === 'red' ? 'blue' : 'red' }}>{'Change Color'}</button>
277
+ <div style={{
278
+ color: @color,
279
+ fontWeight: 'bold',
280
+ }}>{'Dynamic Style'}</div>
281
+ }
282
+
283
+ render(Basic);
284
+
285
+ const button = container.querySelector('button');
286
+ const div = container.querySelector('div');
287
+
288
+ expect(div.style.color).toBe('red');
289
+ expect(div.style.fontWeight).toBe('bold');
290
+
291
+ button.click();
292
+ flushSync();
293
+
294
+ expect(div.style.color).toBe('blue');
295
+ expect(div.style.fontWeight).toBe('bold');
296
+ });
297
+
298
+ it('render tracked variable as style attribute', () => {
299
+ component Basic() {
300
+ let style = track({ color: 'red', fontWeight: 'bold' });
301
+
302
+ function toggleColor() {
303
+ @style = { ...@style, color: @style.color === 'red' ? 'blue' : 'red' };
304
+ }
305
+
306
+ <button onClick={toggleColor}>{'Change Color'}</button>
307
+ <div style={@style}>{'Dynamic Style'}</div>
308
+ }
309
+
310
+ render(Basic);
311
+
312
+ const button = container.querySelector('button');
313
+ const div = container.querySelector('div');
314
+
315
+ expect(div.style.color).toBe('red');
316
+ expect(div.style.fontWeight).toBe('bold');
317
+
318
+ button.click();
319
+ flushSync();
320
+
321
+ expect(div.style.color).toBe('blue');
322
+ expect(div.style.fontWeight).toBe('bold');
323
+ });
324
+
325
+ it('render tracked object as style attribute', () => {
326
+ component Basic() {
327
+ let style = #{ color: 'red', fontWeight: 'bold' };
328
+
329
+ function toggleColor() {
330
+ @style.color = @style.color === 'red' ? 'blue' : 'red';
331
+ }
332
+
333
+ <button onClick={toggleColor}>{'Change Color'}</button>
334
+ <div style={{ ...@style }}>{'Dynamic Style'}</div>
335
+ }
336
+
337
+ render(Basic);
338
+
339
+ const button = container.querySelector('button');
340
+ const div = container.querySelector('div');
341
+
342
+ expect(div.style.color).toBe('red');
343
+ expect(div.style.fontWeight).toBe('bold');
344
+
345
+ button.click();
346
+ flushSync();
347
+
348
+ expect(div.style.color).toBe('blue');
349
+ expect(div.style.fontWeight).toBe('bold');
350
+ });
351
+
352
+ it('render spread attributes with style and class', () => {
353
+ component Basic() {
354
+ const attributes = {
355
+ style: { color: 'red', fontWeight: 'bold' },
356
+ class: ['foo', false && 'bar'],
357
+ };
358
+
359
+ <div {...attributes}>{'Attributes with style and class'}</div>
360
+ }
361
+
362
+ render(Basic);
363
+
364
+ const div = container.querySelector('div');
365
+
366
+ expect(div.style.color).toBe('red');
367
+ expect(div.style.fontWeight).toBe('bold');
368
+
369
+ expect(div.classList.contains('foo')).toBe(true);
370
+ expect(div.classList.contains('bar')).toBe(false);
371
+ });
372
+
269
373
  it('render spread props without duplication', () => {
270
374
  component App() {
271
375
  const checkBoxProp = {name:'car'}
@@ -1637,5 +1741,107 @@ describe('basic client', () => {
1637
1741
  render(App);
1638
1742
  expect(container).toMatchSnapshot();
1639
1743
  });
1744
+
1745
+ it('tick function', async () => {
1746
+ let resolve;
1747
+ const promise = new Promise((res) => (resolve = res));
1748
+
1749
+ component Basic() {
1750
+ let value = track(0);
1751
+ effect(() => {
1752
+ untrack(() => {
1753
+ @value++;
1754
+ tick().then(() => resolve());
1755
+ });
1756
+ });
1757
+ <p>{@value}</p>
1758
+ }
1759
+ render(Basic);
1760
+
1761
+ const p = container.querySelector('p');
1762
+ expect(p.textContent).toBe('0');
1763
+ await promise;
1764
+ expect(p.textContent).toBe('1');
1765
+ });
1766
+
1767
+ it('errors on mutating tracked value inside computed track() evaluation', () => {
1768
+ component Basic() {
1769
+ let count = track(0);
1770
+
1771
+ const doubled = track(() => {
1772
+ try {
1773
+ @count *= 2;
1774
+ } catch (e) {
1775
+ error = e.message;
1776
+ }
1777
+ });
1778
+
1779
+ <p>{@doubled}</p>
1780
+ }
1781
+
1782
+ render(Basic);
1783
+
1784
+ expect(error).toBe('Assignments or updates to tracked values are not allowed during computed "track(() => ...)" evaluation');
1785
+ });
1786
+
1787
+ it('errors on mutating tracked value inside untrack() in computed track() evaluation', () => {
1788
+ component Basic() {
1789
+ let count = track(0);
1790
+
1791
+ const doubled = track(() => {
1792
+ try {
1793
+ untrack(() => {
1794
+ @count *= 2;
1795
+ });
1796
+ } catch (e) {
1797
+ error = e.message;
1798
+ }
1799
+ });
1800
+
1801
+ <p>{@doubled}</p>
1802
+ }
1803
+
1804
+ render(Basic);
1805
+
1806
+ expect(error).toBe('Assignments or updates to tracked values are not allowed during computed "track(() => ...)" evaluation');
1807
+ });
1808
+
1809
+ it("errors on mutating a tracked variable in track() getter", () => {
1810
+ component Basic() {
1811
+ let count = track(0);
1812
+
1813
+ const doubled = track(0, (value) => {
1814
+ try {
1815
+ @count += 1;
1816
+ } catch (e) {
1817
+ error = e.message;
1818
+ }
1819
+ return value;
1820
+ });
1821
+
1822
+ <p>{@doubled}</p>
1823
+ }
1824
+
1825
+ render(Basic);
1826
+
1827
+ expect(error).toBe('Assignments or updates to tracked values are not allowed during computed "track(() => ...)" evaluation');
1828
+ });
1829
+
1830
+ it("doesn't error on mutating a tracked variable in track() setter", () => {
1831
+ component Basic() {
1832
+ let count = track(0);
1833
+
1834
+ const doubled = track(0, undefined, (value) => {
1835
+ @count += value;
1836
+ return value;
1837
+ });
1838
+
1839
+ <p>{@doubled}</p>
1840
+ }
1841
+
1842
+ render(Basic);
1843
+
1844
+ expect(error).toBe(undefined);
1845
+ });
1640
1846
  });
1641
1847
 
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import { mount, TrackedArray, track } from 'ripple';
3
- import { parse } from 'ripple/compiler'
3
+ import { parse, compile } from 'ripple/compiler'
4
4
 
5
5
  describe('compiler success tests', () => {
6
6
  let container;
@@ -280,4 +280,98 @@ describe('compiler success tests', () => {
280
280
 
281
281
  render(App);
282
282
  });
283
+
284
+ describe('attribute name handling', () => {
285
+ it('generates valid JavaScript for component props with hyphenated attributes', () => {
286
+ const source = `
287
+ component Child(props) {
288
+ <div />
289
+ }
290
+
291
+ export default component App() {
292
+ <Child data-scope="test" aria-label="accessible" class="valid" />
293
+ }`;
294
+
295
+ const result = compile(source, 'test.ripple', { mode: 'client' });
296
+
297
+ // Should contain properly quoted hyphenated properties and unquoted valid identifiers
298
+ expect(result.js.code).toMatch(/'data-scope': "test"/);
299
+ expect(result.js.code).toMatch(/'aria-label': "accessible"/);
300
+ expect(result.js.code).toMatch(/class: "valid"/);
301
+ });
302
+
303
+ it('generates valid JavaScript for all types of hyphenated attributes', () => {
304
+ const testCases = [
305
+ { attr: 'data-testid="value"', expected: /'data-testid': "value"/ },
306
+ { attr: 'aria-label="label"', expected: /'aria-label': "label"/ },
307
+ { attr: 'data-custom-attr="custom"', expected: /'data-custom-attr': "custom"/ },
308
+ { attr: 'ng-if="condition"', expected: /'ng-if': "condition"/ },
309
+ ];
310
+
311
+ testCases.forEach(({ attr, expected }) => {
312
+ const source = `
313
+ component Child(props) { <div /> }
314
+ export default component App() { <Child ${attr} /> }`;
315
+
316
+ const result = compile(source, 'test.ripple', { mode: 'client' });
317
+ expect(result.js.code).toMatch(expected);
318
+ });
319
+ });
320
+
321
+ it('handles mixed valid and invalid attribute identifiers correctly', () => {
322
+ const source = `
323
+ component Child(props) {
324
+ <div />
325
+ }
326
+
327
+ export default component App() {
328
+ <Child
329
+ validProp="valid"
330
+ class="valid"
331
+ id="valid"
332
+ data-invalid="invalid"
333
+ aria-invalid="invalid"
334
+ custom-prop="invalid"
335
+ />
336
+ }`;
337
+
338
+ const result = compile(source, 'test.ripple', { mode: 'client' });
339
+
340
+ // Valid identifiers should not be quoted
341
+ expect(result.js.code).toMatch(/validProp: "valid"/);
342
+ expect(result.js.code).toMatch(/class: "valid"/);
343
+ expect(result.js.code).toMatch(/id: "valid"/);
344
+
345
+ // Invalid identifiers (with hyphens) should be quoted
346
+ expect(result.js.code).toMatch(/'data-invalid': "invalid"/);
347
+ expect(result.js.code).toMatch(/'aria-invalid': "invalid"/);
348
+ expect(result.js.code).toMatch(/'custom-prop': "invalid"/);
349
+ });
350
+
351
+ it('ensures generated code is syntactically valid JavaScript', () => {
352
+ const source = `
353
+ component Child(props) {
354
+ <div />
355
+ }
356
+
357
+ export default component App() {
358
+ <Child data-scope="test" />
359
+ }`;
360
+
361
+ const result = compile(source, 'test.ripple', { mode: 'client' });
362
+
363
+ // Extract the props object from the generated code and test it's valid JavaScript
364
+ const match = result.js.code.match(/Child\([^,]+,\s*(\{[^}]+\})/);
365
+ expect(match).toBeTruthy();
366
+
367
+ const propsObject = match[1];
368
+ expect(() => {
369
+ // Test that the object literal is syntactically valid
370
+ new Function(`return ${propsObject}`);
371
+ }).not.toThrow();
372
+
373
+ // Also verify it contains the expected quoted property
374
+ expect(propsObject).toMatch(/'data-scope': "test"/);
375
+ });
376
+ });
283
377
  });
@@ -49,4 +49,32 @@ describe('html directive', () => {
49
49
 
50
50
  expect(container).toMatchSnapshot();
51
51
  });
52
- });
52
+
53
+ it('renders the correct namespace for child svg element when html is surrounded by <svg>', () => {
54
+ component App() {
55
+ let str = '<circle r="45" cx="50" cy="50" fill="red" />';
56
+
57
+ <svg height="100" width="100">{html str}</svg>
58
+ }
59
+
60
+ render(App);
61
+
62
+ const circle = container.querySelector('circle');
63
+ expect(circle.namespaceURI).toBe('http://www.w3.org/2000/svg');
64
+ });
65
+
66
+ it('renders the correct namespace for child math element when html is surrounded by <math>', () => {
67
+ component App() {
68
+ let str = '<mi>x</mi><mo>+</mo><mi>y</mi>';
69
+
70
+ <math>{html str}</math>;
71
+ }
72
+
73
+ render(App);
74
+
75
+ const mi = container.querySelector('mi');
76
+ const mo = container.querySelector('mo');
77
+ expect(mi.namespaceURI).toBe('http://www.w3.org/1998/Math/MathML');
78
+ expect(mo.namespaceURI).toBe('http://www.w3.org/1998/Math/MathML');
79
+ });
80
+ });