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
@@ -1,14 +1,17 @@
1
1
  import { DEV } from 'esm-env';
2
- import { trackAsync } from 'ripple';
2
+ import { setTransport, track, trackAsync } from 'ripple';
3
+ import * as devalue from 'devalue';
3
4
 
4
5
  const TRACK_ASYNC_PUBLIC_ERROR_MESSAGE = 'An error occurred during async rendering';
5
6
 
6
7
  describe('trackAsync serialization (server)', () => {
8
+ afterEach(() => setTransport());
9
+
7
10
  it('serializes resolved trackAsync data as a script tag in SSR output', async () => {
8
11
  function App() @{
9
12
  @try {
10
- let &[data] = trackAsync(() => Promise.resolve('hello world'));
11
- <p class="result">{data}</p>
13
+ const data = trackAsync(() => Promise.resolve('hello world'));
14
+ <p class="result">{data.value}</p>
12
15
  } @pending {
13
16
  <p class="loading">{'loading...'}</p>
14
17
  }
@@ -24,8 +27,8 @@ describe('trackAsync serialization (server)', () => {
24
27
  it('serializes numeric trackAsync data correctly', async () => {
25
28
  function App() @{
26
29
  @try {
27
- let &[count] = trackAsync(() => Promise.resolve(42));
28
- <span class="count">{count}</span>
30
+ const count = trackAsync(() => Promise.resolve(42));
31
+ <span class="count">{count.value}</span>
29
32
  } @pending {
30
33
  <span>{'...'}</span>
31
34
  }
@@ -39,8 +42,8 @@ describe('trackAsync serialization (server)', () => {
39
42
  it('serializes object trackAsync data correctly', async () => {
40
43
  function App() @{
41
44
  @try {
42
- let &[data] = trackAsync(() => Promise.resolve({ name: 'Alice', age: 30 }));
43
- <p>{data.name}</p>
45
+ const data = trackAsync(() => Promise.resolve({ name: 'Alice', age: 30 }));
46
+ <p>{data.value.name}</p>
44
47
  } @pending {
45
48
  <p>{'...'}</p>
46
49
  }
@@ -54,9 +57,9 @@ describe('trackAsync serialization (server)', () => {
54
57
  it('serializes array trackAsync data correctly', async () => {
55
58
  function App() @{
56
59
  @try {
57
- let &[items] = trackAsync(() => Promise.resolve(['a', 'b', 'c']));
60
+ const items = trackAsync(() => Promise.resolve(['a', 'b', 'c']));
58
61
  <ul>
59
- @for (let item of items) {
62
+ @for (let item of items.value) {
60
63
  <li>{item}</li>
61
64
  }
62
65
  </ul>
@@ -73,8 +76,8 @@ describe('trackAsync serialization (server)', () => {
73
76
  it('serializes rejected trackAsync as error envelope', async () => {
74
77
  function App() @{
75
78
  @try {
76
- let &[data] = trackAsync(() => Promise.reject(new Error('fetch failed')));
77
- <p class="result">{data}</p>
79
+ const data = trackAsync(() => Promise.reject(new Error('fetch failed')));
80
+ <p class="result">{data.value}</p>
78
81
  } @pending {
79
82
  <p class="loading">{'loading...'}</p>
80
83
  } @catch (e) {
@@ -93,8 +96,8 @@ describe('trackAsync serialization (server)', () => {
93
96
  it('serializes error when child component error bubbles to parent catch', async () => {
94
97
  function Child() @{
95
98
  @try {
96
- let &[data] = trackAsync(() => Promise.reject(new Error('child error')));
97
- <p class="result">{data}</p>
99
+ const data = trackAsync(() => Promise.reject(new Error('child error')));
100
+ <p class="result">{data.value}</p>
98
101
  } @pending {
99
102
  <p class="pending">{'loading...'}</p>
100
103
  }
@@ -119,10 +122,10 @@ describe('trackAsync serialization (server)', () => {
119
122
  it('serializes sync trackAsync throw using original cause message', async () => {
120
123
  function App() @{
121
124
  @try {
122
- let &[data] = trackAsync(() => {
125
+ const data = trackAsync(() => {
123
126
  throw new Error('sync failure');
124
127
  });
125
- <p class="result">{data}</p>
128
+ <p class="result">{data.value}</p>
126
129
  } @pending {
127
130
  <p class="loading">{'loading...'}</p>
128
131
  } @catch (e) {
@@ -139,13 +142,193 @@ describe('trackAsync serialization (server)', () => {
139
142
  expect(body).not.toContain('Error thrown during trackAsync execution');
140
143
  });
141
144
 
145
+ describe('hydration envelope dependencies', () => {
146
+ /** @param {'direct' | 'view'} read */
147
+ async function deps_for(read: 'direct' | 'view') {
148
+ function App() @{
149
+ const count = track(3);
150
+ const view = count.readOnly();
151
+ @try {
152
+ const data = trackAsync(
153
+ () => Promise.resolve(`count-${read === 'view' ? view.value : count.value}`),
154
+ );
155
+ <p>{data.value}</p>
156
+ } @pending {
157
+ <p>{'...'}</p>
158
+ }
159
+ }
160
+
161
+ const { body } = await render(App);
162
+ const match = body.match(
163
+ /<script id="__ripple_ta_[a-f0-9]+" type="application\/json">(.*?)<\/script>/s,
164
+ );
165
+ expect(match).not.toBeNull();
166
+ return JSON.parse(/** @type {RegExpMatchArray} */ (match)[1]).deps;
167
+ }
168
+
169
+ it('records the tracked a trackAsync read directly', async () => {
170
+ expect(await deps_for('direct')).toHaveLength(1);
171
+ });
172
+
173
+ it('records the tracked behind a readOnly() view a trackAsync read', async () => {
174
+ // The view itself has no hash; the client must still re-subscribe to
175
+ // the source after hydration, so its hash is what gets recorded.
176
+ expect(await deps_for('view')).toEqual(await deps_for('direct'));
177
+ });
178
+ });
179
+
180
+ describe('hydration envelope encoding', () => {
181
+ async function envelope_for(value: unknown) {
182
+ function App() @{
183
+ @try {
184
+ const data = trackAsync(() => Promise.resolve(value));
185
+ <p>{String(data.value !== undefined)}</p>
186
+ } @pending {
187
+ <p>{'...'}</p>
188
+ }
189
+ }
190
+
191
+ const { body } = await render(App);
192
+ const match = body.match(
193
+ /<script id="__ripple_ta_[a-f0-9]+" type="application\/json">(.*?)<\/script>/s,
194
+ );
195
+ expect(match).not.toBeNull();
196
+ const text = /** @type {RegExpMatchArray} */ (match)[1];
197
+ expect(text).not.toContain('<');
198
+ return JSON.parse(text);
199
+ }
200
+
201
+ it('ships plain data trees as raw JSON', async () => {
202
+ const value = {
203
+ title: 'Card 0 — "alpha" \\ <b>&</b>',
204
+ count: 3,
205
+ ratio: 0.5,
206
+ ok: true,
207
+ none: null,
208
+ items: [{ label: 'a\nb', value: 'tab\there' }, ['x', 1, false], '😀 '],
209
+ 'odd key': { nested: [] },
210
+ };
211
+ const envelope = await envelope_for(value);
212
+ expect(envelope.ok).toBe(true);
213
+ expect(envelope.payload).toBeUndefined();
214
+ expect(envelope.value).toEqual(value);
215
+ });
216
+
217
+ it('ships primitives as raw JSON', async () => {
218
+ expect((await envelope_for('hello')).value).toBe('hello');
219
+ expect((await envelope_for('lone � surrogate')).value).toBe('lone � surrogate');
220
+ expect((await envelope_for(42)).value).toBe(42);
221
+ expect((await envelope_for(false)).value).toBe(false);
222
+ expect((await envelope_for(null)).value).toBe(null);
223
+ });
224
+
225
+ it('keeps the devalue encoding for values JSON cannot represent', async () => {
226
+ const shared = { n: 1 };
227
+ const cyclic: { self?: unknown } = {};
228
+ cyclic.self = cyclic;
229
+ for (const value of [
230
+ undefined,
231
+ NaN,
232
+ Infinity,
233
+ -0,
234
+ 10n,
235
+ new Date(0),
236
+ new Map([[1, 2]]),
237
+ new Set([1]),
238
+ /re/,
239
+ Object.create(null),
240
+ { a: undefined },
241
+ { a: shared, b: shared },
242
+ [shared, shared],
243
+ cyclic,
244
+ // eslint-disable-next-line no-sparse-arrays
245
+ [1, , 3],
246
+ ]) {
247
+ const envelope = await envelope_for(value);
248
+ expect(envelope.value).toBeUndefined();
249
+ expect(typeof envelope.payload).toBe('string');
250
+ }
251
+ });
252
+
253
+ it('keeps the raw JSON path for an empty transport', async () => {
254
+ setTransport({});
255
+ const value = { title: 'Card', items: [1, true, null] };
256
+ expect(await envelope_for(value)).toEqual({ ok: true, value });
257
+ });
258
+
259
+ it('serializes custom classes and preserves shared references', async () => {
260
+ class Money {
261
+ amount: number;
262
+ currency: string;
263
+ constructor(amount: number, currency: string) {
264
+ this.amount = amount;
265
+ this.currency = currency;
266
+ }
267
+ }
268
+ const decode = ([amount, currency]: [number, string]) => new Money(amount, currency);
269
+ setTransport({
270
+ Money: {
271
+ encode: (value) => value instanceof Money && [value.amount, value.currency],
272
+ decode,
273
+ },
274
+ });
275
+ const money = new Money(12, '</script><script>injected</script>');
276
+ const envelope = await envelope_for({ first: money, second: money, date: new Date(0) });
277
+ expect(envelope.value).toBeUndefined();
278
+ const restored = devalue.unflatten(envelope.payload, { Money: decode });
279
+ expect(restored.first).toBeInstanceOf(Money);
280
+ expect(restored.first).toEqual(money);
281
+ expect(restored.second).toBe(restored.first);
282
+ expect(restored.date).toEqual(new Date(0));
283
+ });
284
+
285
+ it('lets reducers claim plain objects without first walking the JSON path', async () => {
286
+ const read_amount = vi.fn(() => 12);
287
+ const encode = vi.fn((value: any) => value?.__type === 'Money' && [value.amount]);
288
+ const decode = ([amount]: [number]) => ({ amount, revived: true });
289
+ setTransport({ Money: { encode, decode } });
290
+ const value = {
291
+ nested: {
292
+ __type: 'Money',
293
+ get amount() {
294
+ return read_amount();
295
+ },
296
+ },
297
+ };
298
+ const envelope = await envelope_for(value);
299
+ expect(envelope.value).toBeUndefined();
300
+ expect(devalue.unflatten(envelope.payload, { Money: decode })).toEqual({
301
+ nested: { amount: 12, revived: true },
302
+ });
303
+ expect(encode.mock.calls.filter(([node]) => node === value.nested)).toHaveLength(1);
304
+ expect(read_amount).toHaveBeenCalledTimes(1);
305
+ });
306
+
307
+ it(
308
+ 'uses devalue for plain data under an unused transport and restores JSON after reset',
309
+ async () => {
310
+ setTransport({ Unused: { encode: () => undefined, decode: (value) => value } });
311
+ const value = { title: 'Card', items: [1, true, null] };
312
+ const envelope = await envelope_for(value);
313
+ expect(envelope.value).toBeUndefined();
314
+ expect(devalue.unflatten(envelope.payload)).toEqual(value);
315
+ for (const special of [undefined, NaN, Infinity, -Infinity, -0, 10n, new Date(0)]) {
316
+ const encoded = await envelope_for(special);
317
+ expect(devalue.unflatten(encoded.payload)).toEqual(special);
318
+ }
319
+ setTransport();
320
+ expect(await envelope_for(value)).toEqual({ ok: true, value });
321
+ },
322
+ );
323
+ });
324
+
142
325
  it('escapes serialized error payload to keep script tag safe', async () => {
143
326
  function App() @{
144
327
  @try {
145
- let &[data] = trackAsync(
328
+ const data = trackAsync(
146
329
  () => Promise.reject(new Error('</script><div data-injected="1">boom</div>')),
147
330
  );
148
- <p class="result">{data}</p>
331
+ <p class="result">{data.value}</p>
149
332
  } @pending {
150
333
  <p class="loading">{'loading...'}</p>
151
334
  } @catch (e) {
@@ -166,11 +349,11 @@ describe('trackAsync serialization (server)', () => {
166
349
  it('generates unique hashes for multiple trackAsync calls', async () => {
167
350
  function App() @{
168
351
  @try {
169
- let &[a] = trackAsync(() => Promise.resolve('first'));
170
- let &[b] = trackAsync(() => Promise.resolve('second'));
352
+ const a = trackAsync(() => Promise.resolve('first'));
353
+ const b = trackAsync(() => Promise.resolve('second'));
171
354
  <>
172
- <p>{a}</p>
173
- <p>{b}</p>
355
+ <p>{a.value}</p>
356
+ <p>{b.value}</p>
174
357
  </>
175
358
  } @pending {
176
359
  <p>{'...'}</p>
@@ -3,8 +3,8 @@ import { trackAsync } from 'ripple';
3
3
  describe('try block with catch and pending (server)', () => {
4
4
  it('renders resolved content with an empty pending fallback', async () => {
5
5
  function DataChild() @{
6
- let &[data] = trackAsync(() => Promise.resolve('resolved value'));
7
- <p>{data}</p>
6
+ const data = trackAsync(() => Promise.resolve('resolved value'));
7
+ <p>{data.value}</p>
8
8
  }
9
9
 
10
10
  function App() @{
@@ -47,9 +47,9 @@ describe('try block with catch and pending (server)', () => {
47
47
 
48
48
  it('catch block works when component throws after await with pending block', async () => {
49
49
  function ThrowingAfterAwait() @{
50
- let &[data] = trackAsync(() => Promise.resolve('hello'));
50
+ const data = trackAsync(() => Promise.resolve('hello'));
51
51
  throw new Error('error after await');
52
- <p>{data}</p>
52
+ <p>{data.value}</p>
53
53
  }
54
54
 
55
55
  function App() @{
@@ -70,8 +70,8 @@ describe('try block with catch and pending (server)', () => {
70
70
  it('catch block works with try/catch/pending when async body rejects', async () => {
71
71
  function App() @{
72
72
  @try {
73
- let &[data] = trackAsync(() => Promise.reject(new Error('rejected')));
74
- <p>{data}</p>
73
+ const data = trackAsync(() => Promise.reject(new Error('rejected')));
74
+ <p>{data.value}</p>
75
75
  } @pending {
76
76
  <p>{'loading...'}</p>
77
77
  } @catch (err) {
@@ -88,8 +88,8 @@ describe('try block with catch and pending (server)', () => {
88
88
  function App() @{
89
89
  @try {
90
90
  @try {
91
- let &[data] = trackAsync(() => Promise.resolve('resolved'));
92
- <p>{data}</p>
91
+ const data = trackAsync(() => Promise.resolve('resolved'));
92
+ <p>{data.value}</p>
93
93
  } @pending {
94
94
  <p>{'inner loading...'}</p>
95
95
  }
@@ -116,8 +116,8 @@ describe('trackAsync directly in component body (server)', () => {
116
116
  }
117
117
 
118
118
  function DataChild() @{
119
- let &[data] = trackAsync(() => Promise.resolve('from child'));
120
- <p>{data}</p>
119
+ const data = trackAsync(() => Promise.resolve('from child'));
120
+ <p>{data.value}</p>
121
121
  }
122
122
 
123
123
  const { body } = await render(App);
@@ -128,12 +128,12 @@ describe('trackAsync directly in component body (server)', () => {
128
128
  it('resolves multiple trackAsync values in the same component', async () => {
129
129
  function App() @{
130
130
  @try {
131
- let &[a] = trackAsync(() => Promise.resolve('hello'));
132
- let &[b] = trackAsync(() => Promise.resolve('world'));
131
+ const a = trackAsync(() => Promise.resolve('hello'));
132
+ const b = trackAsync(() => Promise.resolve('world'));
133
133
  <p>
134
- {a}
134
+ {a.value}
135
135
  {' '}
136
- {b}
136
+ {b.value}
137
137
  </p>
138
138
  } @catch (err) {
139
139
  <p>{'error'}</p>
@@ -147,8 +147,8 @@ describe('trackAsync directly in component body (server)', () => {
147
147
 
148
148
  it('renders catch when trackAsync rejects in a child component without its own try', async () => {
149
149
  function RejectChild() @{
150
- let &[data] = trackAsync(() => Promise.reject(new Error('child rejected')));
151
- <p>{data}</p>
150
+ const data = trackAsync(() => Promise.reject(new Error('child rejected')));
151
+ <p>{data.value}</p>
152
152
  }
153
153
 
154
154
  function App() @{
@@ -166,12 +166,12 @@ describe('trackAsync directly in component body (server)', () => {
166
166
  it('chained trackAsync resolves both values', async () => {
167
167
  function App() @{
168
168
  @try {
169
- let &[name] = trackAsync(() => Promise.resolve('ripple'));
170
- let &[upper] = trackAsync(() => {
171
- const n = name;
169
+ const name = trackAsync(() => Promise.resolve('ripple'));
170
+ const upper = trackAsync(() => {
171
+ const n = name.value;
172
172
  return Promise.resolve(n.toUpperCase());
173
173
  });
174
- <p>{upper}</p>
174
+ <p>{upper.value}</p>
175
175
  } @catch (err) {
176
176
  <p>{'error'}</p>
177
177
  }
@@ -185,12 +185,12 @@ describe('trackAsync directly in component body (server)', () => {
185
185
  it('chained trackAsync catches when first rejects', async () => {
186
186
  function App() @{
187
187
  @try {
188
- let &[name] = trackAsync(() => Promise.reject<string>(new Error('first failed')));
189
- let &[upper] = trackAsync(() => {
190
- const n = name;
188
+ const name = trackAsync(() => Promise.reject<string>(new Error('first failed')));
189
+ const upper = trackAsync(() => {
190
+ const n = name.value;
191
191
  return Promise.resolve(n.toUpperCase());
192
192
  });
193
- <p>{upper}</p>
193
+ <p>{upper.value}</p>
194
194
  } @catch (err: Error) {
195
195
  <p>{err.message}</p>
196
196
  }
@@ -203,12 +203,12 @@ describe('trackAsync directly in component body (server)', () => {
203
203
  it('chained trackAsync catches when second rejects', async () => {
204
204
  function App() @{
205
205
  @try {
206
- let &[name] = trackAsync(() => Promise.resolve('ripple'));
207
- let &[upper] = trackAsync(() => {
208
- const n = name;
206
+ const name = trackAsync(() => Promise.resolve('ripple'));
207
+ const upper = trackAsync(() => {
208
+ const n = name.value;
209
209
  return Promise.reject(new Error('second failed'));
210
210
  });
211
- <p>{upper}</p>
211
+ <p>{upper.value}</p>
212
212
  } @catch (err: Error) {
213
213
  <p>{err.message}</p>
214
214
  }
@@ -248,8 +248,8 @@ describe('nested child components with try/catch boundaries (server)', () => {
248
248
 
249
249
  it('inner try/catch catches async rejection from its own child', async () => {
250
250
  function AsyncChild() @{
251
- let &[data] = trackAsync(() => Promise.reject(new Error('async inner error')));
252
- <p>{data}</p>
251
+ const data = trackAsync(() => Promise.reject(new Error('async inner error')));
252
+ <p>{data.value}</p>
253
253
  }
254
254
 
255
255
  function Inner() @{
@@ -301,8 +301,8 @@ describe('nested child components with try/catch boundaries (server)', () => {
301
301
 
302
302
  it('async rejection propagates to outer catch when inner try has no catch', async () => {
303
303
  function AsyncChild() @{
304
- let &[data] = trackAsync(() => Promise.reject(new Error('async propagated')));
305
- <p>{data}</p>
304
+ const data = trackAsync(() => Promise.reject(new Error('async propagated')));
305
+ <p>{data.value}</p>
306
306
  }
307
307
 
308
308
  function Inner() @{
@@ -364,13 +364,13 @@ describe('nested child components with try/catch boundaries (server)', () => {
364
364
 
365
365
  it('sibling components: one fails, the other does not affect catch', async () => {
366
366
  function GoodChild() @{
367
- let &[data] = trackAsync(() => Promise.resolve('good'));
368
- <p>{data}</p>
367
+ const data = trackAsync(() => Promise.resolve('good'));
368
+ <p>{data.value}</p>
369
369
  }
370
370
 
371
371
  function BadChild() @{
372
- let &[data] = trackAsync(() => Promise.reject(new Error('bad child')));
373
- <p>{data}</p>
372
+ const data = trackAsync(() => Promise.reject(new Error('bad child')));
373
+ <p>{data.value}</p>
374
374
  }
375
375
 
376
376
  function App() @{
@@ -391,13 +391,13 @@ describe('nested child components with try/catch boundaries (server)', () => {
391
391
 
392
392
  it('independent try/catch boundaries each handle their own errors', async () => {
393
393
  function FailChild() @{
394
- let &[data] = trackAsync(() => Promise.reject(new Error('fail')));
395
- <p>{data}</p>
394
+ const data = trackAsync(() => Promise.reject(new Error('fail')));
395
+ <p>{data.value}</p>
396
396
  }
397
397
 
398
398
  function SuccessChild() @{
399
- let &[data] = trackAsync(() => Promise.resolve('success'));
400
- <p>{data}</p>
399
+ const data = trackAsync(() => Promise.resolve('success'));
400
+ <p>{data.value}</p>
401
401
  }
402
402
 
403
403
  function App() @{
@@ -423,8 +423,8 @@ describe('nested child components with try/catch boundaries (server)', () => {
423
423
 
424
424
  it('inner catch handles rejection, outer renders normally', async () => {
425
425
  function AsyncChild() @{
426
- let &[data] = trackAsync(() => Promise.reject(new Error('handled inside')));
427
- <p>{data}</p>
426
+ const data = trackAsync(() => Promise.reject(new Error('handled inside')));
427
+ <p>{data.value}</p>
428
428
  }
429
429
 
430
430
  function Inner() @{
@@ -449,9 +449,9 @@ describe('nested child components with try/catch boundaries (server)', () => {
449
449
 
450
450
  it('sync error in child after trackAsync routes to catch boundary', async () => {
451
451
  function BrokenChild() @{
452
- let &[data] = trackAsync(() => Promise.resolve('loaded'));
452
+ const data = trackAsync(() => Promise.resolve('loaded'));
453
453
  throw new Error('sync after async');
454
- <p>{data}</p>
454
+ <p>{data.value}</p>
455
455
  }
456
456
 
457
457
  function Inner() @{
@@ -472,8 +472,8 @@ describe('nested child components with try/catch boundaries (server)', () => {
472
472
 
473
473
  it('outer try with pending, inner try with catch: rejection goes to inner catch', async () => {
474
474
  function AsyncChild() @{
475
- let &[data] = trackAsync(() => Promise.reject(new Error('inner rejection')));
476
- <p>{data}</p>
475
+ const data = trackAsync(() => Promise.reject(new Error('inner rejection')));
476
+ <p>{data.value}</p>
477
477
  }
478
478
 
479
479
  function Inner() @{
@@ -499,8 +499,8 @@ describe('nested child components with try/catch boundaries (server)', () => {
499
499
 
500
500
  it('deeply nested: async resolves through multiple component layers', async () => {
501
501
  function DataFetcher() @{
502
- let &[data] = trackAsync(() => Promise.resolve('deep data'));
503
- <span>{data}</span>
502
+ const data = trackAsync(() => Promise.resolve('deep data'));
503
+ <span>{data.value}</span>
504
504
  }
505
505
 
506
506
  function Level2() @{
@@ -0,0 +1,40 @@
1
+ import { setTransport, type Transport, type Transporter } from 'ripple';
2
+ import { setTransport as setServerTransport } from 'ripple/server';
3
+ import { setTransport as registerTransport } from 'ripple/transport';
4
+ import { defineConfig } from '@ripple-ts/vite-plugin';
5
+
6
+ class Money {
7
+ constructor(
8
+ public amount: number,
9
+ public currency: string,
10
+ ) {}
11
+ }
12
+
13
+ const money: Transporter<Money, [number, string]> = {
14
+ encode: (value) => value instanceof Money && [value.amount, value.currency],
15
+ decode: ([amount, currency]) => new Money(amount, currency),
16
+ };
17
+ const transport: Transport = { Money: money };
18
+ defineConfig({ transport });
19
+ defineConfig({
20
+ transport: {
21
+ Money: {
22
+ encode: (value) => value instanceof Money && [value.amount, value.currency],
23
+ decode: ([amount, currency]) => new Money(amount, currency),
24
+ },
25
+ },
26
+ });
27
+ setTransport(transport);
28
+ setServerTransport(transport);
29
+ registerTransport(transport);
30
+ setTransport();
31
+ setServerTransport({});
32
+
33
+ // @ts-expect-error Every handler needs both directions.
34
+ defineConfig({ transport: { Money: { encode: () => false } } });
35
+ const invalid: Transporter<Money, [number, string]> = {
36
+ ...money,
37
+ // @ts-expect-error A decoder must accept the encoded representation.
38
+ decode: (data: number) => new Money(data, 'USD'),
39
+ };
40
+ void invalid;
@@ -18,6 +18,7 @@ describe('vite-plugin-ripple config resolution', () => {
18
18
  expect(config.platform.env).toEqual({});
19
19
  expect(config.server.trustProxy).toBe(false);
20
20
  expect(config.rootBoundary).toEqual({});
21
+ expect(config.transport).toEqual({});
21
22
  expect(config.build.outDir).toBe('dist');
22
23
  });
23
24
 
@@ -77,4 +78,26 @@ describe('vite-plugin-ripple config resolution', () => {
77
78
  }),
78
79
  ).toThrow('router.routes must be an array');
79
80
  });
81
+
82
+ it('preserves transport functions when resolving a config again', () => {
83
+ const transport = {
84
+ Money: { encode: () => false, decode: (data) => data },
85
+ };
86
+ const config = resolveRippleConfig({ transport });
87
+ expect(config.transport).toBe(transport);
88
+ expect(resolveRippleConfig(config).transport).toBe(transport);
89
+ });
90
+
91
+ it.each([null, false, [], 'Money', () => {}])('rejects invalid transport %s', (transport) => {
92
+ expect(() => resolveRippleConfig({ transport })).toThrow('transport must be an object');
93
+ });
94
+
95
+ it.each([null, [], false, {}, { encode() {} }, { decode() {} }, { encode: true, decode() {} }])(
96
+ 'rejects invalid transport handler %s',
97
+ (Money) => {
98
+ expect(() => resolveRippleConfig({ transport: { Money } })).toThrow(
99
+ 'transport.Money must be an object with encode and decode functions',
100
+ );
101
+ },
102
+ );
80
103
  });