ripple 0.3.91 → 0.3.92

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.
@@ -0,0 +1,342 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { flushSync, hydrate } from 'ripple';
3
+ import { render } from 'ripple/server';
4
+ import { container } from '../setup-hydration.js';
5
+
6
+ import * as ServerComponents from './compiled/server/streaming.js';
7
+ import * as ClientComponents from './compiled/client/streaming.js';
8
+
9
+ /**
10
+ * Executes bare inline `<script>` tags from streamed HTML the way a browser
11
+ * would while parsing (the swap runtime in the shell, `__RIPPLE_S__(n)` calls
12
+ * in chunks). Envelope scripts carry attributes and stay inert, like in a
13
+ * real document.
14
+ * @param {string} html
15
+ */
16
+ function execInlineScripts(html) {
17
+ for (const [, code] of html.matchAll(/<script>([\s\S]*?)<\/script>/g)) {
18
+ try {
19
+ (0, eval)(code);
20
+ } catch (error) {
21
+ // eslint-disable-next-line no-console
22
+ console.error('inline script failed:', /** @type {Error} */ (error).stack);
23
+ throw error;
24
+ }
25
+ }
26
+ }
27
+
28
+ function tick() {
29
+ return new Promise((resolve) => setTimeout(resolve, 0));
30
+ }
31
+
32
+ /**
33
+ * @param {() => void} serverComponent
34
+ * @param {{ catch?: () => void, pending?: () => void }} [serverRootBoundary]
35
+ */
36
+ function startStream(serverComponent, serverRootBoundary) {
37
+ ServerComponents.resetControls();
38
+ ClientComponents.resetControls();
39
+ delete window.__RIPPLE_B__;
40
+ delete window.__RIPPLE_S__;
41
+
42
+ // drop leftovers a previous test streamed into document.body
43
+ for (const child of [...document.body.children]) {
44
+ if (child !== container) {
45
+ child.remove();
46
+ }
47
+ }
48
+
49
+ /** @type {string[]} */
50
+ const chunks = [];
51
+ const sink = {
52
+ push: (/** @type {string} */ chunk) => chunks.push(chunk),
53
+ close() {},
54
+ error() {},
55
+ };
56
+ const done = render(serverComponent, {
57
+ stream: sink,
58
+ ...(serverRootBoundary ? { rootBoundary: serverRootBoundary } : {}),
59
+ });
60
+ return { chunks, done };
61
+ }
62
+
63
+ /** @param {string} shell */
64
+ function mountShell(shell) {
65
+ container.innerHTML = shell;
66
+ execInlineScripts(shell);
67
+ }
68
+
69
+ /** @param {string} chunk */
70
+ function receiveChunk(chunk) {
71
+ document.body.insertAdjacentHTML('beforeend', chunk);
72
+ execInlineScripts(chunk);
73
+ }
74
+
75
+ describe('hydration > streamed boundaries (chunk before hydration)', () => {
76
+ it('hydrates swapped content through the normal resolved path and stays interactive', async () => {
77
+ const { chunks, done } = startStream(ServerComponents.StreamPending);
78
+ ServerComponents.controls.basic.resolve('data');
79
+ await done;
80
+
81
+ expect(chunks).toHaveLength(2);
82
+ mountShell(chunks[0]);
83
+ receiveChunk(chunks[1]);
84
+
85
+ // the swap runtime normalized the slot: no streaming markers remain
86
+ expect(container.innerHTML).not.toContain('<!--[?');
87
+ expect(container.querySelector('.loading')).toBeNull();
88
+ const ssr_value = container.querySelector('.value');
89
+ expect(ssr_value?.textContent).toBe('data:0');
90
+ expect(container.querySelector('.after-async')?.textContent).toBe('after-async');
91
+
92
+ hydrate(ClientComponents.StreamPending, { target: container });
93
+
94
+ // claimed, not recreated
95
+ expect(container.querySelector('.value')).toBe(ssr_value);
96
+
97
+ /** @type {HTMLButtonElement} */ (container.querySelector('.inc')).click();
98
+ flushSync();
99
+ expect(container.querySelector('.value')?.textContent).toBe('data:1');
100
+ });
101
+
102
+ it('hydrates a swapped server-rendered catch branch', async () => {
103
+ const { chunks, done } = startStream(ServerComponents.StreamRejects);
104
+ ServerComponents.controls.rejects.reject(new Error('boom'));
105
+ await done;
106
+
107
+ mountShell(chunks[0]);
108
+ for (const chunk of chunks.slice(1)) {
109
+ receiveChunk(chunk);
110
+ }
111
+
112
+ expect(container.querySelector('.loading')).toBeNull();
113
+ const ssr_caught = container.querySelector('.caught');
114
+ expect(ssr_caught?.textContent).toBe('boom');
115
+
116
+ hydrate(ClientComponents.StreamRejects, { target: container });
117
+ flushSync();
118
+
119
+ expect(container.querySelector('.caught')).toBe(ssr_caught);
120
+ expect(container.querySelector('.resolved')).toBeNull();
121
+ });
122
+
123
+ it('routes an errored slot to the client root catch boundary', async () => {
124
+ const { chunks, done } = startStream(ServerComponents.StreamNoCatch);
125
+ mountShell(chunks[0]);
126
+ ServerComponents.controls.noCatch.reject(new Error('late failure'));
127
+ await done;
128
+ for (const chunk of chunks.slice(1)) {
129
+ receiveChunk(chunk);
130
+ }
131
+
132
+ // the swap runtime emptied the slot and marked it errored
133
+ expect(container.innerHTML).toContain('<!--[!');
134
+ expect(container.querySelector('.loading')).toBeNull();
135
+
136
+ hydrate(ClientComponents.StreamNoCatch, {
137
+ target: container,
138
+ rootBoundary: { catch: ClientComponents.RootCatch },
139
+ });
140
+ await tick();
141
+ flushSync();
142
+
143
+ expect(container.querySelector('.root-catch')?.textContent).toBe('late failure');
144
+ // routing the error neutralized the errored slot markers
145
+ expect(container.innerHTML).not.toContain('<!--[!');
146
+ });
147
+ });
148
+
149
+ describe('hydration > streamed boundaries (chunk after hydration)', () => {
150
+ it('hydrates the fallback, then activates the streamed chunk in place', async () => {
151
+ const { chunks, done } = startStream(ServerComponents.StreamPending);
152
+ mountShell(chunks[0]);
153
+
154
+ hydrate(ClientComponents.StreamPending, { target: container });
155
+
156
+ expect(container.querySelector('.loading')).not.toBeNull();
157
+ expect(container.querySelector('.resolved')).toBeNull();
158
+ expect(container.querySelector('.before')?.textContent).toBe('before');
159
+ expect(container.querySelector('.sibling-after')?.textContent).toBe('sibling-after');
160
+ expect(Object.keys(window.__RIPPLE_B__ ?? {})).toHaveLength(1);
161
+
162
+ ServerComponents.controls.basic.resolve('data');
163
+ await done;
164
+ receiveChunk(chunks[1]);
165
+ flushSync();
166
+
167
+ expect(container.querySelector('.loading')).toBeNull();
168
+ expect(container.querySelector('.value')?.textContent).toBe('data:0');
169
+ expect(container.querySelector('.after-async')?.textContent).toBe('after-async');
170
+ // the trackAsync envelope from the chunk was consumed during activation
171
+ expect(document.querySelector('script[id^="__ripple_ta_"]')).toBeNull();
172
+ // activation retires the slot wrapper markers, matching buffered SSR
173
+ expect(container.innerHTML).not.toContain('<!--[?');
174
+
175
+ /** @type {HTMLButtonElement} */ (container.querySelector('.inc')).click();
176
+ flushSync();
177
+ expect(container.querySelector('.value')?.textContent).toBe('data:1');
178
+ });
179
+
180
+ it('activates a catch-only slot that streamed no fallback', async () => {
181
+ const { chunks, done } = startStream(ServerComponents.StreamCatchOnly);
182
+ mountShell(chunks[0]);
183
+
184
+ hydrate(ClientComponents.StreamCatchOnly, { target: container });
185
+
186
+ expect(container.querySelector('.before')?.textContent).toBe('before');
187
+ expect(container.querySelector('.resolved')).toBeNull();
188
+ expect(container.querySelector('.caught')).toBeNull();
189
+
190
+ ServerComponents.controls.catchOnly.resolve('late body');
191
+ await done;
192
+ receiveChunk(chunks[1]);
193
+ flushSync();
194
+
195
+ expect(container.querySelector('.resolved')?.textContent).toBe('late body');
196
+ expect(container.querySelector('.caught')).toBeNull();
197
+ expect(container.innerHTML).not.toContain('<!--[?');
198
+ });
199
+
200
+ it('activates a streamed server-rendered catch branch', async () => {
201
+ const { chunks, done } = startStream(ServerComponents.StreamRejects);
202
+ mountShell(chunks[0]);
203
+
204
+ hydrate(ClientComponents.StreamRejects, { target: container });
205
+ expect(container.querySelector('.loading')).not.toBeNull();
206
+
207
+ ServerComponents.controls.rejects.reject(new Error('boom'));
208
+ await done;
209
+ for (const chunk of chunks.slice(1)) {
210
+ receiveChunk(chunk);
211
+ }
212
+ flushSync();
213
+
214
+ expect(container.querySelector('.loading')).toBeNull();
215
+ expect(container.querySelector('.caught')?.textContent).toBe('boom');
216
+ expect(container.querySelector('.resolved')).toBeNull();
217
+ expect(container.innerHTML).not.toContain('<!--[?');
218
+ });
219
+
220
+ it('routes an errored slot arriving after hydration to the client root catch', async () => {
221
+ const { chunks, done } = startStream(ServerComponents.StreamNoCatch);
222
+ mountShell(chunks[0]);
223
+
224
+ hydrate(ClientComponents.StreamNoCatch, {
225
+ target: container,
226
+ rootBoundary: { catch: ClientComponents.RootCatch },
227
+ });
228
+ expect(container.querySelector('.loading')).not.toBeNull();
229
+
230
+ ServerComponents.controls.noCatch.reject(new Error('late failure'));
231
+ await done;
232
+ for (const chunk of chunks.slice(1)) {
233
+ receiveChunk(chunk);
234
+ }
235
+ await tick();
236
+ flushSync();
237
+
238
+ expect(container.querySelector('.root-catch')?.textContent).toBe('late failure');
239
+ expect(container.querySelector('.loading')).toBeNull();
240
+ // the errored slot markers were neutralized when the error was routed
241
+ expect(container.innerHTML).not.toContain('<!--[?');
242
+ expect(container.innerHTML).not.toContain('<!--[!');
243
+ });
244
+
245
+ it('hydrates a suspended root boundary and activates it on chunk arrival', async () => {
246
+ const { chunks, done } = startStream(ServerComponents.StreamRootDirect, {
247
+ pending: ServerComponents.RootPending,
248
+ });
249
+
250
+ // the root slot IS the body: the shell starts with the slot marker
251
+ expect(chunks[0]).toMatch(/^(<style[\s\S]*?<\/style>)?<!--\[\?\d+-->/);
252
+ mountShell(chunks[0]);
253
+
254
+ hydrate(ClientComponents.StreamRootDirect, {
255
+ target: container,
256
+ rootBoundary: { pending: ClientComponents.RootPending },
257
+ });
258
+
259
+ expect(container.querySelector('.root-pending')).not.toBeNull();
260
+ expect(container.querySelector('.root-async')).toBeNull();
261
+
262
+ ServerComponents.controls.rootDirect.resolve('root data');
263
+ await done;
264
+ receiveChunk(chunks[1]);
265
+ flushSync();
266
+
267
+ expect(container.querySelector('.root-pending')).toBeNull();
268
+ expect(container.querySelector('.root-async')?.textContent).toBe('root data');
269
+ expect(container.innerHTML).not.toContain('<!--[?');
270
+ });
271
+
272
+ it('hydrates a root chunk that arrived before hydration through the normal path', async () => {
273
+ const { chunks, done } = startStream(ServerComponents.StreamRootDirect, {
274
+ pending: ServerComponents.RootPending,
275
+ });
276
+ ServerComponents.controls.rootDirect.resolve('root data');
277
+ await done;
278
+
279
+ mountShell(chunks[0]);
280
+ receiveChunk(chunks[1]);
281
+
282
+ expect(container.innerHTML).not.toContain('<!--[?');
283
+ const ssr_value = container.querySelector('.root-async');
284
+ expect(ssr_value?.textContent).toBe('root data');
285
+
286
+ hydrate(ClientComponents.StreamRootDirect, {
287
+ target: container,
288
+ rootBoundary: { pending: ClientComponents.RootPending },
289
+ });
290
+
291
+ expect(container.querySelector('.root-async')).toBe(ssr_value);
292
+ expect(container.querySelector('.root-pending')).toBeNull();
293
+ });
294
+
295
+ it('moves streamed head content into document.head on chunk arrival', async () => {
296
+ const { chunks, done } = startStream(ServerComponents.StreamHead);
297
+ mountShell(chunks[0]);
298
+
299
+ hydrate(ClientComponents.StreamHead, { target: container });
300
+ expect(container.querySelector('.loading')).not.toBeNull();
301
+
302
+ ServerComponents.controls.head.resolve('late');
303
+ await done;
304
+ receiveChunk(chunks[1]);
305
+ flushSync();
306
+
307
+ expect(document.title).toBe('title:late');
308
+ expect(container.querySelector('.head-content')?.textContent).toBe('late');
309
+ expect(document.querySelector('template[data-ripple-head]')).toBeNull();
310
+ });
311
+
312
+ it('activates nested boundaries chunk by chunk, parent first', async () => {
313
+ const { chunks, done } = startStream(ServerComponents.StreamNested);
314
+ mountShell(chunks[0]);
315
+
316
+ hydrate(ClientComponents.StreamNested, { target: container });
317
+ expect(container.querySelector('.outer-loading')).not.toBeNull();
318
+
319
+ ServerComponents.controls.outer.resolve('O');
320
+ await tick();
321
+ expect(chunks).toHaveLength(2);
322
+ receiveChunk(chunks[1]);
323
+ flushSync();
324
+
325
+ expect(container.querySelector('.outer-loading')).toBeNull();
326
+ expect(container.querySelector('.outer')?.textContent).toBe('O');
327
+ // the nested boundary streamed its fallback inside the parent chunk and
328
+ // registered itself during the parent's activation
329
+ expect(container.querySelector('.inner-loading')).not.toBeNull();
330
+
331
+ ServerComponents.controls.inner.resolve('I');
332
+ await done;
333
+ expect(chunks).toHaveLength(3);
334
+ receiveChunk(chunks[2]);
335
+ flushSync();
336
+
337
+ expect(container.querySelector('.inner-loading')).toBeNull();
338
+ expect(container.querySelector('.inner')?.textContent).toBe('I');
339
+ // both nested activations retired their slot markers
340
+ expect(container.innerHTML).not.toContain('<!--[?');
341
+ });
342
+ });
@@ -19,7 +19,7 @@ describe('hydration > trackAsync serialization', () => {
19
19
  expect(container.querySelector('.loading')).toBeNull();
20
20
 
21
21
  // Serialization script tags should be removed after hydration
22
- expect(container.querySelector('script[id^="__tsrx_ta_"]')).toBeNull();
22
+ expect(container.querySelector('script[id^="__ripple_ta_"]')).toBeNull();
23
23
  });
24
24
 
25
25
  it('hydrates numeric value from serialized trackAsync', async () => {
@@ -43,7 +43,7 @@ describe('hydration > trackAsync serialization', () => {
43
43
  expect(container.querySelector('.error')?.textContent).toBe(TRACK_ASYNC_ERROR_MESSAGE);
44
44
  expect(container.querySelector('.result')).toBeNull();
45
45
  expect(container.querySelector('.loading')).toBeNull();
46
- expect(container.querySelector('script[id^="__tsrx_ta_"]')).toBeNull();
46
+ expect(container.querySelector('script[id^="__ripple_ta_"]')).toBeNull();
47
47
  });
48
48
 
49
49
  it('hydrates child trackAsync error bubbled to parent catch', async () => {
@@ -54,7 +54,7 @@ describe('hydration > trackAsync serialization', () => {
54
54
  );
55
55
  expect(container.querySelector('.result')).toBeNull();
56
56
  expect(container.querySelector('.pending')).toBeNull();
57
- expect(container.querySelector('script[id^="__tsrx_ta_"]')).toBeNull();
57
+ expect(container.querySelector('script[id^="__ripple_ta_"]')).toBeNull();
58
58
  });
59
59
 
60
60
  it('reruns trackAsync when a dependency changes after hydration', async () => {
@@ -82,7 +82,7 @@ describe('hydration > root try boundary', () => {
82
82
  ({ container, body }) => {
83
83
  expect(body).toContain('<!--[-->');
84
84
  expect(body).toContain('<!--]-->');
85
- expect(body).toContain('__tsrx_ta_');
85
+ expect(body).toContain('__ripple_ta_');
86
86
 
87
87
  ssrValue = container.querySelector('.root-async-value');
88
88
  },
@@ -92,7 +92,7 @@ describe('hydration > root try boundary', () => {
92
92
  expect(ssrValue?.textContent).toBe('root async value');
93
93
  expect(container.querySelector('.root-pending')).toBeNull();
94
94
  expect(container.querySelector('.root-catch')).toBeNull();
95
- expect(container.querySelector('script[id^="__tsrx_ta_"]')).toBeNull();
95
+ expect(container.querySelector('script[id^="__ripple_ta_"]')).toBeNull();
96
96
  });
97
97
 
98
98
  it('hydrates root catch content from a rejected server trackAsync result', async () => {
@@ -111,7 +111,7 @@ describe('hydration > root try boundary', () => {
111
111
  ({ container, body }) => {
112
112
  expect(body).toContain('<!--[-->');
113
113
  expect(body).toContain('<!--]-->');
114
- expect(body).toContain('__tsrx_ta_');
114
+ expect(body).toContain('__ripple_ta_');
115
115
 
116
116
  ssrCatch = container.querySelector('.root-catch');
117
117
  ssrError = container.querySelector('.root-error');
@@ -125,7 +125,7 @@ describe('hydration > root try boundary', () => {
125
125
  expect(ssrError?.textContent).toBe(ROOT_TRACK_ASYNC_ERROR_MESSAGE);
126
126
  expect(container.querySelector('.root-async-value')).toBeNull();
127
127
  expect(container.querySelector('.root-pending')).toBeNull();
128
- expect(container.querySelector('script[id^="__tsrx_ta_"]')).toBeNull();
128
+ expect(container.querySelector('script[id^="__ripple_ta_"]')).toBeNull();
129
129
  });
130
130
  });
131
131