ripple 0.3.127 → 0.4.0

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 (192) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/package.json +10 -4
  3. package/src/runtime/element.js +23 -6
  4. package/src/runtime/index-client.js +81 -41
  5. package/src/runtime/index-server.js +1 -0
  6. package/src/runtime/internal/client/blocks.js +192 -114
  7. package/src/runtime/internal/client/component.js +4 -30
  8. package/src/runtime/internal/client/composite.js +12 -13
  9. package/src/runtime/internal/client/constants.js +14 -5
  10. package/src/runtime/internal/client/context.js +12 -19
  11. package/src/runtime/internal/client/css.js +7 -9
  12. package/src/runtime/internal/client/events.js +178 -90
  13. package/src/runtime/internal/client/expression.js +185 -124
  14. package/src/runtime/internal/client/for.js +616 -264
  15. package/src/runtime/internal/client/head.js +10 -5
  16. package/src/runtime/internal/client/html.js +1 -0
  17. package/src/runtime/internal/client/hydration.js +152 -10
  18. package/src/runtime/internal/client/if.js +239 -50
  19. package/src/runtime/internal/client/index.js +9 -13
  20. package/src/runtime/internal/client/operations.js +44 -51
  21. package/src/runtime/internal/client/portal.js +163 -85
  22. package/src/runtime/internal/client/render.js +44 -25
  23. package/src/runtime/internal/client/rpc.js +3 -3
  24. package/src/runtime/internal/client/runtime.js +799 -576
  25. package/src/runtime/internal/client/selector.js +77 -0
  26. package/src/runtime/internal/client/template.js +133 -158
  27. package/src/runtime/internal/client/transport.js +38 -0
  28. package/src/runtime/internal/client/try.js +465 -426
  29. package/src/runtime/internal/client/types.d.ts +65 -21
  30. package/src/runtime/internal/client/utils.js +1 -3
  31. package/src/runtime/internal/server/dynamic.js +20 -21
  32. package/src/runtime/internal/server/index.js +327 -275
  33. package/src/runtime/internal/server/rpc.js +3 -2
  34. package/src/runtime/internal/server/transport.js +16 -0
  35. package/src/runtime/transport.js +21 -0
  36. package/tests/client/__snapshots__/for.test.tsrx.snap +0 -5
  37. package/tests/client/array/array.copy-within.test.tsrx +12 -12
  38. package/tests/client/array/array.derived.test.tsrx +46 -46
  39. package/tests/client/array/array.iteration.test.tsrx +10 -10
  40. package/tests/client/array/array.mutations.test.tsrx +20 -20
  41. package/tests/client/array/array.static.test.tsrx +11 -11
  42. package/tests/client/array/array.to-methods.test.tsrx +6 -6
  43. package/tests/client/async-suspend.test.tsrx +74 -75
  44. package/tests/client/basic/basic.attributes.test.tsrx +86 -83
  45. package/tests/client/basic/basic.collections.test.tsrx +12 -12
  46. package/tests/client/basic/basic.components.test.tsrx +99 -25
  47. package/tests/client/basic/basic.errors.test.tsrx +18 -18
  48. package/tests/client/basic/basic.events.test.tsrx +42 -42
  49. package/tests/client/basic/basic.get-set.test.tsrx +8 -8
  50. package/tests/client/basic/basic.reactivity.test.tsrx +97 -29
  51. package/tests/client/basic/basic.rendering.test.tsrx +25 -25
  52. package/tests/client/basic/basic.utilities.test.tsrx +3 -3
  53. package/tests/client/boundaries.test.tsrx +8 -8
  54. package/tests/client/boxed-locals.test.tsrx +452 -0
  55. package/tests/client/compiler/__snapshots__/compiler.assignments.test.tsrx.snap +6 -6
  56. package/tests/client/compiler/compiler.assignments.test.tsrx +9 -73
  57. package/tests/client/compiler/compiler.attributes.test.tsrx +23 -23
  58. package/tests/client/compiler/compiler.basic.test.tsrx +15 -60
  59. package/tests/client/compiler/compiler.tracked-access.test.tsrx +18 -43
  60. package/tests/client/compiler/compiler.try-in-function.test.tsrx +2 -2
  61. package/tests/client/composite/__snapshots__/composite.render.test.tsrx.snap +0 -1
  62. package/tests/client/composite/composite.dynamic-components.test.tsrx +77 -6
  63. package/tests/client/composite/composite.props.test.tsrx +149 -12
  64. package/tests/client/composite/composite.reactivity.test.tsrx +51 -52
  65. package/tests/client/composite/composite.render.test.tsrx +26 -9
  66. package/tests/client/css/scoped-styles.test.tsrx +8 -8
  67. package/tests/client/date.test.tsrx +42 -42
  68. package/tests/client/derived-release.test.tsrx +370 -0
  69. package/tests/client/dynamic-elements.test.tsrx +45 -45
  70. package/tests/client/events.test.tsrx +97 -71
  71. package/tests/client/for-selector.test.tsrx +221 -0
  72. package/tests/client/for-single-root-items.test.tsrx +118 -3
  73. package/tests/client/for.test.tsrx +332 -31
  74. package/tests/client/head.test.tsrx +19 -19
  75. package/tests/client/html.test.tsrx +11 -9
  76. package/tests/client/if-block.test.tsrx +80 -0
  77. package/tests/client/map.test.tsrx +16 -16
  78. package/tests/client/media-query.test.tsrx +7 -7
  79. package/tests/client/portal.test.tsrx +224 -26
  80. package/tests/client/primitive-text.test.tsrx +50 -0
  81. package/tests/client/prop-kind-switch.test.tsrx +141 -0
  82. package/tests/client/ref.test.tsrx +17 -17
  83. package/tests/client/return.test.tsrx +26 -3
  84. package/tests/client/root-boundary.test.tsrx +42 -0
  85. package/tests/client/scoped-flush.test.tsrx +8 -8
  86. package/tests/client/set.test.tsrx +6 -6
  87. package/tests/client/svg.test.tsrx +11 -11
  88. package/tests/client/switch.test.tsrx +44 -44
  89. package/tests/client/track-async-hydration.test.tsrx +4 -4
  90. package/tests/client/try.test.tsrx +151 -83
  91. package/tests/client/tsx.test.tsrx +43 -40
  92. package/tests/client/url/url.derived.test.tsrx +6 -6
  93. package/tests/client/url-search-params/url-search-params.derived.test.tsrx +8 -8
  94. package/tests/client/url-search-params/url-search-params.iteration.test.tsrx +10 -10
  95. package/tests/client/url-search-params/url-search-params.mutation.test.tsrx +10 -10
  96. package/tests/client/url-search-params/url-search-params.retrieval.test.tsrx +18 -18
  97. package/tests/client/url-search-params/url-search-params.serialization.test.tsrx +2 -2
  98. package/tests/hydration/basic.test.js +74 -1
  99. package/tests/hydration/build-components.js +22 -1
  100. package/tests/hydration/compiled/client/basic.js +1142 -713
  101. package/tests/hydration/compiled/client/composite.js +151 -100
  102. package/tests/hydration/compiled/client/events.js +190 -150
  103. package/tests/hydration/compiled/client/for.js +1472 -1126
  104. package/tests/hydration/compiled/client/fragment-cursor.js +2341 -0
  105. package/tests/hydration/compiled/client/head.js +255 -199
  106. package/tests/hydration/compiled/client/hmr.js +54 -41
  107. package/tests/hydration/compiled/client/html-in-template.js +54 -39
  108. package/tests/hydration/compiled/client/html.js +1633 -1263
  109. package/tests/hydration/compiled/client/if-children.js +420 -270
  110. package/tests/hydration/compiled/client/if-fragment-controlflow.js +363 -317
  111. package/tests/hydration/compiled/client/if.js +229 -219
  112. package/tests/hydration/compiled/client/mixed-control-flow.js +420 -400
  113. package/tests/hydration/compiled/client/nested-control-flow.js +1326 -1255
  114. package/tests/hydration/compiled/client/portal.js +87 -95
  115. package/tests/hydration/compiled/client/reactivity.js +140 -83
  116. package/tests/hydration/compiled/client/return.js +28 -19
  117. package/tests/hydration/compiled/client/streaming.js +390 -306
  118. package/tests/hydration/compiled/client/switch.js +258 -266
  119. package/tests/hydration/compiled/client/track-async-serialization.js +448 -280
  120. package/tests/hydration/compiled/client/try.js +173 -129
  121. package/tests/hydration/compiled/client/typed-text.js +71 -0
  122. package/tests/hydration/compiled/fixtures/money.js +10 -0
  123. package/tests/hydration/compiled/server/basic.js +123 -6
  124. package/tests/hydration/compiled/server/composite.js +16 -30
  125. package/tests/hydration/compiled/server/events.js +19 -19
  126. package/tests/hydration/compiled/server/for.js +109 -49
  127. package/tests/hydration/compiled/server/fragment-cursor.js +1687 -0
  128. package/tests/hydration/compiled/server/head.js +20 -28
  129. package/tests/hydration/compiled/server/hmr.js +2 -2
  130. package/tests/hydration/compiled/server/html.js +71 -247
  131. package/tests/hydration/compiled/server/if-children.js +107 -15
  132. package/tests/hydration/compiled/server/if.js +11 -15
  133. package/tests/hydration/compiled/server/mixed-control-flow.js +8 -8
  134. package/tests/hydration/compiled/server/portal.js +14 -22
  135. package/tests/hydration/compiled/server/reactivity.js +14 -46
  136. package/tests/hydration/compiled/server/streaming.js +19 -19
  137. package/tests/hydration/compiled/server/switch.js +8 -8
  138. package/tests/hydration/compiled/server/track-async-serialization.js +150 -23
  139. package/tests/hydration/compiled/server/try.js +8 -8
  140. package/tests/hydration/compiled/server/typed-text.js +17 -0
  141. package/tests/hydration/components/basic.tsrx +44 -8
  142. package/tests/hydration/components/composite.tsrx +2 -2
  143. package/tests/hydration/components/events.tsrx +25 -25
  144. package/tests/hydration/components/for.tsrx +115 -67
  145. package/tests/hydration/components/fragment-cursor.tsrx +650 -0
  146. package/tests/hydration/components/head.tsrx +16 -16
  147. package/tests/hydration/components/hmr.tsrx +2 -2
  148. package/tests/hydration/components/html.tsrx +7 -7
  149. package/tests/hydration/components/if-children.tsrx +72 -25
  150. package/tests/hydration/components/if.tsrx +18 -18
  151. package/tests/hydration/components/mixed-control-flow.tsrx +11 -11
  152. package/tests/hydration/components/portal.tsrx +3 -3
  153. package/tests/hydration/components/reactivity.tsrx +16 -16
  154. package/tests/hydration/components/streaming.tsrx +20 -20
  155. package/tests/hydration/components/switch.tsrx +20 -20
  156. package/tests/hydration/components/text-types.ts +4 -0
  157. package/tests/hydration/components/track-async-serialization.tsrx +80 -26
  158. package/tests/hydration/components/try.tsrx +8 -8
  159. package/tests/hydration/components/typed-text.tsrx +22 -0
  160. package/tests/hydration/fixtures/money.js +10 -0
  161. package/tests/hydration/for.test.js +36 -0
  162. package/tests/hydration/fragment-cursor.test.js +111 -0
  163. package/tests/hydration/if-children.test.js +24 -0
  164. package/tests/hydration/streaming.test.js +19 -0
  165. package/tests/hydration/track-async-serialization.test.js +87 -2
  166. package/tests/hydration/tsconfig.text-types.json +10 -0
  167. package/tests/hydration/typed-text.test.js +34 -0
  168. package/tests/server/await.test.tsrx +3 -3
  169. package/tests/server/basic.attributes.test.tsrx +42 -42
  170. package/tests/server/basic.components.test.tsrx +64 -8
  171. package/tests/server/basic.test.tsrx +34 -32
  172. package/tests/server/compiler.test.tsrx +1 -70
  173. package/tests/server/composite.props.test.tsrx +6 -6
  174. package/tests/server/dynamic-elements.test.tsrx +31 -8
  175. package/tests/server/for.test.tsrx +94 -0
  176. package/tests/server/head.test.tsrx +11 -11
  177. package/tests/server/scoped-styles.test.tsrx +2 -2
  178. package/tests/server/streaming-ssr.test.tsrx +48 -43
  179. package/tests/server/track-async-serialization.test.tsrx +204 -21
  180. package/tests/server/try.test.tsrx +48 -48
  181. package/tests/types/transport.ts +40 -0
  182. package/tests/utils/vite-plugin-config.test.js +23 -0
  183. package/types/index.d.ts +48 -7
  184. package/types/server.d.ts +3 -0
  185. package/types/transport.d.ts +15 -0
  186. package/src/runtime/internal/client/switch.js +0 -113
  187. package/src/utils/errors.js +0 -13
  188. package/tests/client/lazy-array.test.tsrx +0 -31
  189. package/tests/client/lazy-destructuring.test.tsrx +0 -438
  190. package/tests/client/tracked-index-access.test.tsrx +0 -113
  191. package/tests/server/lazy-destructuring.test.tsrx +0 -595
  192. package/tests/server/tracked-index-access.test.tsrx +0 -76
@@ -31,18 +31,18 @@ describe('head elements', () => {
31
31
 
32
32
  it('renders reactive title element', () => {
33
33
  function App() @{
34
- let &[title] = track('Initial Title');
34
+ const title = track('Initial Title');
35
35
  <>
36
36
  <head>
37
- <title>{title}</title>
37
+ <title>{title.value}</title>
38
38
  </head>
39
39
  <div>
40
40
  <button
41
41
  onClick={() => {
42
- title = 'Updated Title';
42
+ title.value = 'Updated Title';
43
43
  }}
44
44
  >{'Update Title'}</button>
45
- <span>{title}</span>
45
+ <span>{title.value}</span>
46
46
  </div>
47
47
  </>
48
48
  }
@@ -62,15 +62,15 @@ describe('head elements', () => {
62
62
 
63
63
  it('renders title with template literal', () => {
64
64
  function App() @{
65
- let &[name] = track('World');
65
+ const name = track('World');
66
66
  <>
67
67
  <head>
68
- <title>{`Hello ${name}!`}</title>
68
+ <title>{`Hello ${name.value}!`}</title>
69
69
  </head>
70
70
  <div>
71
71
  <button
72
72
  onClick={() => {
73
- name = 'Ripple';
73
+ name.value = 'Ripple';
74
74
  }}
75
75
  >{'Change Name'}</button>
76
76
  </div>
@@ -90,19 +90,19 @@ describe('head elements', () => {
90
90
 
91
91
  it('renders title with computed value', () => {
92
92
  function App() @{
93
- let &[count] = track(0);
93
+ const count = track(0);
94
94
  let prefix = 'Count: ';
95
95
  <>
96
96
  <head>
97
- <title>{prefix + count}</title>
97
+ <title>{prefix + count.value}</title>
98
98
  </head>
99
99
  <div>
100
100
  <button
101
101
  onClick={() => {
102
- count++;
102
+ count.value++;
103
103
  }}
104
104
  >{'Increment'}</button>
105
- <span>{count}</span>
105
+ <span>{count.value}</span>
106
106
  </div>
107
107
  </>
108
108
  }
@@ -121,15 +121,15 @@ describe('head elements', () => {
121
121
 
122
122
  it('handles multiple title updates', () => {
123
123
  function App() @{
124
- let &[step] = track(1);
124
+ const step = track(1);
125
125
  <>
126
126
  <head>
127
- <title>{`Step ${step} of 3`}</title>
127
+ <title>{`Step ${step.value} of 3`}</title>
128
128
  </head>
129
129
  <div>
130
130
  <button
131
131
  onClick={() => {
132
- step = step % 3 + 1;
132
+ step.value = step.value % 3 + 1;
133
133
  }}
134
134
  >{'Next Step'}</button>
135
135
  </div>
@@ -244,23 +244,23 @@ describe('head elements', () => {
244
244
 
245
245
  it('renders title with conditional content', () => {
246
246
  function App() @{
247
- let &[showPrefix] = track(true);
248
- let &[title] = track('Main Page');
247
+ const showPrefix = track(true);
248
+ const title = track('Main Page');
249
249
  <>
250
250
  <head>
251
251
  <title>
252
- {showPrefix ? 'App - ' + title : title}
252
+ {showPrefix.value ? 'App - ' + title.value : title.value}
253
253
  </title>
254
254
  </head>
255
255
  <div>
256
256
  <button
257
257
  onClick={() => {
258
- showPrefix = !showPrefix;
258
+ showPrefix.value = !showPrefix.value;
259
259
  }}
260
260
  >{'Toggle Prefix'}</button>
261
261
  <button
262
262
  onClick={() => {
263
- title = title === 'Main Page' ? 'Settings' : 'Main Page';
263
+ title.value = title.value === 'Main Page' ? 'Settings' : 'Main Page';
264
264
  }}
265
265
  >{'Change Page'}</button>
266
266
  </div>
@@ -16,12 +16,14 @@ describe('Fragment innerHTML', () => {
16
16
 
17
17
  it('renders spread innerHTML as content instead of an attribute', () => {
18
18
  function App() @{
19
- let &[attrs] = track<{ innerHTML?: string }>({
19
+ const attrs = track<{ innerHTML?: string }>({
20
20
  innerHTML: '<span>Spread HTML</span>',
21
21
  });
22
22
  <>
23
- <code {...attrs} />
24
- <button onClick={() => (attrs = { innerHTML: '<em>Updated HTML</em>' })}>{'Update'}</button>
23
+ <code {...attrs.value} />
24
+ <button
25
+ onClick={() => (attrs.value = { innerHTML: '<em>Updated HTML</em>' })}
26
+ >{'Update'}</button>
25
27
  </>
26
28
  }
27
29
 
@@ -64,12 +66,12 @@ describe('Fragment innerHTML', () => {
64
66
 
65
67
  it('renders dynamic innerHTML fragments', () => {
66
68
  function App() @{
67
- let &[str] = track('<div>Test</div>');
69
+ const str = track('<div>Test</div>');
68
70
  <>
69
- <Fragment innerHTML={str} />
71
+ <Fragment innerHTML={str.value} />
70
72
  <button
71
73
  onClick={() => {
72
- str = '<div>Updated</div>';
74
+ str.value = '<div>Updated</div>';
73
75
  }}
74
76
  >{'Update'}</button>
75
77
  </>
@@ -99,10 +101,10 @@ describe('Fragment innerHTML', () => {
99
101
 
100
102
  it('updates dynamic Fragment innerHTML', () => {
101
103
  function App() @{
102
- let &[html] = track('<span>First</span>');
104
+ const html = track('<span>First</span>');
103
105
  <>
104
- <Fragment innerHTML={html} />
105
- <button onClick={() => (html = '<em>Second</em>')}>{'Update'}</button>
106
+ <Fragment innerHTML={html.value} />
107
+ <button onClick={() => (html.value = '<em>Second</em>')}>{'Update'}</button>
106
108
  </>
107
109
  }
108
110
 
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { flushSync, track, type Tracked } from 'ripple';
3
+
4
+ // `if_block` probes its condition once: a condition that reads no tracked
5
+ // state renders its branch with no block, a dynamic one gets a block whose
6
+ // own first run subscribes it. The dependencies the probe recorded are never
7
+ // handed to the block: a write during the branch's first render (a child's
8
+ // setup here) would prune them before the block could take them over.
9
+
10
+ describe('if block', () => {
11
+ it('keeps re-evaluating when the first branch render writes the condition state', () => {
12
+ let set: (value: number) => void = () => {};
13
+
14
+ function Bumper(props: { n: Tracked<number> }) @{
15
+ // Writes the value the parent's condition depends on, while the
16
+ // parent's if is rendering this branch for the first time.
17
+ props.n.value = props.n.value + 1;
18
+ <span>{'bumped ' + props.n.value}</span>
19
+ }
20
+
21
+ function App() @{
22
+ const n = track(0);
23
+ set = (value) => {
24
+ n.value = value;
25
+ };
26
+ @if (n.value < 5) {
27
+ <Bumper {n} />
28
+ } @else {
29
+ <p>{'done'}</p>
30
+ }
31
+ }
32
+
33
+ render(App);
34
+ flushSync();
35
+ expect(container.querySelector('span')?.textContent).toBe('bumped 1');
36
+
37
+ set(7);
38
+ flushSync();
39
+ expect(container.querySelector('span')).toBeNull();
40
+ expect(container.querySelector('p')?.textContent).toBe('done');
41
+
42
+ set(0);
43
+ flushSync();
44
+ expect(container.querySelector('span')?.textContent).toBe('bumped 1');
45
+ });
46
+
47
+ it('renders a static condition with no block and keeps a dynamic one live', () => {
48
+ let toggle: () => void = () => {};
49
+
50
+ function App(props: { fixed: boolean }) @{
51
+ const on = track(false);
52
+ toggle = () => {
53
+ on.value = !on.value;
54
+ };
55
+ <>
56
+ @if (props.fixed) {
57
+ <b>{'fixed'}</b>
58
+ }
59
+ @if (on.value) {
60
+ <i>{'on'}</i>
61
+ } @else {
62
+ <i>{'off'}</i>
63
+ }
64
+ </>
65
+ }
66
+
67
+ function Root() @{
68
+ <App fixed={true} />
69
+ }
70
+
71
+ render(Root);
72
+ expect(container.querySelector('b')?.textContent).toBe('fixed');
73
+ expect(container.querySelector('i')?.textContent).toBe('off');
74
+
75
+ toggle();
76
+ flushSync();
77
+ expect(container.querySelector('i')?.textContent).toBe('on');
78
+ expect(container.querySelector('b')?.textContent).toBe('fixed');
79
+ });
80
+ });
@@ -4,15 +4,15 @@ describe('RippleMap', () => {
4
4
  it('handles set with update and delete operations with a reactive size var', () => {
5
5
  function MapTest() @{
6
6
  let map = new RippleMap([['a', 1], ['b', 2], ['c', 3]]);
7
- let &[value] = track(() => map.get('a'));
8
- let &[size] = track(() => map.size);
7
+ const value = track(() => map.get('a'));
8
+ const size = track(() => map.size);
9
9
  <>
10
10
  <button onClick={() => map.set('d', 4)}>{'set'}</button>
11
11
  <button onClick={() => map.delete('b')}>{'delete'}</button>
12
12
  <button onClick={() => map.set('a', 5)}>{'update'}</button>
13
13
  <pre>{map.get('d')}</pre>
14
- <pre>{size}</pre>
15
- <pre>{value}</pre>
14
+ <pre>{size.value}</pre>
15
+ <pre>{value.value}</pre>
16
16
  </>
17
17
  }
18
18
 
@@ -62,10 +62,10 @@ describe('RippleMap', () => {
62
62
  it('handles has operation and tracks reactive $has', () => {
63
63
  function MapTest() @{
64
64
  let map = new RippleMap([['a', 1], ['b', 2], ['c', 3]]);
65
- let &[has] = track(() => map.has('b'));
65
+ const has = track(() => map.has('b'));
66
66
  <>
67
67
  <button onClick={() => map.delete('b')}>{'delete'}</button>
68
- <pre>{has}</pre>
68
+ <pre>{has.value}</pre>
69
69
  </>
70
70
  }
71
71
 
@@ -83,14 +83,14 @@ describe('RippleMap', () => {
83
83
  it('handles reactivity of keys, values, and entries', () => {
84
84
  function MapTest() @{
85
85
  let map = new RippleMap([['x', 10], ['y', 20]]);
86
- let &[keys] = track(() => Array.from(map.keys()));
87
- let &[values] = track(() => Array.from(map.values()));
88
- let &[entries] = track(() => Array.from(map.entries()));
86
+ const keys = track(() => Array.from(map.keys()));
87
+ const values = track(() => Array.from(map.values()));
88
+ const entries = track(() => Array.from(map.entries()));
89
89
  <>
90
90
  <button onClick={() => map.delete('x')}>{'delete'}</button>
91
- <pre>{JSON.stringify(keys)}</pre>
92
- <pre>{JSON.stringify(values)}</pre>
93
- <pre>{JSON.stringify(entries)}</pre>
91
+ <pre>{JSON.stringify(keys.value)}</pre>
92
+ <pre>{JSON.stringify(values.value)}</pre>
93
+ <pre>{JSON.stringify(entries.value)}</pre>
94
94
  </>
95
95
  }
96
96
 
@@ -144,11 +144,11 @@ describe('RippleMap', () => {
144
144
  it('creates RippleMap with initial entries using RippleMap() shorthand syntax', () => {
145
145
  function MapTest() @{
146
146
  let map = new RippleMap([['a', 1], ['b', 2], ['c', 3]]);
147
- let &[value] = track(() => map.get('b'));
147
+ const value = track(() => map.get('b'));
148
148
  <>
149
149
  <button onClick={() => map.set('b', 10)}>{'update'}</button>
150
150
  <pre>{map.size}</pre>
151
- <pre>{value}</pre>
151
+ <pre>{value.value}</pre>
152
152
  </>
153
153
  }
154
154
 
@@ -167,12 +167,12 @@ describe('RippleMap', () => {
167
167
  it('handles all operations with RippleMap() shorthand syntax', () => {
168
168
  function MapTest() @{
169
169
  let map = new RippleMap([['x', 100], ['y', 200]]);
170
- let &[keys] = track(() => Array.from(map.keys()));
170
+ const keys = track(() => Array.from(map.keys()));
171
171
  <>
172
172
  <button onClick={() => map.set('z', 300)}>{'add'}</button>
173
173
  <button onClick={() => map.delete('x')}>{'delete'}</button>
174
174
  <button onClick={() => map.clear()}>{'clear'}</button>
175
- <pre>{JSON.stringify(keys)}</pre>
175
+ <pre>{JSON.stringify(keys.value)}</pre>
176
176
  <pre>{map.size}</pre>
177
177
  </>
178
178
  }
@@ -51,9 +51,9 @@ describe('MediaQuery', () => {
51
51
  const media = '(min-width: 600px)';
52
52
 
53
53
  function App() @{
54
- let &[medium] = new MediaQuery(media);
54
+ const medium = new MediaQuery(media);
55
55
  <div>
56
- <p>{medium}</p>
56
+ <p>{medium.value}</p>
57
57
  </div>
58
58
  }
59
59
 
@@ -85,19 +85,19 @@ describe('MediaQuery', () => {
85
85
  const media = '(min-width: 600px)';
86
86
 
87
87
  function App() @{
88
- let &[show] = track(true);
88
+ const show = track(true);
89
89
  <>
90
- @if (show) {
90
+ @if (show.value) {
91
91
  <Child />
92
92
  }
93
- <button onClick={() => (show = !show)}>{'Toggle Child'}</button>
93
+ <button onClick={() => (show.value = !show.value)}>{'Toggle Child'}</button>
94
94
  </>
95
95
  }
96
96
 
97
97
  function Child() @{
98
- let &[medium] = new MediaQuery(media);
98
+ const medium = new MediaQuery(media);
99
99
  <div>
100
- <p>{medium}</p>
100
+ <p>{medium.value}</p>
101
101
  </div>
102
102
  }
103
103
 
@@ -1,4 +1,4 @@
1
- import { Portal, flushSync, track } from 'ripple';
1
+ import { Portal, flushSync, track, type Tracked } from 'ripple';
2
2
 
3
3
  describe('Portal', () => {
4
4
  afterEach(() => {
@@ -46,14 +46,14 @@ describe('Portal', () => {
46
46
 
47
47
  it('cleans up portal content when destroyed via conditional rendering', () => {
48
48
  function TestPortal() @{
49
- let &[open] = track(true);
49
+ const open = track(true);
50
50
  <>
51
- @if (open) {
51
+ @if (open.value) {
52
52
  <Portal target={document.body}>
53
53
  <div class="test-portal">{'Conditional content'}</div>
54
54
  </Portal>
55
55
  }
56
- <button onClick={() => (open = false)}>{'Close'}</button>
56
+ <button onClick={() => (open.value = false)}>{'Close'}</button>
57
57
  </>
58
58
  }
59
59
 
@@ -72,18 +72,18 @@ describe('Portal', () => {
72
72
 
73
73
  it('opens and closes portal via conditional rendering', () => {
74
74
  function TestPortal() @{
75
- let &[open] = track(false);
75
+ const open = track(false);
76
76
  <>
77
- @if (open) {
77
+ @if (open.value) {
78
78
  <Portal target={document.body}>
79
79
  <div class="test-portal">
80
80
  {'Content'}
81
- <button onClick={() => (open = false)}>{'Close'}</button>
81
+ <button onClick={() => (open.value = false)}>{'Close'}</button>
82
82
  </div>
83
83
  </Portal>
84
84
  }
85
- @if (!open) {
86
- <button onClick={() => (open = true)}>{'Open'}</button>
85
+ @if (!open.value) {
86
+ <button onClick={() => (open.value = true)}>{'Open'}</button>
87
87
  }
88
88
  </>
89
89
  }
@@ -146,28 +146,28 @@ describe('Portal', () => {
146
146
  // down the shared delegated event listeners for *all* portals on that
147
147
  // target, silently breaking clicks inside any portal still open.
148
148
  function TestSharedTargetPortals() @{
149
- let &[modalOpen] = track(true);
150
- let &[sheetOpen] = track(true);
151
- let &[sheetClicks] = track(0);
149
+ const modalOpen = track(true);
150
+ const sheetOpen = track(true);
151
+ const sheetClicks = track(0);
152
152
  <>
153
- @if (modalOpen) {
153
+ @if (modalOpen.value) {
154
154
  <Portal target={document.body}>
155
155
  <div class="test-portal test-modal">
156
156
  <button
157
157
  class="close-modal"
158
- onClick={() => (modalOpen = false)}
158
+ onClick={() => (modalOpen.value = false)}
159
159
  >{'Close modal'}</button>
160
160
  </div>
161
161
  </Portal>
162
162
  }
163
- @if (sheetOpen) {
163
+ @if (sheetOpen.value) {
164
164
  <Portal target={document.body}>
165
165
  <div class="test-portal test-sheet">
166
- <span class="sheet-clicks">{String(sheetClicks)}</span>
166
+ <span class="sheet-clicks">{String(sheetClicks.value)}</span>
167
167
  <button
168
168
  class="sheet-btn"
169
169
  onClick={() => {
170
- sheetClicks++;
170
+ sheetClicks.value++;
171
171
  }}
172
172
  >{'Sheet button'}</button>
173
173
  </div>
@@ -209,14 +209,14 @@ describe('Portal', () => {
209
209
  document.body.appendChild(target2);
210
210
 
211
211
  function TestRetargetPortal() @{
212
- let &[useSecond] = track(false);
213
- let &[clicks] = track(0);
212
+ const useSecond = track(false);
213
+ const clicks = track(0);
214
214
  <>
215
- <button class="swap" onClick={() => (useSecond = true)}>{'Swap'}</button>
216
- <Portal target={useSecond ? target2 : target1}>
215
+ <button class="swap" onClick={() => (useSecond.value = true)}>{'Swap'}</button>
216
+ <Portal target={useSecond.value ? target2 : target1}>
217
217
  <div class="test-portal">
218
- <span class="clicks">{String(clicks)}</span>
219
- <button class="portal-btn" onClick={() => clicks++}>{'Click'}</button>
218
+ <span class="clicks">{String(clicks.value)}</span>
219
+ <button class="portal-btn" onClick={() => clicks.value++}>{'Click'}</button>
220
220
  </div>
221
221
  </Portal>
222
222
  </>
@@ -246,12 +246,12 @@ describe('Portal', () => {
246
246
 
247
247
  it('handles portal with reactive content', () => {
248
248
  function TestReactivePortal() @{
249
- let &[count] = track(0);
249
+ const count = track(0);
250
250
  <Portal target={document.body}>
251
251
  <div class="test-portal">
252
252
  {'Count: '}
253
- {String(count)}
254
- <button onClick={() => count++}>{'Increment'}</button>
253
+ {String(count.value)}
254
+ <button onClick={() => count.value++}>{'Increment'}</button>
255
255
  </div>
256
256
  </Portal>
257
257
  }
@@ -268,4 +268,202 @@ describe('Portal', () => {
268
268
 
269
269
  expect(portalElement.textContent).toContain('Count: 1');
270
270
  });
271
+
272
+ it('re-renders a root @if child at its own position among sibling portals', () => {
273
+ // Portal content appends through an append-into sentinel with no anchor
274
+ // node of its own. A child component whose root is control flow must
275
+ // still re-render where it was, not after content appended since.
276
+ const target = document.createElement('div');
277
+ document.body.appendChild(target);
278
+
279
+ function Toggle(props: { on: Tracked<boolean> }) @{
280
+ @if (props.on.value) {
281
+ <span class="test-portal">{'A'}</span>
282
+ } @else {
283
+ <span class="test-portal">{'a'}</span>
284
+ }
285
+ }
286
+
287
+ function TestPortalOrder() @{
288
+ const on = track(true);
289
+ <>
290
+ <button onClick={() => (on.value = !on.value)}>{'Toggle'}</button>
291
+ <Portal {target}>
292
+ <Toggle {on} />
293
+ </Portal>
294
+ <Portal {target}>
295
+ <span class="test-portal">{'B'}</span>
296
+ </Portal>
297
+ </>
298
+ }
299
+
300
+ render(TestPortalOrder);
301
+
302
+ expect(target.textContent).toBe('AB');
303
+
304
+ container.querySelector('button').click();
305
+ flushSync();
306
+ expect(target.textContent).toBe('aB');
307
+
308
+ container.querySelector('button').click();
309
+ flushSync();
310
+ expect(target.textContent).toBe('AB');
311
+
312
+ document.body.removeChild(target);
313
+ });
314
+
315
+ it('moves items to the end of a keyed @for rendered as a component root inside a portal', () => {
316
+ const target = document.createElement('div');
317
+ document.body.appendChild(target);
318
+
319
+ function List(props: { items: Tracked<number[]> }) @{
320
+ @for (const item of props.items.value; key item) {
321
+ <span class="test-portal">{item}</span>
322
+ }
323
+ }
324
+
325
+ function TestPortalList() @{
326
+ const items = track([1, 2, 3]);
327
+ <>
328
+ <button
329
+ onClick={() => (items.value = [items.value[1], items.value[2], items.value[0]])}
330
+ >{'Rotate'}</button>
331
+ <Portal {target}>
332
+ <List {items} />
333
+ </Portal>
334
+ </>
335
+ }
336
+
337
+ render(TestPortalList);
338
+
339
+ expect(target.textContent).toBe('123');
340
+
341
+ // Moving the first item to the end inserts before the list's anchor.
342
+ container.querySelector('button').click();
343
+ flushSync();
344
+ expect(target.textContent).toBe('231');
345
+
346
+ document.body.removeChild(target);
347
+ });
348
+
349
+ it('moves items to the end of a keyed @for rendered directly as portal children', () => {
350
+ const target = document.createElement('div');
351
+ document.body.appendChild(target);
352
+
353
+ function TestPortalFor() @{
354
+ const items = track([1, 2, 3]);
355
+ <>
356
+ <button
357
+ onClick={() => (items.value = [items.value[1], items.value[2], items.value[0]])}
358
+ >{'Rotate'}</button>
359
+ <Portal {target}>
360
+ @for (const item of items.value; key item) {
361
+ <span class="test-portal">{item}</span>
362
+ }
363
+ </Portal>
364
+ </>
365
+ }
366
+
367
+ render(TestPortalFor);
368
+
369
+ expect(target.textContent).toBe('123');
370
+
371
+ container.querySelector('button').click();
372
+ flushSync();
373
+ expect(target.textContent).toBe('231');
374
+
375
+ document.body.removeChild(target);
376
+ });
377
+
378
+ it('removes portal content when a fragment containing the portal is destroyed', () => {
379
+ // The `@if` branch owns a DOM range of its own (the fragment), so it
380
+ // tells its subtree not to remove DOM; the portal content lives in the
381
+ // target, outside that range, and must still go.
382
+ function TestPortalInFragment() @{
383
+ const open = track(true);
384
+ <>
385
+ <button onClick={() => (open.value = false)}>{'Close'}</button>
386
+ @if (open.value) {
387
+ <>
388
+ <Portal target={document.body}>
389
+ <div class="test-portal">{'Content'}</div>
390
+ </Portal>
391
+ <span class="sibling">{'Sibling'}</span>
392
+ </>
393
+ }
394
+ </>
395
+ }
396
+
397
+ render(TestPortalInFragment);
398
+
399
+ expect(document.body.querySelector('.test-portal')).toBeTruthy();
400
+
401
+ container.querySelector('button').click();
402
+ flushSync();
403
+
404
+ expect(container.querySelector('.sibling')).toBeNull();
405
+ expect(document.body.querySelector('.test-portal')).toBeNull();
406
+ });
407
+
408
+ it(
409
+ 'leaves nothing in the target after closing or retargeting a portal with control-flow roots',
410
+ () => {
411
+ // A root-controlled block inside portal content gets a text anchor of its
412
+ // own; the target outlives the portal, so that anchor must go with the
413
+ // content on close and on retarget.
414
+ const target1 = document.createElement('div');
415
+ const target2 = document.createElement('div');
416
+ document.body.appendChild(target1);
417
+ document.body.appendChild(target2);
418
+
419
+ function Toggle(props) @{
420
+ @if (props.on) {
421
+ <span class="test-portal">{'A'}</span>
422
+ } @else {
423
+ <span class="test-portal">{'a'}</span>
424
+ }
425
+ }
426
+
427
+ function List(props) @{
428
+ @for (const item of props.items; key item) {
429
+ <span class="test-portal">{item}</span>
430
+ }
431
+ }
432
+
433
+ function TestPortalCleanup() @{
434
+ const open = track(true);
435
+ const useSecond = track(false);
436
+ <>
437
+ <button class="close" onClick={() => (open.value = false)}>{'Close'}</button>
438
+ <button class="swap" onClick={() => (useSecond.value = true)}>{'Swap'}</button>
439
+ @if (open.value) {
440
+ <>
441
+ <Portal target={useSecond.value ? target2 : target1}>
442
+ <Toggle on={true} />
443
+ </Portal>
444
+ <Portal target={useSecond.value ? target2 : target1}>
445
+ <List items={[1, 2]} />
446
+ </Portal>
447
+ </>
448
+ }
449
+ </>
450
+ }
451
+
452
+ render(TestPortalCleanup);
453
+
454
+ expect(target1.textContent).toBe('A12');
455
+
456
+ container.querySelector('.swap').click();
457
+ flushSync();
458
+ expect(target1.childNodes.length).toBe(0);
459
+ expect(target2.textContent).toBe('A12');
460
+
461
+ container.querySelector('.close').click();
462
+ flushSync();
463
+ expect(target2.childNodes.length).toBe(0);
464
+
465
+ document.body.removeChild(target1);
466
+ document.body.removeChild(target2);
467
+ },
468
+ );
271
469
  });