ripple 0.3.9 → 0.3.11

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 (70) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +2 -2
  3. package/src/compiler/errors.js +1 -1
  4. package/src/compiler/index.d.ts +3 -1
  5. package/src/compiler/phases/1-parse/index.js +195 -23
  6. package/src/compiler/phases/2-analyze/index.js +266 -108
  7. package/src/compiler/phases/2-analyze/prune.js +13 -5
  8. package/src/compiler/phases/3-transform/client/index.js +304 -80
  9. package/src/compiler/phases/3-transform/server/index.js +108 -43
  10. package/src/compiler/types/index.d.ts +28 -3
  11. package/src/compiler/types/parse.d.ts +3 -1
  12. package/src/compiler/utils.js +275 -1
  13. package/src/runtime/element.js +39 -0
  14. package/src/runtime/index-client.js +14 -4
  15. package/src/runtime/internal/client/composite.js +10 -6
  16. package/src/runtime/internal/client/expression.js +280 -0
  17. package/src/runtime/internal/client/index.js +4 -0
  18. package/src/runtime/internal/client/portal.js +12 -6
  19. package/src/runtime/internal/server/index.js +26 -1
  20. package/src/utils/builders.js +30 -0
  21. package/tests/client/basic/__snapshots__/basic.rendering.test.ripple.snap +1 -0
  22. package/tests/client/basic/basic.components.test.ripple +85 -87
  23. package/tests/client/basic/basic.errors.test.ripple +4 -8
  24. package/tests/client/basic/basic.rendering.test.ripple +27 -10
  25. package/tests/client/capture-error.js +12 -0
  26. package/tests/client/compiler/compiler.basic.test.ripple +76 -6
  27. package/tests/client/composite/composite.props.test.ripple +1 -3
  28. package/tests/client/composite/composite.render.test.ripple +91 -13
  29. package/tests/client/css/global-additional-cases.test.ripple +3 -3
  30. package/tests/client/return.test.ripple +101 -0
  31. package/tests/client/svg.test.ripple +4 -4
  32. package/tests/client/tsx.test.ripple +486 -0
  33. package/tests/hydration/basic.test.js +23 -0
  34. package/tests/hydration/compiled/client/basic.js +111 -75
  35. package/tests/hydration/compiled/client/composite.js +81 -46
  36. package/tests/hydration/compiled/client/events.js +18 -63
  37. package/tests/hydration/compiled/client/for.js +90 -183
  38. package/tests/hydration/compiled/client/head.js +10 -25
  39. package/tests/hydration/compiled/client/hmr.js +10 -13
  40. package/tests/hydration/compiled/client/html.js +251 -380
  41. package/tests/hydration/compiled/client/if-children.js +35 -45
  42. package/tests/hydration/compiled/client/if.js +2 -2
  43. package/tests/hydration/compiled/client/mixed-control-flow.js +24 -72
  44. package/tests/hydration/compiled/client/nested-control-flow.js +115 -391
  45. package/tests/hydration/compiled/client/portal.js +8 -20
  46. package/tests/hydration/compiled/client/reactivity.js +14 -47
  47. package/tests/hydration/compiled/client/return.js +2 -5
  48. package/tests/hydration/compiled/client/try.js +4 -4
  49. package/tests/hydration/compiled/server/basic.js +64 -31
  50. package/tests/hydration/compiled/server/composite.js +62 -29
  51. package/tests/hydration/compiled/server/hmr.js +24 -37
  52. package/tests/hydration/compiled/server/html.js +472 -611
  53. package/tests/hydration/compiled/server/if-children.js +77 -103
  54. package/tests/hydration/compiled/server/portal.js +8 -8
  55. package/tests/hydration/components/basic.ripple +15 -5
  56. package/tests/hydration/components/composite.ripple +13 -1
  57. package/tests/hydration/components/hmr.ripple +1 -3
  58. package/tests/hydration/components/html.ripple +13 -35
  59. package/tests/hydration/components/if-children.ripple +4 -8
  60. package/tests/hydration/composite.test.js +11 -0
  61. package/tests/server/basic.attributes.test.ripple +50 -0
  62. package/tests/server/basic.components.test.ripple +22 -28
  63. package/tests/server/basic.test.ripple +12 -0
  64. package/tests/server/compiler.test.ripple +25 -8
  65. package/tests/server/composite.props.test.ripple +1 -3
  66. package/tests/server/style-identifier.test.ripple +2 -4
  67. package/tests/utils/compiler-compat-config.test.js +38 -0
  68. package/tests/utils/vite-plugin-config.test.js +113 -0
  69. package/tsconfig.typecheck.json +2 -1
  70. package/types/index.d.ts +8 -11
@@ -2498,3 +2498,104 @@ describe('early return in client components', () => {
2498
2498
  });
2499
2499
  });
2500
2500
  });
2501
+
2502
+ describe('throw statements in if blocks', () => {
2503
+ it('allows if statement with throw in then body', () => {
2504
+ const code = `
2505
+ export default component App() {
2506
+ let error = true;
2507
+ if (error) {
2508
+ throw new Error('Test error');
2509
+ }
2510
+ <div>{'no error'}</div>
2511
+ }
2512
+ `;
2513
+ expect(() => {
2514
+ compile(code, 'test.ripple');
2515
+ }).not.toThrow();
2516
+ });
2517
+
2518
+ it('allows if statement with throw in else body', () => {
2519
+ const code = `
2520
+ export default component App() {
2521
+ let error = false;
2522
+ if (error) {
2523
+ <div>{'no error'}</div>
2524
+ } else {
2525
+ throw new Error('Test error');
2526
+ }
2527
+ }
2528
+ `;
2529
+ expect(() => {
2530
+ compile(code, 'test.ripple');
2531
+ }).not.toThrow();
2532
+ });
2533
+
2534
+ it('allows if statement with throw in both bodies', () => {
2535
+ const code = `
2536
+ export default component App() {
2537
+ let mode = 'error';
2538
+ if (mode === 'a') {
2539
+ <div>{'a'}</div>
2540
+ } else if (mode === 'b') {
2541
+ <div>{'b'}</div>
2542
+ } else {
2543
+ throw new Error('Unknown mode');
2544
+ }
2545
+ }
2546
+ `;
2547
+ expect(() => {
2548
+ compile(code, 'test.ripple');
2549
+ }).not.toThrow();
2550
+ });
2551
+
2552
+ it('allows nested if with throw', () => {
2553
+ const code = `
2554
+ export default component App() {
2555
+ let a = true;
2556
+ let b = true;
2557
+ if (a) {
2558
+ if (b) {
2559
+ throw new Error('Both true');
2560
+ }
2561
+ <div>{'a only'}</div>
2562
+ }
2563
+ <div>{'rest'}</div>
2564
+ }
2565
+ `;
2566
+ expect(() => {
2567
+ compile(code, 'test.ripple');
2568
+ }).not.toThrow();
2569
+ });
2570
+
2571
+ it('allows throw with reactive condition', () => {
2572
+ const code = `
2573
+ export default component App() {
2574
+ let &[error] = track(false);
2575
+ if (error) {
2576
+ throw new Error('Error occurred');
2577
+ }
2578
+ <div>{'success'}</div>
2579
+ }
2580
+ `;
2581
+ expect(() => {
2582
+ compile(code, 'test.ripple');
2583
+ }).not.toThrow();
2584
+ });
2585
+
2586
+ it('allows throw with template in then body and throw in else body', () => {
2587
+ const code = `
2588
+ export default component App() {
2589
+ let error = true;
2590
+ if (error) {
2591
+ <div>{'error case'}</div>
2592
+ } else {
2593
+ throw new Error('No error');
2594
+ }
2595
+ }
2596
+ `;
2597
+ expect(() => {
2598
+ compile(code, 'test.ripple');
2599
+ }).not.toThrow();
2600
+ });
2601
+ });
@@ -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
 
@@ -0,0 +1,486 @@
1
+ import { flushSync, track } from 'ripple';
2
+
3
+ describe('tsx expression', () => {
4
+ it('renders a basic tsx element', () => {
5
+ component App() {
6
+ const el = <tsx><div>hello world</div></tsx>;
7
+ {el}
8
+ }
9
+ render(App);
10
+ expect(container.textContent).toBe('hello world');
11
+ expect(container.querySelector('div')).toBeTruthy();
12
+ });
13
+
14
+ it('renders a tsx element assigned to a variable', () => {
15
+ component App() {
16
+ const el = <tsx><span class="test">content</span></tsx>;
17
+ {el}
18
+ }
19
+ render(App);
20
+ const span = container.querySelector('span.test');
21
+ expect(span).toBeTruthy();
22
+ expect(span.textContent).toBe('content');
23
+ });
24
+
25
+ it('renders a tsx element with multiple children', () => {
26
+ component App() {
27
+ const el = <tsx><div>first</div><div>second</div></tsx>;
28
+ {el}
29
+ }
30
+ render(App);
31
+ const divs = container.querySelectorAll('div');
32
+ expect(divs.length).toBe(2);
33
+ expect(divs[0].textContent).toBe('first');
34
+ expect(divs[1].textContent).toBe('second');
35
+ });
36
+
37
+ it('renders a tsx element with nested elements', () => {
38
+ component App() {
39
+ const el = <tsx>
40
+ <div class="outer">
41
+ <span class="inner">nested</span>
42
+ </div>
43
+ </tsx>;
44
+ {el}
45
+ }
46
+ render(App);
47
+ const outer = container.querySelector('div.outer');
48
+ expect(outer).toBeTruthy();
49
+ const inner = outer.querySelector('span.inner');
50
+ expect(inner).toBeTruthy();
51
+ expect(inner.textContent).toBe('nested');
52
+ });
53
+
54
+ it('renders a tsx element inline in a parent element', () => {
55
+ component App() {
56
+ const el = <tsx><span>inline</span></tsx>;
57
+ <div class="parent">{el}</div>
58
+ }
59
+ render(App);
60
+ const parent = container.querySelector('div.parent');
61
+ expect(parent).toBeTruthy();
62
+ expect(parent.querySelector('span')).toBeTruthy();
63
+ expect(parent.textContent).toBe('inline');
64
+ });
65
+
66
+ it('renders a tsx element with reactive expressions', () => {
67
+ component App() {
68
+ let &[count] = track(0);
69
+ const el = <tsx>
70
+ <div>
71
+ {'count: ' + count}
72
+ </div>
73
+ </tsx>;
74
+ {el}
75
+ <button onClick={() => count++}>{'increment'}</button>
76
+ }
77
+ render(App);
78
+ expect(container.querySelector('div').textContent).toBe('count: 0');
79
+
80
+ container.querySelector('button').click();
81
+ flushSync();
82
+ expect(container.querySelector('div').textContent).toBe('count: 1');
83
+ });
84
+
85
+ it('conditionally renders tsx elements', () => {
86
+ component App() {
87
+ let &[show] = track(true);
88
+ const el = <tsx><div class="tsx-content">visible</div></tsx>;
89
+
90
+ if (show) {
91
+ {el}
92
+ }
93
+ <button onClick={() => (show = !show)}>{'toggle'}</button>
94
+ }
95
+ render(App);
96
+ expect(container.querySelector('.tsx-content')).toBeTruthy();
97
+
98
+ container.querySelector('button').click();
99
+ flushSync();
100
+ expect(container.querySelector('.tsx-content')).toBeFalsy();
101
+
102
+ container.querySelector('button').click();
103
+ flushSync();
104
+ expect(container.querySelector('.tsx-content')).toBeTruthy();
105
+ });
106
+
107
+ it('renders tsx element passed as children prop', () => {
108
+ component Child(&{ children }: { children: any }) {
109
+ <div class="wrapper">{children}</div>
110
+ }
111
+
112
+ component App() {
113
+ const el = <tsx><span>from tsx</span></tsx>;
114
+ <Child children={el} />
115
+ }
116
+ render(App);
117
+ const wrapper = container.querySelector('.wrapper');
118
+ expect(wrapper).toBeTruthy();
119
+ expect(wrapper.querySelector('span')).toBeTruthy();
120
+ expect(wrapper.textContent).toBe('from tsx');
121
+ });
122
+
123
+ it('renders tsx element with text content only', () => {
124
+ component App() {
125
+ const el = <tsx>just text</tsx>;
126
+ {el}
127
+ }
128
+ render(App);
129
+ expect(container.textContent).toBe('just text');
130
+ });
131
+
132
+ it('renders tsx element with static attributes', () => {
133
+ component App() {
134
+ const el = <tsx>
135
+ <div id="my-id" class="my-class" data-testid="test" aria-label="label">content</div>
136
+ </tsx>;
137
+ {el}
138
+ }
139
+ render(App);
140
+ const div = container.querySelector('div');
141
+ expect(div.id).toBe('my-id');
142
+ expect(div.className).toBe('my-class');
143
+ expect(div.getAttribute('data-testid')).toBe('test');
144
+ expect(div.getAttribute('aria-label')).toBe('label');
145
+ });
146
+
147
+ it('renders tsx element with dynamic attribute values', () => {
148
+ component App() {
149
+ let &[name] = track('initial');
150
+ const el = <tsx><div id={name} class={'cls-' + name}>content</div></tsx>;
151
+ {el}
152
+ <button onClick={() => (name = 'updated')}>{'update'}</button>
153
+ }
154
+ render(App);
155
+ const div = container.querySelector('div');
156
+ expect(div.id).toBe('initial');
157
+ expect(div.className).toBe('cls-initial');
158
+
159
+ container.querySelector('button').click();
160
+ flushSync();
161
+ expect(div.id).toBe('updated');
162
+ expect(div.className).toBe('cls-updated');
163
+ });
164
+
165
+ it('renders tsx element with event handlers', () => {
166
+ component App() {
167
+ let &[clicked] = track(false);
168
+ const el = <tsx>
169
+ <button onClick={() => (clicked = true)}>
170
+ {'click me'}
171
+ </button>
172
+ </tsx>;
173
+ {el}
174
+ <div class="status">{clicked ? 'clicked' : 'not clicked'}</div>
175
+ }
176
+ render(App);
177
+ expect(container.querySelector('.status').textContent).toBe('not clicked');
178
+
179
+ container.querySelector('button').click();
180
+ flushSync();
181
+ expect(container.querySelector('.status').textContent).toBe('clicked');
182
+ });
183
+
184
+ it('renders tsx element with boolean attributes', () => {
185
+ component App() {
186
+ let &[isDisabled] = track(true);
187
+ const el = <tsx>
188
+ <button disabled={isDisabled}>
189
+ {'btn'}
190
+ </button>
191
+ </tsx>;
192
+ {el}
193
+ <button class="toggle" onClick={() => (isDisabled = !isDisabled)}>{'toggle'}</button>
194
+ }
195
+ render(App);
196
+ expect(container.querySelector('button:not(.toggle)').disabled).toBe(true);
197
+
198
+ container.querySelector('.toggle').click();
199
+ flushSync();
200
+ expect(container.querySelector('button:not(.toggle)').disabled).toBe(false);
201
+ });
202
+
203
+ it('renders tsx element with style attribute', () => {
204
+ component App() {
205
+ let &[color] = track('red');
206
+ const el = <tsx><div style={'color: ' + color}>styled</div></tsx>;
207
+ {el}
208
+ <button onClick={() => (color = 'blue')}>{'change color'}</button>
209
+ }
210
+ render(App);
211
+ expect(container.querySelector('div').style.color).toBe('red');
212
+
213
+ container.querySelector('button').click();
214
+ flushSync();
215
+ expect(container.querySelector('div').style.color).toBe('blue');
216
+ });
217
+
218
+ it('renders tsx element with multiple dynamic attributes', () => {
219
+ component App() {
220
+ let &[index] = track(0);
221
+ const el = <tsx>
222
+ <div id={'item-' + index} class={'item pos-' + index} data-index={index} title={'Item ' +
223
+ index}>
224
+ {'Item ' + index}
225
+ </div>
226
+ </tsx>;
227
+ {el}
228
+ <button onClick={() => index++}>{'next'}</button>
229
+ }
230
+ render(App);
231
+ const div = container.querySelector('div');
232
+ expect(div.id).toBe('item-0');
233
+ expect(div.className).toBe('item pos-0');
234
+ expect(div.getAttribute('data-index')).toBe('0');
235
+ expect(div.title).toBe('Item 0');
236
+
237
+ container.querySelector('button').click();
238
+ flushSync();
239
+ expect(div.id).toBe('item-1');
240
+ expect(div.className).toBe('item pos-1');
241
+ expect(div.getAttribute('data-index')).toBe('1');
242
+ expect(div.title).toBe('Item 1');
243
+ });
244
+
245
+ it('renders tsx passed directly as component prop', () => {
246
+ component Wrapper(&{ content }: { content: any }) {
247
+ <div class="wrapper">{content}</div>
248
+ }
249
+
250
+ component App() {
251
+ <Wrapper content={<tsx><span class="inner">direct prop</span></tsx>} />
252
+ }
253
+ render(App);
254
+ const wrapper = container.querySelector('.wrapper');
255
+ expect(wrapper).toBeTruthy();
256
+ expect(wrapper.querySelector('.inner')).toBeTruthy();
257
+ expect(wrapper.textContent).toBe('direct prop');
258
+ });
259
+
260
+ it('renders tsx passed directly as children prop', () => {
261
+ component Card(&{ title, children }: { title: any; children: any }) {
262
+ <div class="card">
263
+ <h2 class="card-title">{title}</h2>
264
+ <div class="card-body">{children}</div>
265
+ </div>
266
+ }
267
+
268
+ component App() {
269
+ <Card
270
+ title={<tsx><span class="bold">Title</span></tsx>}
271
+ children={<tsx><p>Card content here</p></tsx>}
272
+ />
273
+ }
274
+ render(App);
275
+ const card = container.querySelector('.card');
276
+ expect(card.querySelector('.card-title .bold').textContent).toBe('Title');
277
+ expect(card.querySelector('.card-body p').textContent).toBe('Card content here');
278
+ });
279
+
280
+ it('renders tsx from function defined outside component', () => {
281
+ function createBadge(label: string) {
282
+ return <tsx>
283
+ <span class="badge">
284
+ {label}
285
+ </span>
286
+ </tsx>;
287
+ }
288
+
289
+ component App() {
290
+ const badge = createBadge('New');
291
+ {badge}
292
+ }
293
+ render(App);
294
+ expect(container.querySelector('.badge')).toBeTruthy();
295
+ expect(container.querySelector('.badge').textContent).toBe('New');
296
+ });
297
+
298
+ it('renders tsx from function with multiple elements', () => {
299
+ function createListItem(item: string) {
300
+ return <tsx>
301
+ <li class="list-item">
302
+ {item}
303
+ </li>
304
+ </tsx>;
305
+ }
306
+
307
+ component App() {
308
+ const items = ['Apple', 'Banana', 'Cherry'];
309
+ <ul>
310
+ for (const item of items) {
311
+ {createListItem(item)}
312
+ }
313
+ </ul>
314
+ }
315
+ render(App);
316
+ const listItems = container.querySelectorAll('.list-item');
317
+ expect(listItems.length).toBe(3);
318
+ expect(listItems[0].textContent).toBe('Apple');
319
+ expect(listItems[1].textContent).toBe('Banana');
320
+ expect(listItems[2].textContent).toBe('Cherry');
321
+ });
322
+
323
+ it('renders tsx from factory function passed to component', () => {
324
+ component List(&{ renderItem, items }: { renderItem: (item: string) => any; items: string[] }) {
325
+ <ul class="list">
326
+ for (const item of items) {
327
+ {renderItem(item)}
328
+ }
329
+ </ul>
330
+ }
331
+
332
+ function itemRenderer(item: string) {
333
+ return <tsx>
334
+ <li>
335
+ <span class="item-content">
336
+ {item}
337
+ </span>
338
+ </li>
339
+ </tsx>;
340
+ }
341
+
342
+ component App() {
343
+ <List items={['One', 'Two', 'Three']} renderItem={itemRenderer} />
344
+ }
345
+ render(App);
346
+ const items = container.querySelectorAll('.item-content');
347
+ expect(items.length).toBe(3);
348
+ expect(items[0].textContent).toBe('One');
349
+ expect(items[1].textContent).toBe('Two');
350
+ expect(items[2].textContent).toBe('Three');
351
+ });
352
+
353
+ it('renders tsx from function with reactive state', () => {
354
+ function createCounter(label: string, getCount: () => number) {
355
+ return <tsx>
356
+ <div class="counter-display">
357
+ <span class="label">
358
+ {label}
359
+ </span>
360
+ <span class="count">
361
+ {getCount()}
362
+ </span>
363
+ </div>
364
+ </tsx>;
365
+ }
366
+
367
+ component App() {
368
+ let &[count] = track(0);
369
+ const counterElement = createCounter('Count:', () => count);
370
+ {counterElement}
371
+ <button onClick={() => count++}>{'increment'}</button>
372
+ }
373
+ render(App);
374
+ expect(container.querySelector('.label').textContent).toBe('Count:');
375
+ expect(container.querySelector('.count').textContent).toBe('0');
376
+
377
+ container.querySelector('button').click();
378
+ flushSync();
379
+ expect(container.querySelector('.count').textContent).toBe('1');
380
+ });
381
+
382
+ it('renders nested tsx from multiple functions', () => {
383
+ function createIcon(name: string) {
384
+ return <tsx><i class={'icon icon-' + name}></i></tsx>;
385
+ }
386
+
387
+ function createButton(icon: string, label: string) {
388
+ return <tsx>
389
+ <button class="icon-button">
390
+ {createIcon(icon)}
391
+ <span class="btn-label">
392
+ {label}
393
+ </span>
394
+ </button>
395
+ </tsx>;
396
+ }
397
+
398
+ component App() {
399
+ const btn = createButton('save', 'Save');
400
+ {btn}
401
+ }
402
+ render(App);
403
+ const button = container.querySelector('.icon-button');
404
+ expect(button).toBeTruthy();
405
+ expect(button.querySelector('.icon-save')).toBeTruthy();
406
+ expect(button.querySelector('.btn-label').textContent).toBe('Save');
407
+ });
408
+
409
+ it('renders tsx as prop with fallback in component', () => {
410
+ component Alert(&{ icon, message }: { icon?: any; message: string }) {
411
+ <div class="alert">
412
+ {icon}
413
+ <span class="message">{message}</span>
414
+ </div>
415
+ }
416
+
417
+ component App() {
418
+ <Alert message="No icon" />
419
+ <Alert icon={<tsx><span class="custom-icon">✓</span></tsx>} message="Custom icon" />
420
+ }
421
+ render(App);
422
+ const alerts = container.querySelectorAll('.alert');
423
+ expect(alerts[0].querySelector('.message').textContent).toBe('No icon');
424
+ expect(alerts[1].querySelector('.custom-icon')).toBeTruthy();
425
+ expect(alerts[1].querySelector('.message').textContent).toBe('Custom icon');
426
+ });
427
+
428
+ it('renders tsx stored in array via function', () => {
429
+ function createItem(className: string, content: string) {
430
+ return <tsx>
431
+ <div class={className}>
432
+ {content}
433
+ </div>
434
+ </tsx>;
435
+ }
436
+
437
+ component App() {
438
+ const items = [
439
+ { className: 'item-a', content: 'A' },
440
+ { className: 'item-b', content: 'B' },
441
+ { className: 'item-c', content: 'C' },
442
+ ];
443
+
444
+ <div class="container">
445
+ for (const item of items) {
446
+ {createItem(item.className, item.content)}
447
+ }
448
+ </div>
449
+ }
450
+ render(App);
451
+ const container_el = container.querySelector('.container');
452
+ expect(container_el.querySelector('.item-a').textContent).toBe('A');
453
+ expect(container_el.querySelector('.item-b').textContent).toBe('B');
454
+ expect(container_el.querySelector('.item-c').textContent).toBe('C');
455
+ });
456
+
457
+ it('renders tsx conditionally from function', () => {
458
+ function createContent(type: string) {
459
+ if (type === 'success') {
460
+ return <tsx><div class="success">Success!</div></tsx>;
461
+ } else if (type === 'error') {
462
+ return <tsx><div class="error">Error!</div></tsx>;
463
+ }
464
+ return <tsx><div class="default">Default</div></tsx>;
465
+ }
466
+
467
+ component App() {
468
+ let &[status] = track('default');
469
+ {createContent(status)}
470
+ <button class="set-success" onClick={() => (status = 'success')}>{'Success'}</button>
471
+ <button class="set-error" onClick={() => (status = 'error')}>{'Error'}</button>
472
+ }
473
+ render(App);
474
+ expect(container.querySelector('.default')).toBeTruthy();
475
+
476
+ container.querySelector('.set-success').click();
477
+ flushSync();
478
+ expect(container.querySelector('.success')).toBeTruthy();
479
+ expect(container.querySelector('.default')).toBeFalsy();
480
+
481
+ container.querySelector('.set-error').click();
482
+ flushSync();
483
+ expect(container.querySelector('.error')).toBeTruthy();
484
+ expect(container.querySelector('.success')).toBeFalsy();
485
+ });
486
+ });
@@ -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,