ripple 0.3.128 → 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 +267 -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 +173 -91
  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 +9 -0
  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 +45 -25
  14. package/src/runtime/internal/client/for.js +409 -129
  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 +4 -12
  20. package/src/runtime/internal/client/operations.js +40 -61
  21. package/src/runtime/internal/client/portal.js +163 -85
  22. package/src/runtime/internal/client/render.js +39 -39
  23. package/src/runtime/internal/client/rpc.js +3 -3
  24. package/src/runtime/internal/client/runtime.js +592 -433
  25. package/src/runtime/internal/client/selector.js +5 -2
  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 +56 -23
  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 +325 -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 +73 -38
  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 +14 -14
  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 +184 -171
  103. package/tests/hydration/compiled/client/for.js +1472 -1132
  104. package/tests/hydration/compiled/client/fragment-cursor.js +2341 -0
  105. package/tests/hydration/compiled/client/head.js +254 -201
  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 -404
  113. package/tests/hydration/compiled/client/nested-control-flow.js +1326 -1277
  114. package/tests/hydration/compiled/client/portal.js +87 -95
  115. package/tests/hydration/compiled/client/reactivity.js +135 -101
  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 +66 -18
  126. package/tests/hydration/compiled/server/for.js +117 -41
  127. package/tests/hydration/compiled/server/fragment-cursor.js +1687 -0
  128. package/tests/hydration/compiled/server/head.js +20 -20
  129. package/tests/hydration/compiled/server/hmr.js +2 -2
  130. package/tests/hydration/compiled/server/html.js +70 -238
  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 -14
  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
@@ -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
  });
@@ -0,0 +1,50 @@
1
+ import { flushSync, track } from 'ripple';
2
+
3
+ it('renders shadowed intrinsic calls as elements beside text', () => {
4
+ function App() @{
5
+ const String = () => <b>string</b>;
6
+ const Number = () => <b>number</b>;
7
+ const Boolean = () => <b>boolean</b>;
8
+ const BigInt = () => <b>bigint</b>;
9
+ const Date = () => <b>date</b>;
10
+ <>
11
+ <p>before{String()}after</p>
12
+ <p>before{Number()}after</p>
13
+ <p>before{Boolean()}after</p>
14
+ <p>before{BigInt()}after</p>
15
+ <p>before{Date()}after</p>
16
+ </>
17
+ }
18
+ render(App);
19
+ expect([...container.querySelectorAll('b')].map((node) => node.textContent)).toEqual([
20
+ 'string',
21
+ 'number',
22
+ 'boolean',
23
+ 'bigint',
24
+ 'date',
25
+ ]);
26
+ });
27
+
28
+ it('keeps numeric addition, bigint conversion, and sequence effects during updates', () => {
29
+ let calls = 0;
30
+ function App() @{
31
+ const count = track(2);
32
+ <>
33
+ <p>sum: {Number(count.value) + Number(count.value)}</p>
34
+ <div>
35
+ {'big: '}
36
+ {(calls++, BigInt(count.value) satisfies bigint)}
37
+ </div>
38
+ <button onClick={() => count.value++}>update</button>
39
+ </>
40
+ }
41
+ render(App);
42
+ expect(container.querySelector('p').textContent).toBe('sum: 4');
43
+ expect(container.querySelector('div').textContent).toBe('big: 2');
44
+ expect(calls).toBe(1);
45
+ container.querySelector('button').click();
46
+ flushSync();
47
+ expect(container.querySelector('p').textContent).toBe('sum: 6');
48
+ expect(container.querySelector('div').textContent).toBe('big: 3');
49
+ expect(calls).toBe(2);
50
+ });