ripple 0.3.24 → 0.3.26

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 (49) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +5 -5
  3. package/src/runtime/index-client.js +4 -0
  4. package/src/runtime/internal/client/hmr.js +1 -1
  5. package/src/runtime/internal/client/hydration.js +14 -0
  6. package/src/runtime/internal/client/runtime.js +127 -31
  7. package/src/runtime/internal/client/types.d.ts +3 -33
  8. package/src/runtime/internal/server/blocks.js +21 -1
  9. package/src/runtime/internal/server/index.js +299 -34
  10. package/src/runtime/internal/server/types.d.ts +3 -31
  11. package/src/runtime/reactive-value.js +1 -0
  12. package/src/utils/escaping.js +11 -0
  13. package/src/utils/track-async-serialization.js +9 -0
  14. package/tests/client/async-suspend.test.tsrx +11 -1
  15. package/tests/client/compiler/compiler.basic.test.tsrx +18 -3
  16. package/tests/client/track-async-hydration.test.tsrx +54 -0
  17. package/tests/hydration/compiled/client/basic.js +1 -1
  18. package/tests/hydration/compiled/client/events.js +8 -8
  19. package/tests/hydration/compiled/client/for.js +22 -24
  20. package/tests/hydration/compiled/client/head.js +6 -6
  21. package/tests/hydration/compiled/client/hmr.js +1 -1
  22. package/tests/hydration/compiled/client/html.js +1 -1
  23. package/tests/hydration/compiled/client/if-children.js +7 -7
  24. package/tests/hydration/compiled/client/if.js +5 -5
  25. package/tests/hydration/compiled/client/mixed-control-flow.js +4 -4
  26. package/tests/hydration/compiled/client/portal.js +1 -1
  27. package/tests/hydration/compiled/client/reactivity.js +9 -9
  28. package/tests/hydration/compiled/client/return.js +23 -23
  29. package/tests/hydration/compiled/client/switch.js +4 -4
  30. package/tests/hydration/compiled/client/track-async-serialization.js +390 -0
  31. package/tests/hydration/compiled/client/try.js +2 -2
  32. package/tests/hydration/compiled/server/basic.js +1 -1
  33. package/tests/hydration/compiled/server/events.js +8 -8
  34. package/tests/hydration/compiled/server/for.js +34 -28
  35. package/tests/hydration/compiled/server/head.js +6 -6
  36. package/tests/hydration/compiled/server/hmr.js +1 -1
  37. package/tests/hydration/compiled/server/html.js +1 -1
  38. package/tests/hydration/compiled/server/if-children.js +7 -7
  39. package/tests/hydration/compiled/server/if.js +5 -5
  40. package/tests/hydration/compiled/server/mixed-control-flow.js +4 -4
  41. package/tests/hydration/compiled/server/portal.js +1 -1
  42. package/tests/hydration/compiled/server/reactivity.js +9 -9
  43. package/tests/hydration/compiled/server/return.js +11 -11
  44. package/tests/hydration/compiled/server/switch.js +4 -4
  45. package/tests/hydration/compiled/server/track-async-serialization.js +502 -0
  46. package/tests/hydration/compiled/server/try.js +2 -2
  47. package/tests/hydration/components/track-async-serialization.tsrx +116 -0
  48. package/tests/hydration/track-async-serialization.test.js +127 -0
  49. package/tests/server/track-async-serialization.test.tsrx +185 -0
@@ -0,0 +1,127 @@
1
+ import { DEV } from 'esm-env';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { flushSync } from 'ripple';
4
+ import * as devalue from 'devalue';
5
+ import { hydrateComponent, container } from '../setup-hydration.js';
6
+
7
+ import * as ServerComponents from './compiled/server/track-async-serialization.js';
8
+ import * as ClientComponents from './compiled/client/track-async-serialization.js';
9
+
10
+ const TRACK_ASYNC_PUBLIC_ERROR_MESSAGE = 'An error occurred during async rendering';
11
+ const TRACK_ASYNC_ERROR_MESSAGE = DEV ? 'fetch failed' : TRACK_ASYNC_PUBLIC_ERROR_MESSAGE;
12
+ const TRACK_ASYNC_CHILD_ERROR_MESSAGE = DEV ? 'child error' : TRACK_ASYNC_PUBLIC_ERROR_MESSAGE;
13
+
14
+ describe('hydration > trackAsync serialization', () => {
15
+ it('hydrates simple string value from serialized trackAsync', async () => {
16
+ await hydrateComponent(ServerComponents.AsyncSimpleValue, ClientComponents.AsyncSimpleValue);
17
+
18
+ expect(container.querySelector('.result')?.textContent).toBe('hydrated value');
19
+ expect(container.querySelector('.loading')).toBeNull();
20
+
21
+ // Serialization script tags should be removed after hydration
22
+ expect(container.querySelector('script[id^="__tsrx_ta_"]')).toBeNull();
23
+ });
24
+
25
+ it('hydrates numeric value from serialized trackAsync', async () => {
26
+ await hydrateComponent(ServerComponents.AsyncNumericValue, ClientComponents.AsyncNumericValue);
27
+
28
+ expect(container.querySelector('.count')?.textContent).toBe('42');
29
+ expect(container.querySelector('.pending')).toBeNull();
30
+ });
31
+
32
+ it('hydrates object value from serialized trackAsync', async () => {
33
+ await hydrateComponent(ServerComponents.AsyncObjectValue, ClientComponents.AsyncObjectValue);
34
+
35
+ expect(container.querySelector('.name')?.textContent).toBe('Alice');
36
+ expect(container.querySelector('.age')?.textContent).toBe('30');
37
+ expect(container.querySelector('.loading')).toBeNull();
38
+ });
39
+
40
+ it('hydrates rejected trackAsync and shows catch content', async () => {
41
+ await hydrateComponent(ServerComponents.AsyncWithCatch, ClientComponents.AsyncWithCatch);
42
+
43
+ expect(container.querySelector('.error')?.textContent).toBe(TRACK_ASYNC_ERROR_MESSAGE);
44
+ expect(container.querySelector('.result')).toBeNull();
45
+ expect(container.querySelector('.loading')).toBeNull();
46
+ expect(container.querySelector('script[id^="__tsrx_ta_"]')).toBeNull();
47
+ });
48
+
49
+ it('hydrates child trackAsync error bubbled to parent catch', async () => {
50
+ await hydrateComponent(ServerComponents.ParentWithCatch, ClientComponents.ParentWithCatch);
51
+
52
+ expect(container.querySelector('.parent-error')?.textContent).toBe(
53
+ TRACK_ASYNC_CHILD_ERROR_MESSAGE,
54
+ );
55
+ expect(container.querySelector('.result')).toBeNull();
56
+ expect(container.querySelector('.pending')).toBeNull();
57
+ expect(container.querySelector('script[id^="__tsrx_ta_"]')).toBeNull();
58
+ });
59
+
60
+ it('reruns trackAsync when a dependency changes after hydration', async () => {
61
+ await hydrateComponent(
62
+ ServerComponents.AsyncWithReactiveDependency,
63
+ ClientComponents.AsyncWithReactiveDependency,
64
+ );
65
+
66
+ // Hydrated value from SSR should match `count-0`
67
+ expect(container.querySelector('.result')?.textContent).toBe('count-0');
68
+ expect(container.querySelector('.loading')).toBeNull();
69
+
70
+ /** @type {any} */ (container.querySelector('.increment'))?.click();
71
+ flushSync();
72
+ // Wait for the trackAsync promise to resolve
73
+ await Promise.resolve();
74
+ await Promise.resolve();
75
+ flushSync();
76
+
77
+ expect(container.querySelector('.result')?.textContent).toBe('count-1');
78
+ });
79
+
80
+ it('reruns trackAsync via #server RPC call when a dependency changes', async () => {
81
+ const originalFetch = globalThis.fetch;
82
+ const fetchMock = vi.fn(async (_url, init) => {
83
+ const args = devalue.parse(init.body);
84
+ const result = `server-${args[0]}`;
85
+ return new Response(devalue.stringify({ value: result }), {
86
+ status: 200,
87
+ headers: { 'Content-Type': 'text/plain' },
88
+ });
89
+ });
90
+ globalThis.fetch = /** @type {any} */ (fetchMock);
91
+
92
+ try {
93
+ await hydrateComponent(
94
+ ServerComponents.AsyncWithServerCall,
95
+ ClientComponents.AsyncWithServerCall,
96
+ );
97
+
98
+ // Hydrated value comes from the SSR-serialized trackAsync output
99
+ expect(container.querySelector('.result')?.textContent).toBe('server-0');
100
+ expect(container.querySelector('.loading')).toBeNull();
101
+ expect(fetchMock).not.toHaveBeenCalled();
102
+
103
+ /** @type {any} */ (container.querySelector('.increment'))?.click();
104
+ flushSync();
105
+ // Wait for the RPC fetch + devalue parse chain to resolve
106
+ await vi.waitFor(() => {
107
+ expect(container.querySelector('.result')?.textContent).toBe('server-1');
108
+ });
109
+
110
+ expect(fetchMock).toHaveBeenCalledTimes(1);
111
+ expect(fetchMock.mock.calls[0][0]).toMatch(/\/_\$_ripple_rpc_\$_\//);
112
+ } finally {
113
+ globalThis.fetch = originalFetch;
114
+ }
115
+ });
116
+
117
+ it('hydrates multiple trackAsync values independently', async () => {
118
+ await hydrateComponent(
119
+ ServerComponents.AsyncMultipleValues,
120
+ ClientComponents.AsyncMultipleValues,
121
+ );
122
+
123
+ expect(container.querySelector('.first')?.textContent).toBe('alpha');
124
+ expect(container.querySelector('.second')?.textContent).toBe('beta');
125
+ expect(container.querySelector('.loading')).toBeNull();
126
+ });
127
+ });
@@ -0,0 +1,185 @@
1
+ import { DEV } from 'esm-env';
2
+ import { trackAsync } from 'ripple';
3
+
4
+ const TRACK_ASYNC_PUBLIC_ERROR_MESSAGE = 'An error occurred during async rendering';
5
+
6
+ describe('trackAsync serialization (server)', () => {
7
+ it('serializes resolved trackAsync data as a script tag in SSR output', async () => {
8
+ component App() {
9
+ try {
10
+ let &[data] = trackAsync(() => Promise.resolve('hello world'));
11
+ <p class="result">{data}</p>
12
+ } pending {
13
+ <p class="loading">{'loading...'}</p>
14
+ }
15
+ }
16
+
17
+ const { body } = await render(App);
18
+ expect(body).toContain('hello world');
19
+ // Should contain a serialization script tag
20
+ expect(body).toContain('__tsrx_ta_');
21
+ expect(body).toContain('type="application/json"');
22
+ });
23
+
24
+ it('serializes numeric trackAsync data correctly', async () => {
25
+ component App() {
26
+ try {
27
+ let &[count] = trackAsync(() => Promise.resolve(42));
28
+ <span class="count">{count}</span>
29
+ } pending {
30
+ <span>{'...'}</span>
31
+ }
32
+ }
33
+
34
+ const { body } = await render(App);
35
+ expect(body).toContain('42');
36
+ expect(body).toContain('__tsrx_ta_');
37
+ });
38
+
39
+ it('serializes object trackAsync data correctly', async () => {
40
+ component App() {
41
+ try {
42
+ let &[data] = trackAsync(() => Promise.resolve({ name: 'Alice', age: 30 }));
43
+ <p>{data.name}</p>
44
+ } pending {
45
+ <p>{'...'}</p>
46
+ }
47
+ }
48
+
49
+ const { body } = await render(App);
50
+ expect(body).toContain('Alice');
51
+ expect(body).toContain('__tsrx_ta_');
52
+ });
53
+
54
+ it('serializes array trackAsync data correctly', async () => {
55
+ component App() {
56
+ try {
57
+ let &[items] = trackAsync(() => Promise.resolve(['a', 'b', 'c']));
58
+ <ul>
59
+ for (let item of items) {
60
+ <li>{item}</li>
61
+ }
62
+ </ul>
63
+ } pending {
64
+ <p>{'...'}</p>
65
+ }
66
+ }
67
+
68
+ const { body } = await render(App);
69
+ expect(body).toContain('<li>a</li>');
70
+ expect(body).toContain('__tsrx_ta_');
71
+ });
72
+
73
+ it('serializes rejected trackAsync as error envelope', async () => {
74
+ component App() {
75
+ try {
76
+ let &[data] = trackAsync(() => Promise.reject(new Error('fetch failed')));
77
+ <p class="result">{data}</p>
78
+ } pending {
79
+ <p class="loading">{'loading...'}</p>
80
+ } catch (e) {
81
+ <p class="error">{(e as Error).message}</p>
82
+ }
83
+ }
84
+
85
+ const { body } = await render(App);
86
+ const public_message = DEV ? 'fetch failed' : TRACK_ASYNC_PUBLIC_ERROR_MESSAGE;
87
+ expect(body).toContain(`<p class="error">${public_message}</p>`);
88
+ expect(body).toContain('__tsrx_ta_');
89
+ expect(body).toContain('"ok":false');
90
+ expect(body).toContain(`"message":"${public_message}"`);
91
+ });
92
+
93
+ it('serializes error when child component error bubbles to parent catch', async () => {
94
+ component Child() {
95
+ try {
96
+ let &[data] = trackAsync(() => Promise.reject(new Error('child error')));
97
+ <p class="result">{data}</p>
98
+ } pending {
99
+ <p class="pending">{'loading...'}</p>
100
+ }
101
+ }
102
+
103
+ component Parent() {
104
+ try {
105
+ <Child />
106
+ } catch (e) {
107
+ <p class="parent-error">{(e as Error).message}</p>
108
+ }
109
+ }
110
+
111
+ const { body } = await render(Parent);
112
+ const public_message = DEV ? 'child error' : TRACK_ASYNC_PUBLIC_ERROR_MESSAGE;
113
+ expect(body).toContain(`<p class="parent-error">${public_message}</p>`);
114
+ expect(body).toContain('__tsrx_ta_');
115
+ expect(body).toContain('"ok":false');
116
+ expect(body).toContain(`"message":"${public_message}"`);
117
+ });
118
+
119
+ it('serializes sync trackAsync throw using original cause message', async () => {
120
+ component App() {
121
+ try {
122
+ let &[data] = trackAsync(() => {
123
+ throw new Error('sync failure');
124
+ });
125
+ <p class="result">{data}</p>
126
+ } pending {
127
+ <p class="loading">{'loading...'}</p>
128
+ } catch (e) {
129
+ <p class="error">{(e as Error).message}</p>
130
+ }
131
+ }
132
+
133
+ const { body } = await render(App);
134
+ const public_message = DEV ? 'sync failure' : TRACK_ASYNC_PUBLIC_ERROR_MESSAGE;
135
+ expect(body).toContain(`<p class="error">${public_message}</p>`);
136
+ expect(body).toContain('__tsrx_ta_');
137
+ expect(body).toContain('"ok":false');
138
+ expect(body).toContain(`"message":"${public_message}"`);
139
+ expect(body).not.toContain('Error thrown during trackAsync execution');
140
+ });
141
+
142
+ it('escapes serialized error payload to keep script tag safe', async () => {
143
+ component App() {
144
+ try {
145
+ let &[data] = trackAsync(
146
+ () => Promise.reject(new Error('</script><div data-injected="1">boom</div>')),
147
+ );
148
+ <p class="result">{data}</p>
149
+ } pending {
150
+ <p class="loading">{'loading...'}</p>
151
+ } catch (e) {
152
+ <p class="error">{(e as Error).message}</p>
153
+ }
154
+ }
155
+
156
+ const { body } = await render(App);
157
+ expect(body).toContain('__tsrx_ta_');
158
+ if (DEV) {
159
+ expect(body).toContain('\\u003c/script\\u003e');
160
+ expect(body).not.toContain(
161
+ 'type="application/json">{"ok":false,"error":{"message":"</script>',
162
+ );
163
+ }
164
+ });
165
+
166
+ it('generates unique hashes for multiple trackAsync calls', async () => {
167
+ component App() {
168
+ try {
169
+ let &[a] = trackAsync(() => Promise.resolve('first'));
170
+ let &[b] = trackAsync(() => Promise.resolve('second'));
171
+ <p>{a}</p>
172
+ <p>{b}</p>
173
+ } pending {
174
+ <p>{'...'}</p>
175
+ }
176
+ }
177
+
178
+ const { body } = await render(App);
179
+ // Find all script tag IDs
180
+ const matches = [...body.matchAll(/id="(__tsrx_ta_[a-f0-9]+)"/g)];
181
+ expect(matches.length).toBe(2);
182
+ // Hashes should be different
183
+ expect(matches[0][1]).not.toBe(matches[1][1]);
184
+ });
185
+ });