ripple 0.3.9 → 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 (60) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +2 -2
  3. package/src/compiler/phases/1-parse/index.js +25 -15
  4. package/src/compiler/phases/2-analyze/index.js +35 -88
  5. package/src/compiler/phases/2-analyze/prune.js +13 -5
  6. package/src/compiler/phases/3-transform/client/index.js +188 -56
  7. package/src/compiler/phases/3-transform/server/index.js +62 -40
  8. package/src/compiler/types/index.d.ts +9 -1
  9. package/src/compiler/types/parse.d.ts +2 -0
  10. package/src/compiler/utils.js +101 -1
  11. package/src/runtime/element.js +39 -0
  12. package/src/runtime/internal/client/composite.js +10 -6
  13. package/src/runtime/internal/client/expression.js +218 -0
  14. package/src/runtime/internal/client/index.js +4 -0
  15. package/src/runtime/internal/client/portal.js +12 -6
  16. package/src/runtime/internal/server/index.js +26 -1
  17. package/tests/client/basic/basic.components.test.ripple +85 -87
  18. package/tests/client/basic/basic.errors.test.ripple +4 -8
  19. package/tests/client/basic/basic.rendering.test.ripple +23 -8
  20. package/tests/client/capture-error.js +12 -0
  21. package/tests/client/compiler/compiler.basic.test.ripple +76 -6
  22. package/tests/client/composite/composite.props.test.ripple +1 -3
  23. package/tests/client/composite/composite.render.test.ripple +45 -13
  24. package/tests/client/css/global-additional-cases.test.ripple +3 -3
  25. package/tests/client/svg.test.ripple +4 -4
  26. package/tests/hydration/basic.test.js +23 -0
  27. package/tests/hydration/compiled/client/basic.js +118 -66
  28. package/tests/hydration/compiled/client/composite.js +90 -37
  29. package/tests/hydration/compiled/client/events.js +18 -18
  30. package/tests/hydration/compiled/client/for.js +62 -62
  31. package/tests/hydration/compiled/client/head.js +10 -10
  32. package/tests/hydration/compiled/client/hmr.js +13 -10
  33. package/tests/hydration/compiled/client/html.js +274 -236
  34. package/tests/hydration/compiled/client/if-children.js +41 -35
  35. package/tests/hydration/compiled/client/if.js +2 -2
  36. package/tests/hydration/compiled/client/mixed-control-flow.js +12 -12
  37. package/tests/hydration/compiled/client/nested-control-flow.js +46 -46
  38. package/tests/hydration/compiled/client/portal.js +8 -8
  39. package/tests/hydration/compiled/client/reactivity.js +14 -14
  40. package/tests/hydration/compiled/client/return.js +2 -2
  41. package/tests/hydration/compiled/client/try.js +4 -4
  42. package/tests/hydration/compiled/server/basic.js +64 -31
  43. package/tests/hydration/compiled/server/composite.js +62 -29
  44. package/tests/hydration/compiled/server/hmr.js +24 -37
  45. package/tests/hydration/compiled/server/html.js +472 -611
  46. package/tests/hydration/compiled/server/if-children.js +77 -103
  47. package/tests/hydration/compiled/server/portal.js +8 -8
  48. package/tests/hydration/components/basic.ripple +15 -5
  49. package/tests/hydration/components/composite.ripple +13 -1
  50. package/tests/hydration/components/hmr.ripple +1 -3
  51. package/tests/hydration/components/html.ripple +13 -35
  52. package/tests/hydration/components/if-children.ripple +4 -8
  53. package/tests/hydration/composite.test.js +11 -0
  54. package/tests/server/basic.attributes.test.ripple +50 -0
  55. package/tests/server/basic.components.test.ripple +22 -28
  56. package/tests/server/basic.test.ripple +12 -0
  57. package/tests/server/compiler.test.ripple +25 -8
  58. package/tests/server/composite.props.test.ripple +1 -3
  59. package/tests/server/style-identifier.test.ripple +2 -4
  60. package/types/index.d.ts +9 -2
@@ -70,9 +70,7 @@ describe('basic client > errors', () => {
70
70
 
71
71
  expect(() => {
72
72
  compile(code, 'test.ripple');
73
- }).toThrow(
74
- '`children` cannot be rendered using text interpolation. Use `<children />` instead.',
75
- );
73
+ }).not.toThrow();
76
74
  });
77
75
 
78
76
  it('should throw error for interpolating props.children as text', () => {
@@ -84,9 +82,7 @@ describe('basic client > errors', () => {
84
82
 
85
83
  expect(() => {
86
84
  compile(code, 'test.ripple');
87
- }).toThrow(
88
- '`children` cannot be rendered using text interpolation. Use `<children />` instead.',
89
- );
85
+ }).not.toThrow();
90
86
  });
91
87
 
92
88
  it('should throw error for calling children as a function', () => {
@@ -99,7 +95,7 @@ describe('basic client > errors', () => {
99
95
  expect(() => {
100
96
  compile(code, 'test.ripple');
101
97
  }).toThrow(
102
- '`children` cannot be called like a regular function. Use element syntax instead, e.g. `<children />` or `<props.children />`.',
98
+ '`children` cannot be called like a regular function. Render it with `{children}` or `{props.children}` instead.',
103
99
  );
104
100
  });
105
101
 
@@ -113,7 +109,7 @@ describe('basic client > errors', () => {
113
109
  expect(() => {
114
110
  compile(code, 'test.ripple');
115
111
  }).toThrow(
116
- '`children` cannot be called like a regular function. Use element syntax instead, e.g. `<children />` or `<props.children />`.',
112
+ '`children` cannot be called like a regular function. Render it with `{children}` or `{props.children}` instead.',
117
113
  );
118
114
  });
119
115
 
@@ -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>;
@@ -433,16 +467,16 @@ export component App() {
433
467
  const code = `
434
468
  component Card(props) {
435
469
  <div class="card">
436
- <props.children />
470
+ {props.children}
437
471
  </div>
438
472
  }
439
473
 
440
474
  export component App() {
441
- <Card>
442
475
  component children() {
443
476
  <p>{'Card content here'}</p>
444
477
  }
445
- </Card>
478
+
479
+ <Card {children} />
446
480
 
447
481
  const test = 5;
448
482
 
@@ -452,7 +486,7 @@ export component App() {
452
486
  expect(() => compile(code, 'test.ripple')).not.toThrow();
453
487
  });
454
488
 
455
- it('converts nested component children into props in Volar mappings', () => {
489
+ it('rejects component declarations inside composite children', () => {
456
490
  const source = `
457
491
  export component App() {
458
492
  <ark.div class="host-class" data-value="42">
@@ -461,12 +495,48 @@ export component App() {
461
495
  }
462
496
  </ark.div>
463
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
+ }
464
514
  `;
465
515
  const result = compile_to_volar_mappings(source, 'test.ripple').code;
466
516
 
467
- 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}');
468
518
  expect(result).not.toContain('children={() =>');
469
- 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(');
470
540
  });
471
541
 
472
542
  it('should not error on `this` MemberExpression with a UpdateExpression', () => {
@@ -107,9 +107,7 @@ describe('composite > props', () => {
107
107
 
108
108
  it('correctly retains prop accessors and reactivity when using rest props', () => {
109
109
  component Button(&{ children, ...rest }: Props) {
110
- <button {...rest}>
111
- <@children />
112
- </button>
110
+ <button {...rest}>{children}</button>
113
111
  <style>
114
112
  .on {
115
113
  color: blue;
@@ -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
 
@@ -234,7 +234,7 @@ describe('SVG namespace handling', () => {
234
234
  it('should render SVG with children as svg elements', () => {
235
235
  component SVG({ children }: PropsWithChildren<{}>) {
236
236
  <svg width={20} height={20} fill="blue" viewBox="0 0 30 10" preserveAspectRatio="none">
237
- <children />
237
+ {children}
238
238
  </svg>
239
239
  }
240
240
 
@@ -285,7 +285,7 @@ describe('SVG namespace handling', () => {
285
285
  it('should render SVG with children as dynamic elements', () => {
286
286
  component SVG({ children }: PropsWithChildren<{}>) {
287
287
  <svg width={20} height={20} fill="blue" viewBox="0 0 30 10" preserveAspectRatio="none">
288
- <children />
288
+ {children}
289
289
  </svg>
290
290
  }
291
291
 
@@ -308,7 +308,7 @@ describe('SVG namespace handling', () => {
308
308
  it('should render SVG with children as dynamic components', () => {
309
309
  component SVG({ children }: PropsWithChildren<{}>) {
310
310
  <svg width={20} height={20} fill="blue" viewBox="0 0 30 10" preserveAspectRatio="none">
311
- <children />
311
+ {children}
312
312
  </svg>
313
313
  }
314
314
 
@@ -336,7 +336,7 @@ describe('SVG namespace handling', () => {
336
336
  component SVG({ children }: PropsWithChildren<{}>) {
337
337
  let &[tag] = track('svg');
338
338
  <@tag width={100} height={50} fill="red" viewBox="0 0 30 10" preserveAspectRatio="none">
339
- <children />
339
+ {children}
340
340
  </@tag>
341
341
  }
342
342
 
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
+ import { flushSync } from 'ripple';
2
3
  import { hydrateComponent, container } from '../setup-hydration.js';
3
4
 
4
5
  // Import server-compiled components
@@ -59,6 +60,28 @@ describe('hydration > basic', () => {
59
60
  expect(container.innerHTML).toBeHtml('<div>42</div><span>COMPUTED</span>');
60
61
  });
61
62
 
63
+ it('restores text children after hydrating away initial server text', async () => {
64
+ await hydrateComponent(
65
+ ServerComponents.TextPropWithToggle,
66
+ ClientComponents.TextPropWithToggle,
67
+ );
68
+
69
+ expect(container.querySelector('.text-prop')?.textContent).toBe('');
70
+
71
+ /** @type {any} */ (container.querySelector('.show-text'))?.click();
72
+ flushSync();
73
+
74
+ expect(container.querySelector('.text-prop')?.textContent).toBe('hello');
75
+
76
+ // Verify text is placed between hydration markers, not before anchor
77
+ const innerHTML = container.querySelector('.text-prop')?.innerHTML ?? '';
78
+ const textIndex = innerHTML.indexOf('hello');
79
+ const startMarker = innerHTML.indexOf('<!--[-->');
80
+ const endMarker = innerHTML.indexOf('<!--]-->');
81
+ expect(textIndex).toBeGreaterThan(startMarker);
82
+ expect(textIndex).toBeLessThan(endMarker);
83
+ });
84
+
62
85
  it('hydrates static child component followed by sibling content', async () => {
63
86
  await hydrateComponent(
64
87
  ServerComponents.StaticChildWithSiblings,