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
@@ -16,17 +16,17 @@ export function SwitchStatic() @{
16
16
  }
17
17
 
18
18
  export function SwitchReactive() @{
19
- let &[status] = track<'a' | 'b' | 'c'>('a');
19
+ const status = track<'a' | 'b' | 'c'>('a');
20
20
  <>
21
21
  <button
22
22
  class="toggle"
23
23
  onClick={() => {
24
- if (status === 'a') status = 'b';
25
- else if (status === 'b') status = 'c';
26
- else status = 'a';
24
+ if (status.value === 'a') status.value = 'b';
25
+ else if (status.value === 'b') status.value = 'c';
26
+ else status.value = 'a';
27
27
  }}
28
28
  >{'Toggle'}</button>
29
- @switch (status) {
29
+ @switch (status.value) {
30
30
  @case 'a': {
31
31
  <div class="case-a">Case A</div>
32
32
  }
@@ -55,17 +55,17 @@ export function SwitchFallthrough() @{
55
55
  }
56
56
 
57
57
  export function SwitchNumericLevels() @{
58
- let &[level] = track<1 | 2 | 3>(1);
58
+ const level = track<1 | 2 | 3>(1);
59
59
  <>
60
60
  <button
61
61
  class="level-toggle"
62
62
  onClick={() => {
63
- if (level === 1) level = 2;
64
- else if (level === 2) level = 3;
65
- else level = 1;
63
+ if (level.value === 1) level.value = 2;
64
+ else if (level.value === 2) level.value = 3;
65
+ else level.value = 1;
66
66
  }}
67
67
  >{'Toggle Level'}</button>
68
- @switch (level) {
68
+ @switch (level.value) {
69
69
  @case 1: {
70
70
  <div class="level-1">{'Level 1'}</div>
71
71
  }
@@ -80,17 +80,17 @@ export function SwitchNumericLevels() @{
80
80
  }
81
81
 
82
82
  export function SwitchBlockScoped() @{
83
- let &[level] = track<1 | 2 | 3>(1);
83
+ const level = track<1 | 2 | 3>(1);
84
84
  <>
85
85
  <button
86
86
  class="block-toggle"
87
87
  onClick={() => {
88
- if (level === 1) level = 2;
89
- else if (level === 2) level = 3;
90
- else level = 1;
88
+ if (level.value === 1) level.value = 2;
89
+ else if (level.value === 2) level.value = 3;
90
+ else level.value = 1;
91
91
  }}
92
92
  >{'Toggle'}</button>
93
- @switch (level) {
93
+ @switch (level.value) {
94
94
  @case 1: {
95
95
  <div class="block-1">{'Block 1'}</div>
96
96
  }
@@ -105,17 +105,17 @@ export function SwitchBlockScoped() @{
105
105
  }
106
106
 
107
107
  export function SwitchNoBreak() @{
108
- let &[level] = track<1 | 2 | 3>(1);
108
+ const level = track<1 | 2 | 3>(1);
109
109
  <>
110
110
  <button
111
111
  class="nobreak-toggle"
112
112
  onClick={() => {
113
- if (level === 1) level = 2;
114
- else if (level === 2) level = 3;
115
- else level = 1;
113
+ if (level.value === 1) level.value = 2;
114
+ else if (level.value === 2) level.value = 3;
115
+ else level.value = 1;
116
116
  }}
117
117
  >{'Toggle'}</button>
118
- @switch (level) {
118
+ @switch (level.value) {
119
119
  @case 1: {
120
120
  <div class="nobreak-1">{'NoBreak 1'}</div>
121
121
  }
@@ -0,0 +1,4 @@
1
+ export interface TextRow {
2
+ label: string;
3
+ count: number | bigint;
4
+ }
@@ -1,29 +1,66 @@
1
1
  import { track, trackAsync } from 'ripple';
2
+ import { Money } from '../fixtures/money.js';
3
+
4
+ export const transport = {
5
+ Money: {
6
+ encode: (value: unknown) => value instanceof Money && [value.amount, value.currency],
7
+ decode: ([amount, currency]: [number, string]) => new Money(amount, currency),
8
+ },
9
+ };
2
10
 
3
11
  module server {
12
+ import { Money } from '../fixtures/money.js';
13
+
4
14
  export async function formatValue(n: number): Promise<string> {
5
15
  return `server-${n}`;
6
16
  }
17
+
18
+ export async function doubleMoney(value: Money): Promise<Money> {
19
+ if (!(value instanceof Money)) throw new Error('RPC argument was not revived');
20
+ return new Money(value.amount * 2, value.currency);
21
+ }
7
22
  }
8
23
 
9
- import { formatValue } from server;
24
+ import { doubleMoney, formatValue } from server;
25
+
26
+ function MoneyResult({ count }: { count: { value: number } }) @{
27
+ const money = trackAsync(() => doubleMoney(new Money(count.value, 'USD')));
28
+ <p class="result">{money.value.format()}</p>
29
+ }
30
+
31
+ export function AsyncCustomType() @{
32
+ const count = track(6);
33
+ <>
34
+ <button
35
+ class="increment"
36
+ onClick={() => {
37
+ count.value++;
38
+ }}
39
+ >{'increment'}</button>
40
+ @try {
41
+ <MoneyResult {count} />
42
+ } @pending {
43
+ <p class="loading">{'loading...'}</p>
44
+ }
45
+ </>
46
+ }
10
47
 
11
48
  function ServerCallResult({ count }: { count: { value: number } }) @{
12
- let &[data] = trackAsync(() => formatValue(count.value));
13
- <p class="result">{data}</p>
49
+ const data = trackAsync(() => formatValue(count.value));
50
+ <p class="result">{data.value}</p>
14
51
  }
15
52
 
16
53
  export function AsyncWithServerCall() @{
17
- let &[count, count_ref] = track(0);
54
+ const count = track(0);
18
55
  <>
19
56
  <button
20
57
  class="increment"
21
58
  onClick={() => {
22
- count++;
59
+ count.value++;
23
60
  }}
24
61
  >{'increment'}</button>
25
62
  @try {
26
- <ServerCallResult count={count_ref} />
63
+ <ServerCallResult {count} />
27
64
  } @pending {
28
65
  <p class="loading">{'loading...'}</p>
29
66
  }
@@ -32,8 +69,8 @@ export function AsyncWithServerCall() @{
32
69
 
33
70
  export function AsyncSimpleValue() @{
34
71
  @try {
35
- let &[data] = trackAsync(() => Promise.resolve('hydrated value'));
36
- <p class="result">{data}</p>
72
+ const data = trackAsync(() => Promise.resolve('hydrated value'));
73
+ <p class="result">{data.value}</p>
37
74
  } @pending {
38
75
  <p class="loading">{'loading...'}</p>
39
76
  }
@@ -41,8 +78,8 @@ export function AsyncSimpleValue() @{
41
78
 
42
79
  export function AsyncNumericValue() @{
43
80
  @try {
44
- let &[count] = trackAsync(() => Promise.resolve(42));
45
- <span class="count">{count}</span>
81
+ const count = trackAsync(() => Promise.resolve(42));
82
+ <span class="count">{count.value}</span>
46
83
  } @pending {
47
84
  <span class="pending">{'...'}</span>
48
85
  }
@@ -50,10 +87,10 @@ export function AsyncNumericValue() @{
50
87
 
51
88
  export function AsyncObjectValue() @{
52
89
  @try {
53
- let &[user] = trackAsync(() => Promise.resolve({ name: 'Alice', age: 30 }));
90
+ const user = trackAsync(() => Promise.resolve({ name: 'Alice', age: 30 }));
54
91
  <div class="user">
55
- <span class="name">{user.name}</span>
56
- <span class="age">{user.age}</span>
92
+ <span class="name">{user.value.name}</span>
93
+ <span class="age">{user.value.age}</span>
57
94
  </div>
58
95
  } @pending {
59
96
  <div class="loading">{'loading user...'}</div>
@@ -62,11 +99,11 @@ export function AsyncObjectValue() @{
62
99
 
63
100
  export function AsyncMultipleValues() @{
64
101
  @try {
65
- let &[first] = trackAsync(() => Promise.resolve('alpha'));
66
- let &[second] = trackAsync(() => Promise.resolve('beta'));
102
+ const first = trackAsync(() => Promise.resolve('alpha'));
103
+ const second = trackAsync(() => Promise.resolve('beta'));
67
104
  <div class="multi">
68
- <span class="first">{first}</span>
69
- <span class="second">{second}</span>
105
+ <span class="first">{first.value}</span>
106
+ <span class="second">{second.value}</span>
70
107
  </div>
71
108
  } @pending {
72
109
  <div class="loading">{'loading...'}</div>
@@ -75,8 +112,8 @@ export function AsyncMultipleValues() @{
75
112
 
76
113
  export function AsyncWithCatch() @{
77
114
  @try {
78
- let &[data] = trackAsync(() => Promise.reject(new Error('fetch failed')));
79
- <p class="result">{data}</p>
115
+ const data = trackAsync(() => Promise.reject(new Error('fetch failed')));
116
+ <p class="result">{data.value}</p>
80
117
  } @pending {
81
118
  <p class="loading">{'loading...'}</p>
82
119
  } @catch (e) {
@@ -86,8 +123,8 @@ export function AsyncWithCatch() @{
86
123
 
87
124
  export function ChildWithError() @{
88
125
  @try {
89
- let &[data] = trackAsync(() => Promise.reject(new Error('child error')));
90
- <p class="result">{data}</p>
126
+ const data = trackAsync(() => Promise.reject(new Error('child error')));
127
+ <p class="result">{data.value}</p>
91
128
  } @pending {
92
129
  <p class="pending">{'loading...'}</p>
93
130
  }
@@ -102,21 +139,38 @@ export function ParentWithCatch() @{
102
139
  }
103
140
 
104
141
  function ReactiveDependencyResult({ count }: { count: { value: number } }) @{
105
- let &[data] = trackAsync(() => Promise.resolve(`count-${count.value}`));
106
- <p class="result">{data}</p>
142
+ const data = trackAsync(() => Promise.resolve(`count-${count.value}`));
143
+ <p class="result">{data.value}</p>
107
144
  }
108
145
 
109
146
  export function AsyncWithReactiveDependency() @{
110
- let &[count, count_ref] = track(0);
147
+ const count = track(0);
148
+ <>
149
+ <button
150
+ class="increment"
151
+ onClick={() => {
152
+ count.value++;
153
+ }}
154
+ >{'increment'}</button>
155
+ @try {
156
+ <ReactiveDependencyResult {count} />
157
+ } @pending {
158
+ <p class="loading">{'loading...'}</p>
159
+ }
160
+ </>
161
+ }
162
+
163
+ export function AsyncWithReadOnlyDependency() @{
164
+ const count = track(0);
111
165
  <>
112
166
  <button
113
167
  class="increment"
114
168
  onClick={() => {
115
- count++;
169
+ count.value++;
116
170
  }}
117
171
  >{'increment'}</button>
118
172
  @try {
119
- <ReactiveDependencyResult count={count_ref} />
173
+ <ReactiveDependencyResult count={count.readOnly()} />
120
174
  } @pending {
121
175
  <p class="loading">{'loading...'}</p>
122
176
  }
@@ -17,13 +17,13 @@ export function RootThrows() @{
17
17
  }
18
18
 
19
19
  export function RootAsyncDirect() @{
20
- let &[value] = trackAsync(() => Promise.resolve('root async value'));
21
- <p class="root-async-value">{value}</p>
20
+ const value = trackAsync(() => Promise.resolve('root async value'));
21
+ <p class="root-async-value">{value.value}</p>
22
22
  }
23
23
 
24
24
  export function RootAsyncRejects() @{
25
- let &[value] = trackAsync(() => Promise.reject(new Error('root async failed')));
26
- <p class="root-async-value">{value}</p>
25
+ const value = trackAsync(() => Promise.reject(new Error('root async failed')));
26
+ <p class="root-async-value">{value.value}</p>
27
27
  }
28
28
 
29
29
  export function AsyncListInTryPending() @{
@@ -35,9 +35,9 @@ export function AsyncListInTryPending() @{
35
35
  }
36
36
 
37
37
  function AsyncList() @{
38
- let &[items] = trackAsync(() => Promise.resolve(['alpha', 'beta', 'gamma']));
38
+ const items = trackAsync(() => Promise.resolve(['alpha', 'beta', 'gamma']));
39
39
  <ul class="items">
40
- @for (let item of items) {
40
+ @for (let item of items.value) {
41
41
  <li>{item}</li>
42
42
  }
43
43
  </ul>
@@ -55,6 +55,6 @@ export function AsyncTryWithLeadingSibling() @{
55
55
  }
56
56
 
57
57
  function AsyncContent() @{
58
- let &[value] = trackAsync(() => Promise.resolve('ready'));
59
- <div class="resolved">{value}</div>
58
+ const value = trackAsync(() => Promise.resolve('ready'));
59
+ <div class="resolved">{value.value}</div>
60
60
  }
@@ -0,0 +1,22 @@
1
+ import { track } from 'ripple';
2
+ import type { TextRow } from './text-types';
3
+
4
+ export function TypedText() @{
5
+ const row = track<TextRow>({ label: '<first>', count: 2 });
6
+ <>
7
+ <div class="typed-run">
8
+ {'value: '}
9
+ {row.value.label}
10
+ {row.value.count}
11
+ <span>tail</span>
12
+ </div>
13
+ <p class="typed-number">
14
+ {(0, row.value.count)}
15
+ </p>
16
+ <button
17
+ onClick={() => {
18
+ row.value = { label: '&next', count: 3n };
19
+ }}
20
+ >update</button>
21
+ </>
22
+ }
@@ -0,0 +1,10 @@
1
+ export class Money {
2
+ /** @param {number} amount @param {string} currency */
3
+ constructor(amount, currency) {
4
+ this.amount = amount;
5
+ this.currency = currency;
6
+ }
7
+ format() {
8
+ return `${this.amount} ${this.currency}`;
9
+ }
10
+ }
@@ -477,4 +477,40 @@ describe('hydration > for blocks', () => {
477
477
  expect(listItems[2]?.textContent).toBe('B');
478
478
  expect(listItems[3]?.textContent).toBe('A');
479
479
  });
480
+
481
+ it('hydrates keyed for loop and appends and rotates before a trailing sibling', async () => {
482
+ await hydrateComponent(
483
+ ServerComponents.KeyedForLoopAppendAndRotate,
484
+ ClientComponents.KeyedForLoopAppendAndRotate,
485
+ );
486
+
487
+ const host = container.querySelector('.host');
488
+ expect(host?.textContent).toBe('123p');
489
+
490
+ container.querySelector('.push')?.click();
491
+ flushSync();
492
+ expect(host?.textContent).toBe('1234p');
493
+
494
+ container.querySelector('.rotate')?.click();
495
+ flushSync();
496
+ expect(host?.textContent).toBe('2341p');
497
+ });
498
+
499
+ it('hydrates a root keyed for loop component and appends and rotates before a trailing sibling', async () => {
500
+ await hydrateComponent(
501
+ ServerComponents.RootKeyedForLoopAppendAndRotate,
502
+ ClientComponents.RootKeyedForLoopAppendAndRotate,
503
+ );
504
+
505
+ const host = container.querySelector('.host');
506
+ expect(host?.textContent).toBe('123p');
507
+
508
+ container.querySelector('.push')?.click();
509
+ flushSync();
510
+ expect(host?.textContent).toBe('1234p');
511
+
512
+ container.querySelector('.rotate')?.click();
513
+ flushSync();
514
+ expect(host?.textContent).toBe('2341p');
515
+ });
480
516
  });
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { flushSync } from 'ripple';
3
+ import { render } from 'ripple/server';
4
+ import { hydrateComponent, container, stripHydrationMarkers } from '../setup-hydration.js';
5
+
6
+ import * as ServerComponents from './compiled/server/fragment-cursor.js';
7
+ import * as ClientComponents from './compiled/client/fragment-cursor.js';
8
+
9
+ // Component roots, mostly fragments, whose last node the hydration cursor
10
+ // must end on. Each is hydrated on its own and as `Wrap<Name>`: a child
11
+ // followed by siblings that hydrate wrongly if the cursor is off by a node.
12
+ const shapes = [
13
+ 'TrailingNavigatedElements',
14
+ 'TrailingStaticNavigatedElements',
15
+ 'NavigatedThenStatic',
16
+ 'LeadingNavigatedThenStatic',
17
+ 'TrailingNestedNavigated',
18
+ 'NestedNavigatedThenStatic',
19
+ 'TrackedTextThenStatic',
20
+ 'StaticThenTrackedText',
21
+ 'StaticNestedThenStatic',
22
+ 'AllStatic',
23
+ 'TrailingDynamicChild',
24
+ 'DynamicChildThenStatic',
25
+ 'IfThenStatic',
26
+ 'StaticThenIf',
27
+ 'CompThenStatic',
28
+ 'StaticThenComp',
29
+ 'SiblingComps',
30
+ 'UntrackedTextThenStatic',
31
+ 'NestedFragmentThenStatic',
32
+ 'TrailingNestedFragment',
33
+ 'ForThenStatic',
34
+ 'SwitchThenStatic',
35
+ 'TryThenStatic',
36
+ 'StyleThenStatic',
37
+ 'CollectionThenStatic',
38
+ 'InlineElementThenStatic',
39
+ 'IfOnly',
40
+ 'IfThenOne',
41
+ 'SingleRootWithIf',
42
+ 'IfTwoThenStatic',
43
+ 'StaticCallThenStatic',
44
+ 'StaticThenStyleThenStatic',
45
+ ];
46
+
47
+ // Single-root shapes whose children end on a slot the cursor must step past.
48
+ const rootShapes = ['ExprThenSiblingInDiv', 'IfTwoInDiv', 'ForTwoNodeItems'];
49
+
50
+ /**
51
+ * Server renders and hydrates a component, asserting the hydrated DOM matches
52
+ * the server markup (no duplicated or dropped nodes).
53
+ * @param {string} name
54
+ */
55
+ async function hydrateShape(name) {
56
+ const { body } = await render(ServerComponents[name]);
57
+ await hydrateComponent(ServerComponents[name], ClientComponents[name]);
58
+ expect(container.innerHTML).toBeHtml(stripHydrationMarkers(body));
59
+ }
60
+
61
+ /**
62
+ * Clicks the shape's own `.inc` button (when it has one) and checks the
63
+ * hydrated `.count` text updates in place.
64
+ */
65
+ function clickInner() {
66
+ const inc = container.querySelector('.inc');
67
+ const count = container.querySelector('.count');
68
+ if (inc && count) {
69
+ expect(count.textContent).toBe('0');
70
+ inc.click();
71
+ flushSync();
72
+ expect(count.textContent).toBe('1');
73
+ }
74
+ }
75
+
76
+ describe('hydration > fragment cursor', () => {
77
+ describe('fragment as the root', () => {
78
+ for (const name of shapes) {
79
+ it(`hydrates ${name}`, async () => {
80
+ await hydrateShape(name);
81
+ clickInner();
82
+ });
83
+ }
84
+ });
85
+
86
+ describe('slot followed by siblings inside an element', () => {
87
+ for (const name of rootShapes) {
88
+ it(`hydrates ${name}`, async () => {
89
+ await hydrateShape(name);
90
+ clickInner();
91
+ });
92
+ }
93
+ });
94
+
95
+ describe('fragment as a child with trailing siblings', () => {
96
+ for (const name of shapes) {
97
+ it(`hydrates ${name} followed by siblings`, async () => {
98
+ await hydrateShape(`Wrap${name}`);
99
+
100
+ const after = container.querySelector('.after');
101
+ expect(after?.textContent).toBe('0');
102
+
103
+ container.querySelector('.outer-inc')?.click();
104
+ flushSync();
105
+ expect(after?.textContent).toBe('1');
106
+
107
+ clickInner();
108
+ });
109
+ }
110
+ });
111
+ });
@@ -269,4 +269,28 @@ describe('hydration > if blocks with children', () => {
269
269
  expect(container.querySelector('.section-heading')?.textContent).toBe('Section Heading');
270
270
  expect(container.querySelector('.section-content a')?.textContent).toBe('a link');
271
271
  });
272
+
273
+ it('re-renders control-flow roots of all-component children at their own position', async () => {
274
+ await hydrateComponent(
275
+ ServerComponents.ComponentChildrenWithControlFlowRoots,
276
+ ClientComponents.ComponentChildrenWithControlFlowRoots,
277
+ );
278
+
279
+ const host = container.querySelector('.host');
280
+ expect(host?.textContent).toBe('on123end');
281
+
282
+ // The root `@if` child swaps its branch in place, before the siblings.
283
+ container.querySelector('.toggle')?.click();
284
+ flushSync();
285
+ expect(host?.textContent).toBe('off123end');
286
+
287
+ // The root `@for` child moves an item to its own end, not the host's.
288
+ container.querySelector('.rotate')?.click();
289
+ flushSync();
290
+ expect(host?.textContent).toBe('off231end');
291
+
292
+ container.querySelector('.toggle')?.click();
293
+ flushSync();
294
+ expect(host?.textContent).toBe('on231end');
295
+ });
272
296
  });
@@ -269,6 +269,25 @@ describe('hydration > streamed boundaries (chunk after hydration)', () => {
269
269
  expect(container.innerHTML).not.toContain('<!--[?');
270
270
  });
271
271
 
272
+ it('hydrates a streamed root slot under a boundary even with rootBoundary: false', async () => {
273
+ const { chunks, done } = startStream(ServerComponents.StreamRootDirect, {
274
+ pending: ServerComponents.RootPending,
275
+ });
276
+ mountShell(chunks[0]);
277
+
278
+ // The shell starts with a slot marker: only a boundary can adopt the
279
+ // fallback and activate the chunk, so the opt-out does not apply here.
280
+ hydrate(ClientComponents.StreamRootDirect, { target: container, rootBoundary: false });
281
+
282
+ ServerComponents.controls.rootDirect.resolve('root data');
283
+ await done;
284
+ receiveChunk(chunks[1]);
285
+ flushSync();
286
+
287
+ expect(container.querySelector('.root-async')?.textContent).toBe('root data');
288
+ expect(container.innerHTML).not.toContain('<!--[?');
289
+ });
290
+
272
291
  it('hydrates a root chunk that arrived before hydration through the normal path', async () => {
273
292
  const { chunks, done } = startStream(ServerComponents.StreamRootDirect, {
274
293
  pending: ServerComponents.RootPending,
@@ -1,6 +1,7 @@
1
1
  import { DEV } from 'esm-env';
2
- import { describe, it, expect, vi } from 'vitest';
3
- import { flushSync } from 'ripple';
2
+ import { afterEach, describe, it, expect, vi } from 'vitest';
3
+ import { flushSync, hydrate, setTransport } from 'ripple';
4
+ import { executeServerFunction, render, setTransport as setServerTransport } from 'ripple/server';
4
5
  import * as devalue from 'devalue';
5
6
  import { hydrateComponent, container } from '../setup-hydration.js';
6
7
 
@@ -12,6 +13,73 @@ const TRACK_ASYNC_ERROR_MESSAGE = DEV ? 'fetch failed' : TRACK_ASYNC_PUBLIC_ERRO
12
13
  const TRACK_ASYNC_CHILD_ERROR_MESSAGE = DEV ? 'child error' : TRACK_ASYNC_PUBLIC_ERROR_MESSAGE;
13
14
 
14
15
  describe('hydration > trackAsync serialization', () => {
16
+ afterEach(() => {
17
+ setTransport();
18
+ setServerTransport();
19
+ vi.unstubAllGlobals();
20
+ });
21
+
22
+ it('also accepts string devalue payloads with a registered transport', async () => {
23
+ setServerTransport(ServerComponents.transport);
24
+ setTransport(ClientComponents.transport);
25
+ const { body } = await render(ServerComponents.AsyncCustomType);
26
+ container.innerHTML = body;
27
+ for (const script of container.querySelectorAll('script[id^="__ripple_ta_"]')) {
28
+ const envelope = JSON.parse(script.textContent);
29
+ envelope.payload = JSON.stringify(envelope.payload);
30
+ script.textContent = JSON.stringify(envelope);
31
+ }
32
+ const unmount = hydrate(ClientComponents.AsyncCustomType, { target: container });
33
+ try {
34
+ expect(container.querySelector('.result')?.textContent).toBe('12 USD');
35
+ expect(container.querySelector('script[id^="__ripple_ta_"]')).toBeNull();
36
+ } finally {
37
+ unmount();
38
+ }
39
+ });
40
+
41
+ it('revives a custom class during hydration and sends it through RPC in both directions', async () => {
42
+ const serverDecode = vi.fn(ServerComponents.transport.Money.decode);
43
+ const clientDecode = vi.fn(ClientComponents.transport.Money.decode);
44
+ setServerTransport({ Money: { ...ServerComponents.transport.Money, decode: serverDecode } });
45
+ setTransport({ Money: { ...ClientComponents.transport.Money, decode: clientDecode } });
46
+ const fetchMock = vi.fn(async (_url, init) => {
47
+ const body = await executeServerFunction(
48
+ ServerComponents._$_server_$_.doubleMoney,
49
+ init.body,
50
+ );
51
+ return new Response(body, { status: 200 });
52
+ });
53
+ vi.stubGlobal('fetch', fetchMock);
54
+
55
+ const { unmount } = await hydrateComponent(
56
+ ServerComponents.AsyncCustomType,
57
+ ClientComponents.AsyncCustomType,
58
+ );
59
+ try {
60
+ // Calling the instance method requires the client decoder, and hydration
61
+ // must adopt the server result without refetching it.
62
+ expect(container.querySelector('.result')?.textContent).toBe('12 USD');
63
+ expect(container.querySelector('.loading')).toBeNull();
64
+ expect(container.querySelector('script[id^="__ripple_ta_"]')).toBeNull();
65
+ expect(fetchMock).not.toHaveBeenCalled();
66
+ expect(clientDecode).toHaveBeenCalledTimes(1);
67
+ expect(serverDecode).not.toHaveBeenCalled();
68
+
69
+ container.querySelector('.increment').click();
70
+ flushSync();
71
+ await vi.waitFor(() => {
72
+ expect(container.querySelector('.result')?.textContent).toBe('14 USD');
73
+ });
74
+ expect(fetchMock).toHaveBeenCalledTimes(1);
75
+ expect(fetchMock.mock.calls[0][0]).toMatch(/\/_\$_ripple_rpc_\$_\//);
76
+ expect(clientDecode).toHaveBeenCalledTimes(2);
77
+ expect(serverDecode).toHaveBeenCalledTimes(1);
78
+ } finally {
79
+ unmount();
80
+ }
81
+ });
82
+
15
83
  it('hydrates simple string value from serialized trackAsync', async () => {
16
84
  await hydrateComponent(ServerComponents.AsyncSimpleValue, ClientComponents.AsyncSimpleValue);
17
85
 
@@ -77,6 +145,23 @@ describe('hydration > trackAsync serialization', () => {
77
145
  expect(container.querySelector('.result')?.textContent).toBe('count-1');
78
146
  });
79
147
 
148
+ it('reruns trackAsync when a dependency read through readOnly() changes after hydration', async () => {
149
+ await hydrateComponent(
150
+ ServerComponents.AsyncWithReadOnlyDependency,
151
+ ClientComponents.AsyncWithReadOnlyDependency,
152
+ );
153
+
154
+ expect(container.querySelector('.result')?.textContent).toBe('count-0');
155
+
156
+ /** @type {any} */ (container.querySelector('.increment'))?.click();
157
+ flushSync();
158
+ await Promise.resolve();
159
+ await Promise.resolve();
160
+ flushSync();
161
+
162
+ expect(container.querySelector('.result')?.textContent).toBe('count-1');
163
+ });
164
+
80
165
  it('reruns trackAsync via module server RPC call when a dependency changes', async () => {
81
166
  const originalFetch = globalThis.fetch;
82
167
  const fetchMock = vi.fn(async (_url, init) => {