ripple 0.3.8 → 0.3.10

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 (79) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +2 -2
  3. package/src/compiler/phases/1-parse/index.js +38 -172
  4. package/src/compiler/phases/2-analyze/index.js +308 -115
  5. package/src/compiler/phases/2-analyze/prune.js +13 -5
  6. package/src/compiler/phases/3-transform/client/index.js +197 -213
  7. package/src/compiler/phases/3-transform/segments.js +0 -7
  8. package/src/compiler/phases/3-transform/server/index.js +77 -170
  9. package/src/compiler/types/acorn.d.ts +1 -1
  10. package/src/compiler/types/estree.d.ts +1 -1
  11. package/src/compiler/types/import.d.ts +0 -2
  12. package/src/compiler/types/index.d.ts +14 -18
  13. package/src/compiler/types/parse.d.ts +3 -9
  14. package/src/compiler/utils.js +154 -21
  15. package/src/runtime/element.js +39 -0
  16. package/src/runtime/index-client.js +2 -13
  17. package/src/runtime/index-server.js +2 -2
  18. package/src/runtime/internal/client/bindings.js +3 -1
  19. package/src/runtime/internal/client/composite.js +11 -6
  20. package/src/runtime/internal/client/events.js +1 -1
  21. package/src/runtime/internal/client/expression.js +218 -0
  22. package/src/runtime/internal/client/head.js +3 -4
  23. package/src/runtime/internal/client/index.js +4 -1
  24. package/src/runtime/internal/client/portal.js +12 -6
  25. package/src/runtime/internal/client/runtime.js +0 -52
  26. package/src/runtime/internal/server/index.js +57 -56
  27. package/tests/client/basic/basic.components.test.ripple +85 -87
  28. package/tests/client/basic/basic.errors.test.ripple +28 -4
  29. package/tests/client/basic/basic.reactivity.test.ripple +10 -155
  30. package/tests/client/basic/basic.rendering.test.ripple +23 -8
  31. package/tests/client/capture-error.js +12 -0
  32. package/tests/client/compiler/compiler.basic.test.ripple +107 -18
  33. package/tests/client/composite/composite.props.test.ripple +5 -9
  34. package/tests/client/composite/composite.reactivity.test.ripple +35 -36
  35. package/tests/client/composite/composite.render.test.ripple +45 -13
  36. package/tests/client/css/global-additional-cases.test.ripple +3 -3
  37. package/tests/client/dynamic-elements.test.ripple +3 -4
  38. package/tests/client/lazy-destructuring.test.ripple +69 -12
  39. package/tests/client/svg.test.ripple +4 -4
  40. package/tests/hydration/basic.test.js +23 -0
  41. package/tests/hydration/compiled/client/basic.js +118 -66
  42. package/tests/hydration/compiled/client/composite.js +90 -37
  43. package/tests/hydration/compiled/client/events.js +18 -18
  44. package/tests/hydration/compiled/client/for.js +62 -62
  45. package/tests/hydration/compiled/client/head.js +10 -10
  46. package/tests/hydration/compiled/client/hmr.js +13 -10
  47. package/tests/hydration/compiled/client/html.js +274 -236
  48. package/tests/hydration/compiled/client/if-children.js +41 -35
  49. package/tests/hydration/compiled/client/if.js +2 -2
  50. package/tests/hydration/compiled/client/mixed-control-flow.js +12 -12
  51. package/tests/hydration/compiled/client/nested-control-flow.js +46 -46
  52. package/tests/hydration/compiled/client/portal.js +8 -8
  53. package/tests/hydration/compiled/client/reactivity.js +14 -14
  54. package/tests/hydration/compiled/client/return.js +2 -2
  55. package/tests/hydration/compiled/client/try.js +4 -4
  56. package/tests/hydration/compiled/server/basic.js +64 -31
  57. package/tests/hydration/compiled/server/composite.js +62 -29
  58. package/tests/hydration/compiled/server/hmr.js +24 -37
  59. package/tests/hydration/compiled/server/html.js +472 -611
  60. package/tests/hydration/compiled/server/if-children.js +77 -103
  61. package/tests/hydration/compiled/server/portal.js +8 -8
  62. package/tests/hydration/components/basic.ripple +15 -5
  63. package/tests/hydration/components/composite.ripple +13 -1
  64. package/tests/hydration/components/hmr.ripple +1 -3
  65. package/tests/hydration/components/html.ripple +13 -35
  66. package/tests/hydration/components/if-children.ripple +4 -8
  67. package/tests/hydration/composite.test.js +11 -0
  68. package/tests/server/basic.attributes.test.ripple +50 -0
  69. package/tests/server/basic.components.test.ripple +22 -28
  70. package/tests/server/basic.test.ripple +12 -0
  71. package/tests/server/compiler.test.ripple +43 -4
  72. package/tests/server/composite.props.test.ripple +5 -9
  73. package/tests/server/dynamic-elements.test.ripple +3 -4
  74. package/tests/server/lazy-destructuring.test.ripple +68 -12
  75. package/tests/server/style-identifier.test.ripple +2 -4
  76. package/tsconfig.typecheck.json +4 -0
  77. package/types/index.d.ts +9 -21
  78. package/tests/client/__snapshots__/tracked-expression.test.ripple.snap +0 -34
  79. package/tests/client/tracked-expression.test.ripple +0 -26
@@ -1,5 +1,5 @@
1
1
  import type { PropsWithChildren, Tracked } from 'ripple';
2
- import { effect, flushSync, track, trackSplit, untrack } from 'ripple';
2
+ import { effect, flushSync, track, untrack } from 'ripple';
3
3
 
4
4
  describe('basic client > reactivity', () => {
5
5
  it('renders multiple reactive lexical blocks', () => {
@@ -384,162 +384,17 @@ describe('basic client > reactivity', () => {
384
384
  expect(state.finalValue).toBe(5);
385
385
  });
386
386
 
387
- describe('track/trackSplit APIs', () => {
388
- it('errors on invalid value as null for track with trackSplit', () => {
389
- component App() {
390
- let &[message] = track('');
391
-
392
- try {
393
- const [a, b, rest] = trackSplit(null, ['a', 'b']);
394
- } catch (e) {
395
- message = (e as Error).message;
396
- }
397
-
398
- <pre>{message}</pre>
399
- }
400
-
401
- render(App);
402
-
403
- const pre = container.querySelectorAll('pre')[0];
404
- expect(pre.textContent).toBe('Invalid value: expected a non-tracked object');
405
- });
406
-
407
- it('errors on invalid value as array for track with trackSplit', () => {
408
- component App() {
409
- let &[message] = track('');
410
-
411
- try {
412
- const [a, b, rest] = trackSplit([1, 2, 3], ['a', 'b']);
413
- } catch (e) {
414
- message = (e as Error).message;
415
- }
416
-
417
- <pre>{message}</pre>
418
- }
419
-
420
- render(App);
421
-
422
- const pre = container.querySelectorAll('pre')[0];
423
- expect(pre.textContent).toBe('Invalid value: expected a non-tracked object');
424
- });
425
-
426
- it('errors on invalid value as tracked for track with trackSplit', () => {
427
- component App() {
428
- const t = track({ a: 1, b: 2, c: 3 });
429
- let &[message] = track('');
430
-
431
- try {
432
- const [a, b, rest] = trackSplit(t, ['a', 'b']);
433
- } catch (e) {
434
- message = (e as Error).message;
435
- }
436
-
437
- <pre>{message}</pre>
438
- }
439
-
440
- render(App);
441
-
442
- const pre = container.querySelectorAll('pre')[0];
443
- expect(pre.textContent).toBe('Invalid value: expected a non-tracked object');
444
- });
445
-
446
- it('returns undefined for non-existent props in track with trackSplit', () => {
447
- component App() {
448
- const [a, b, rest] = trackSplit({ a: 1, c: 1 }, ['a', 'b']);
449
-
450
- <pre>{a.value}</pre>
451
- <pre>{String(b.value)}</pre>
452
- <pre>{rest.value.c}</pre>
453
- }
454
-
455
- render(App);
456
-
457
- const preA = container.querySelectorAll('pre')[0];
458
- const preB = container.querySelectorAll('pre')[1];
459
- const preC = container.querySelectorAll('pre')[2];
460
-
461
- expect(preA.textContent).toBe('1');
462
- expect(preB.textContent).toBe('undefined');
463
- expect(preC.textContent).toBe('1');
464
- });
465
-
466
- it('returns the same tracked object if plain track is called with a tracked object', () => {
467
- component App() {
468
- const t = track({ a: 1, b: 2, c: 3 });
469
- const doublet = track(t);
470
-
471
- <pre>{t === doublet}</pre>
472
- }
473
-
474
- render(App);
475
-
476
- const pre = container.querySelectorAll('pre')[0];
477
- expect(pre.textContent).toBe('true');
478
- });
479
-
480
- it('can retain reactivity for destructure rest via track trackSplit', () => {
481
- let logs: string[] = [];
482
-
483
- component App() {
484
- let &[count] = track(0);
485
- let &[name] = track('Click Me');
486
-
487
- function buttonRef(el: HTMLButtonElement) {
488
- logs.push('ref called');
489
- return () => {
490
- logs.push('cleanup ref');
491
- };
492
- }
493
-
494
- <Child
495
- class="my-button"
496
- onClick={() => name === 'Click Me' ? name = 'Clicked' : name = 'Click Me'}
497
- {count}
498
- {ref buttonRef}
499
- >
500
- {name}
501
- </Child>
502
-
503
- <button onClick={() => count++}>{'Increment Count'}</button>
504
- }
505
-
506
- component Child(props: PropsWithChildren<{
507
- count: Tracked<number>;
508
- class: string;
509
- onClick: () => void;
510
- }>) {
511
- const [children, count, rest] = trackSplit(props, ['children', 'count']);
512
-
513
- if (count.value < 2) {
514
- <button {...rest.value}>
515
- <@children />
516
- </button>
517
- }
518
- <pre>{count.value}</pre>
519
- }
520
-
521
- render(App);
522
- flushSync();
523
-
524
- const buttonClickMe = container.querySelectorAll('button')[0];
525
- const buttonIncrement = container.querySelectorAll('button')[1];
526
- const countPre = container.querySelector('pre');
527
-
528
- expect(buttonClickMe.textContent).toBe('Click Me');
529
- expect(countPre.textContent).toBe('0');
530
- expect(logs).toEqual(['ref called']);
531
-
532
- buttonClickMe.click();
533
- buttonIncrement.click();
534
- flushSync();
387
+ it('returns the same tracked object if plain track is called with a tracked object', () => {
388
+ component App() {
389
+ const t = track({ a: 1, b: 2, c: 3 });
390
+ const doublet = track(t);
535
391
 
536
- expect(buttonClickMe.textContent).toBe('Clicked');
537
- expect(countPre.textContent).toBe('1');
392
+ <pre>{t === doublet}</pre>
393
+ }
538
394
 
539
- buttonIncrement.click();
540
- flushSync();
395
+ render(App);
541
396
 
542
- expect(logs).toEqual(['ref called', 'cleanup ref']);
543
- });
397
+ const pre = container.querySelectorAll('pre')[0];
398
+ expect(pre.textContent).toBe('true');
544
399
  });
545
400
  });
@@ -15,9 +15,9 @@ describe('basic client > rendering & text', () => {
15
15
 
16
16
  it('renders semi-dynamic text', () => {
17
17
  component Basic() {
18
- let text = 'Hello World';
18
+ let message = 'Hello World';
19
19
 
20
- <div>{text}</div>
20
+ <div>{message}</div>
21
21
  }
22
22
 
23
23
  render(Basic);
@@ -25,18 +25,33 @@ describe('basic client > rendering & text', () => {
25
25
  expect(container).toMatchSnapshot();
26
26
  });
27
27
 
28
+ it('renders explicit text interpolation without creating HTML', () => {
29
+ component Basic() {
30
+ let markup = '<span>Not HTML</span>';
31
+
32
+ <div>{text markup}</div>
33
+ }
34
+
35
+ render(Basic);
36
+
37
+ const div = container.querySelector('div');
38
+
39
+ expect(div.textContent).toBe('<span>Not HTML</span>');
40
+ expect(div.querySelector('span')).toBeNull();
41
+ });
42
+
28
43
  it('renders dynamic text', () => {
29
44
  component Basic() {
30
- let &[text] = track('Hello World');
45
+ let &[message] = track('Hello World');
31
46
 
32
47
  <button
33
48
  onClick={() => {
34
- text = 'Hello Ripple';
49
+ message = 'Hello Ripple';
35
50
  }}
36
51
  >
37
52
  {'Change Text'}
38
53
  </button>
39
- <div>{text}</div>
54
+ <div>{message}</div>
40
55
  }
41
56
 
42
57
  render(Basic);
@@ -70,13 +85,13 @@ describe('basic client > rendering & text', () => {
70
85
  it('renders tick template literal for nested children', () => {
71
86
  component Child({ level, children }: { level: number; children: any }) {
72
87
  if (level == 1) {
73
- <h1><children /></h1>
88
+ <h1>{children}</h1>
74
89
  }
75
90
  if (level == 2) {
76
- <h2><children /></h2>
91
+ <h2>{children}</h2>
77
92
  }
78
93
  if (level == 3) {
79
- <h3><children /></h3>
94
+ <h3>{children}</h3>
80
95
  }
81
96
  }
82
97
 
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @param {() => void} fn
3
+ * @returns {boolean}
4
+ */
5
+ export function did_error(fn) {
6
+ try {
7
+ fn();
8
+ return false;
9
+ } catch {
10
+ return true;
11
+ }
12
+ }
@@ -44,6 +44,40 @@ describe('compiler > basics', () => {
44
44
  ).toEqual(style3);
45
45
  });
46
46
 
47
+ it('parses explicit text interpolation and reserves the text keyword', () => {
48
+ const source = `export component App() {
49
+ const markup = '<span>Not HTML</span>';
50
+
51
+ <div>{markup}</div>
52
+ <div>{text markup}</div>
53
+ }`;
54
+
55
+ const ast = parse(source);
56
+ const component_node = (ast.body[0] as AST.ExportNamedDeclaration).declaration as unknown as AST.Component;
57
+ const elements = component_node.body.filter((node) => node.type === 'Element') as AST.Element[];
58
+ const expression = elements[0].children[0] as AST.Node & { expression: AST.Expression };
59
+ const explicit_text = elements[1].children[0] as AST.TextNode;
60
+
61
+ expect(elements).toHaveLength(2);
62
+ expect(expression.type).toBe('RippleExpression');
63
+ expect((expression.expression as AST.Identifier).name).toBe('markup');
64
+ expect(explicit_text.type).toBe('Text');
65
+ expect((explicit_text.expression as AST.Identifier).name).toBe('markup');
66
+
67
+ const { js } = compile(source, 'text-directive.ripple', { mode: 'client' });
68
+ expect(js.code).not.toContain('_$_.html');
69
+
70
+ const invalid_source = `export component App() {
71
+ const text = 'plain';
72
+
73
+ <div>{text}</div>
74
+ }`;
75
+
76
+ expect(() => parse(invalid_source)).toThrow(
77
+ '"text" is a Ripple keyword and must be used in the form {text some_value}',
78
+ );
79
+ });
80
+
47
81
  it('renders without crashing', () => {
48
82
  component App() {
49
83
  let foo: Record<string, number>;
@@ -341,18 +375,6 @@ component App() {
341
375
  });
342
376
 
343
377
  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
378
  const track_source = `
357
379
  import { track } from 'ripple';
358
380
  component App() {
@@ -369,6 +391,37 @@ component App() {
369
391
  expect(track_result).not.toContain('lazy0');
370
392
  });
371
393
 
394
+ it('uses tracked fast path for nested lazy params typed as Tracked', () => {
395
+ const source = `
396
+ import type { Tracked } from 'ripple';
397
+ function use_nested({ value: &[count, tracked] }: { value: Tracked<number> }) {
398
+ count++;
399
+ return tracked;
400
+ }
401
+ `;
402
+ const { js } = compile(source, 'tracked-nested-lazy.ripple', { mode: 'client' });
403
+
404
+ // Nested lazy array should still use tracked tuple fast path from outer annotation.
405
+ expect(js.code).toContain('_$_.update(');
406
+ expect(js.code).not.toContain('[0]');
407
+ expect(js.code).not.toContain('[1]');
408
+ });
409
+
410
+ it('uses tracked fast path for nested lazy params at tuple rest positions', () => {
411
+ const source = `
412
+ import type { Tracked } from 'ripple';
413
+ function use_tuple_rest({ value: [head, &[count, tracked]] }: { value: [number, ...Tracked<number>[]] }) {
414
+ count++;
415
+ return tracked;
416
+ }
417
+ `;
418
+ const { js } = compile(source, 'tracked-nested-lazy-tuple-rest.ripple', { mode: 'client' });
419
+
420
+ // Tuple rest element access should resolve to Tracked<number>, not Tracked<number>[].
421
+ expect(js.code).toContain('_$_.update(');
422
+ expect(js.code).not.toContain('[1]');
423
+ });
424
+
372
425
  it('preserves generic type args in interface extends for Volar mappings', () => {
373
426
  const source = `
374
427
  interface PolymorphicProps<T extends keyof HTMLElementTagNameMap> {
@@ -414,16 +467,16 @@ export component App() {
414
467
  const code = `
415
468
  component Card(props) {
416
469
  <div class="card">
417
- <props.children />
470
+ {props.children}
418
471
  </div>
419
472
  }
420
473
 
421
474
  export component App() {
422
- <Card>
423
475
  component children() {
424
476
  <p>{'Card content here'}</p>
425
477
  }
426
- </Card>
478
+
479
+ <Card {children} />
427
480
 
428
481
  const test = 5;
429
482
 
@@ -433,7 +486,7 @@ export component App() {
433
486
  expect(() => compile(code, 'test.ripple')).not.toThrow();
434
487
  });
435
488
 
436
- it('converts nested component children into props in Volar mappings', () => {
489
+ it('rejects component declarations inside composite children', () => {
437
490
  const source = `
438
491
  export component App() {
439
492
  <ark.div class="host-class" data-value="42">
@@ -442,12 +495,48 @@ export component App() {
442
495
  }
443
496
  </ark.div>
444
497
  }
498
+ `;
499
+
500
+ expect(() => compile_to_volar_mappings(source, 'test.ripple')).toThrow(
501
+ /Component declarations cannot be used inside composite component children/,
502
+ );
503
+ });
504
+
505
+ it('preserves explicit component props in Volar mappings', () => {
506
+ const source = `
507
+ export component App() {
508
+ component asChild({ children, href, ...rest }: { href: string; [key: string]: any }) {
509
+ <a id="aschild-anchor" {href} {...rest} data-extra="yes">{'Link'}</a>
510
+ }
511
+
512
+ <ark.div class="host-class" data-value="42" {asChild} />
513
+ }
445
514
  `;
446
515
  const result = compile_to_volar_mappings(source, 'test.ripple').code;
447
516
 
448
- expect(result).toContain('<ark.div class="host-class" data-value="42" asChild={');
517
+ expect(result).toContain('<ark.div class="host-class" data-value="42" asChild={asChild}');
449
518
  expect(result).not.toContain('children={() =>');
450
- expect(result).not.toContain('function asChild');
519
+ });
520
+
521
+ it('merges explicit children prop with implicit children in client output', () => {
522
+ const source = `
523
+ component Card(props) {
524
+ <div>{props.children}</div>
525
+ }
526
+
527
+ export component App() {
528
+ const fallback = 'fallback';
529
+
530
+ <Card children={fallback}>
531
+ <span>{'content'}</span>
532
+ </Card>
533
+ }
534
+ `;
535
+
536
+ const result = compile(source, 'test.ripple', { mode: 'client' }).js.code;
537
+
538
+ expect((result.match(/children:/g) || []).length).toBe(1);
539
+ expect(result).toContain('children: _$_.normalize_children(fallback) ?? _$_.ripple_element(');
451
540
  });
452
541
 
453
542
  it('should not error on `this` MemberExpression with a UpdateExpression', () => {
@@ -1,5 +1,5 @@
1
1
  import type { Tracked, Props } from 'ripple';
2
- import { effect, flushSync, track, trackSplit } from 'ripple';
2
+ import { effect, flushSync, track } from 'ripple';
3
3
 
4
4
  describe('composite > props', () => {
5
5
  it('correctly handles default prop values', () => {
@@ -106,11 +106,8 @@ describe('composite > props', () => {
106
106
  });
107
107
 
108
108
  it('correctly retains prop accessors and reactivity when using rest props', () => {
109
- component Button(props: Props) {
110
- const [children, rest] = trackSplit(props, ['children']);
111
- <button {...rest.value}>
112
- <@children />
113
- </button>
109
+ component Button(&{ children, ...rest }: Props) {
110
+ <button {...rest}>{children}</button>
114
111
  <style>
115
112
  .on {
116
113
  color: blue;
@@ -121,10 +118,9 @@ describe('composite > props', () => {
121
118
  </style>
122
119
  }
123
120
 
124
- component Toggle(props: { pressed: Tracked<boolean> }) {
125
- const [pressed, rest] = trackSplit(props, ['pressed']);
121
+ component Toggle(&{ pressed, ...rest }: { pressed: Tracked<boolean> }) {
126
122
  const onClick = () => (pressed.value = !pressed.value);
127
- <Button {...rest.value} class={pressed.value ? 'on' : 'off'} {onClick}>{'button 1'}</Button>
123
+ <Button {...rest} class={pressed.value ? 'on' : 'off'} {onClick}>{'button 1'}</Button>
128
124
  <Button class={pressed.value ? 'on' : 'off'} {onClick}>{'button 2'}</Button>
129
125
  }
130
126
 
@@ -1,5 +1,5 @@
1
1
  import type { Props, Tracked } from 'ripple';
2
- import { RippleObject, effect, flushSync, track, trackSplit } from 'ripple';
2
+ import { RippleObject, effect, flushSync, track } from 'ripple';
3
3
 
4
4
  describe('composite > reactivity', () => {
5
5
  it('renders composite components with object state', () => {
@@ -227,46 +227,42 @@ describe('composite > reactivity', () => {
227
227
  expect(logs).toEqual(['Child effect: 1, 2, 3', 'Child effect: 2, 3, 4']);
228
228
  });
229
229
 
230
- it(
231
- 'keeps reactivity for spread props via intermediate components and usage of trackSplit()',
232
- () => {
233
- component App() {
234
- let &[count] = track(0);
235
- <CounterWrapper {count} up={() => count++} down={() => count--} />
236
- }
230
+ it('keeps reactivity for spread props via intermediate components and lazy destructuring', () => {
231
+ component App() {
232
+ let &[count] = track(0);
233
+ <CounterWrapper {count} up={() => count++} down={() => count--} />
234
+ }
237
235
 
238
- component CounterWrapper(props: Props) {
239
- <div>
240
- <Counter {...props} />
241
- </div>
242
- }
236
+ component CounterWrapper(props: Props) {
237
+ <div>
238
+ <Counter {...props} />
239
+ </div>
240
+ }
243
241
 
244
- component Counter(props: Props) {
245
- let [count, up, down, rest] = trackSplit(props, ['count', 'up', 'down']);
246
- <button onClick={() => up.value()}>{'UP'}</button>
247
- <button onClick={() => down.value()}>{'DOWN'}</button>
248
- <span {...rest.value}>{`Counter: ${count.value}`}</span>
249
- }
242
+ component Counter(&{ count, up, down, ...rest }: Props) {
243
+ <button onClick={() => up()}>{'UP'}</button>
244
+ <button onClick={() => down()}>{'DOWN'}</button>
245
+ <span {...rest}>{`Counter: ${count}`}</span>
246
+ }
250
247
 
251
- render(App);
248
+ render(App);
252
249
 
253
- const buttonIncrement = container.querySelectorAll('button')[0];
254
- const buttonDecrement = container.querySelectorAll('button')[1];
255
- const span = container.querySelector('span');
250
+ const buttonIncrement = container.querySelectorAll('button')[0];
251
+ const buttonDecrement = container.querySelectorAll('button')[1];
252
+ const span = container.querySelector('span');
256
253
 
257
- expect(span.textContent).toBe('Counter: 0');
254
+ expect(span.textContent).toBe('Counter: 0');
258
255
 
259
- buttonIncrement.click();
260
- flushSync();
256
+ buttonIncrement.click();
257
+ flushSync();
261
258
 
262
- expect(span.textContent).toBe('Counter: 1');
259
+ expect(span.textContent).toBe('Counter: 1');
263
260
 
264
- buttonDecrement.click();
265
- flushSync();
261
+ buttonDecrement.click();
262
+ flushSync();
266
263
 
267
- expect(span.textContent).toBe('Counter: 0');
268
- },
269
- );
264
+ expect(span.textContent).toBe('Counter: 0');
265
+ });
270
266
 
271
267
  it('keeps reactivity on elements for element spreads and adds / removes dynamic props', () => {
272
268
  component App() {
@@ -295,16 +291,19 @@ describe('composite > reactivity', () => {
295
291
  </div>
296
292
  }
297
293
 
298
- component Counter(props: {
294
+ component Counter(&{
295
+ count,
296
+ up,
297
+ ...rest
298
+ }: {
299
299
  count: number;
300
300
  up: () => void;
301
301
  double: Tracked<number>;
302
302
  another?: number;
303
303
  extra: number;
304
304
  }) {
305
- let [count, up, rest] = trackSplit(props, ['count', 'up']);
306
- <div {...rest.value}>{`Counter: ${count.value} Double: ${props.double.value}`}</div>
307
- <button onClick={() => up.value()}>{'UP'}</button>
305
+ <div {...rest}>{`Counter: ${count} Double: ${rest.double.value}`}</div>
306
+ <button onClick={() => up()}>{'UP'}</button>
308
307
  }
309
308
 
310
309
  render(App);
@@ -32,20 +32,22 @@ describe('composite > render', () => {
32
32
  component Button({ A, B, children }: { A: () => void; B: () => void; children: () => void }) {
33
33
  <div>
34
34
  <A />
35
- <children />
35
+ {children}
36
36
  <B />
37
37
  </div>
38
38
  }
39
39
 
40
40
  component App() {
41
- <Button>
42
- component A() {
43
- <div>{'I am A'}</div>
44
- }
41
+ component A() {
42
+ <div>{'I am A'}</div>
43
+ }
44
+
45
+ component B() {
46
+ <div>{'I am B'}</div>
47
+ }
48
+
49
+ <Button {A} {B}>
45
50
  <div>{'other text'}</div>
46
- component B() {
47
- <div>{'I am B'}</div>
48
- }
49
51
  </Button>
50
52
  }
51
53
 
@@ -62,21 +64,51 @@ describe('composite > render', () => {
62
64
  }
63
65
 
64
66
  component Child({ children, ...rest }: { children: string; class: string }) {
65
- <button {...rest}>
66
- <children />
67
- </button>
67
+ <button {...rest}>{children}</button>
68
68
  }
69
69
 
70
70
  render(App);
71
71
  expect(container).toMatchSnapshot();
72
72
  });
73
73
 
74
+ it('renders explicit text around children', () => {
75
+ component Frame({ children }) {
76
+ <div class="frame">
77
+ {text 'before'}
78
+ {children}
79
+ {text 'after'}
80
+ </div>
81
+ }
82
+
83
+ component App() {
84
+ <Frame>
85
+ <span class="middle">{'middle'}</span>
86
+ </Frame>
87
+ }
88
+
89
+ render(App);
90
+
91
+ const frame = /** @type {HTMLDivElement} */ (container.querySelector('.frame'));
92
+ const nodes = Array.from(frame.childNodes).filter(
93
+ (node) => node.nodeType !== Node.COMMENT_NODE,
94
+ );
95
+
96
+ expect(nodes).toHaveLength(3);
97
+ expect(nodes[0].nodeType).toBe(Node.TEXT_NODE);
98
+ expect(nodes[0].textContent).toBe('before');
99
+ expect((/** @type {HTMLElement} */ (nodes[1])).outerHTML).toBe(
100
+ '<span class="middle">middle</span>',
101
+ );
102
+ expect(nodes[2].nodeType).toBe(Node.TEXT_NODE);
103
+ expect(nodes[2].textContent).toBe('after');
104
+ });
105
+
74
106
  it('preserves distinct scoped ripple hashes for wrapper and child content', () => {
75
107
  component App() {
76
108
  component Wrapper({ children }) {
77
109
  <div class="green">
78
110
  {'Wrapper'}
79
- <children />
111
+ {children}
80
112
  </div>
81
113
 
82
114
  <style>
@@ -141,7 +173,7 @@ describe('composite > render', () => {
141
173
  [key: string]: any;
142
174
  }) {
143
175
  <div {...props}>
144
- <children />
176
+ {children}
145
177
  // @ts-expect-error - intentionally testing behavior when a component is undefined
146
178
  <NonExistent />
147
179
  </div>
@@ -353,7 +353,7 @@ export component Test({ children }) {
353
353
  <div>
354
354
  <p class="before">{'before'}</p>
355
355
 
356
- <children />
356
+ {children}
357
357
 
358
358
  <p class="foo">
359
359
  <span>{'foo'}</span>
@@ -379,8 +379,8 @@ export component Test({ children }) {
379
379
  const { css } = compile(source, 'test.ripple');
380
380
 
381
381
  expect(css).toMatch(/\.before\.ripple-[a-z0-9]+ \+ \.foo:where\(\.ripple-[a-z0-9]+\) {/);
382
- expect((css.match(/\.x\ /g) || []).length).toBe(5);
383
- expect((css.match(/\(unused\) :global\(\.x\) /g) || []).length).toBe(1);
382
+ expect((css.match(/\.x\ /g) || []).length).toBe(0);
383
+ expect((css.match(/\(unused\) :global\(\.x\) /g) || []).length).toBe(6);
384
384
  expect(css).toContain('(unused) :global(.x) + .bar {');
385
385
  });
386
386