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.
- package/CHANGELOG.md +21 -0
- package/package.json +1 -1
- package/src/constants.js +13 -0
- package/src/runtime/index-client.js +5 -1
- package/src/runtime/internal/client/hydration.js +4 -1
- package/src/runtime/internal/client/template.js +2 -1
- package/src/runtime/internal/client/try.js +199 -9
- package/src/runtime/internal/client/types.d.ts +25 -0
- package/src/runtime/internal/server/blocks.js +47 -9
- package/src/runtime/internal/server/index.js +546 -69
- package/src/runtime/internal/server/stream-runtime.js +173 -0
- package/src/utils/track-async-serialization.js +1 -1
- package/tests/hydration/compiled/client/streaming.js +448 -0
- package/tests/hydration/compiled/server/streaming.js +476 -0
- package/tests/hydration/components/streaming.tsrx +164 -0
- package/tests/hydration/streaming.test.js +342 -0
- package/tests/hydration/track-async-serialization.test.js +3 -3
- package/tests/hydration/try.test.js +4 -4
- package/tests/server/streaming-ssr.test.tsrx +710 -0
- package/tests/server/track-async-serialization.test.tsrx +9 -9
- package/types/server.d.ts +15 -0
|
@@ -1,4 +1,102 @@
|
|
|
1
1
|
import { create_ssr_stream } from 'ripple/server';
|
|
2
|
+
import { trackAsync } from 'ripple';
|
|
3
|
+
|
|
4
|
+
interface Deferred<T> {
|
|
5
|
+
promise: Promise<T>;
|
|
6
|
+
resolve: (value: T) => void;
|
|
7
|
+
reject: (reason?: unknown) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function deferred<T>(): Deferred<T> {
|
|
11
|
+
let resolve: (value: T) => void = () => {};
|
|
12
|
+
let reject: (reason?: unknown) => void = () => {};
|
|
13
|
+
const promise = new Promise<T>((res, rej) => {
|
|
14
|
+
resolve = res;
|
|
15
|
+
reject = rej;
|
|
16
|
+
});
|
|
17
|
+
return { promise, resolve, reject };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function recordingSink() {
|
|
21
|
+
const chunks: string[] = [];
|
|
22
|
+
let closed = false;
|
|
23
|
+
return {
|
|
24
|
+
chunks,
|
|
25
|
+
isClosed: () => closed,
|
|
26
|
+
sink: {
|
|
27
|
+
push(chunk: string) {
|
|
28
|
+
chunks.push(chunk);
|
|
29
|
+
},
|
|
30
|
+
close() {
|
|
31
|
+
closed = true;
|
|
32
|
+
},
|
|
33
|
+
error(_reason: unknown) {},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SLOT_OPEN_RE = /<!--\[\?(\d+)-->/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Applies streamed chunks the way the inline swap runtime would: collects
|
|
42
|
+
* chunk templates, then repeatedly replaces `<!--[?N-->…<!--]-->` slots
|
|
43
|
+
* (depth-aware over `[`-prefixed / `]` comments) with the template content.
|
|
44
|
+
*/
|
|
45
|
+
function applyStream(chunks: string[]): string {
|
|
46
|
+
let html = chunks.join('');
|
|
47
|
+
const templates = new Map<string, string>();
|
|
48
|
+
html = html.replace(
|
|
49
|
+
/<template data-ripple-chunk="(\d+)">([\s\S]*?)<\/template>/g,
|
|
50
|
+
(_m, id, content) => {
|
|
51
|
+
templates.set(id, content);
|
|
52
|
+
return '';
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
html = html.replace(/<script>__RIPPLE_S__\((\d+)(,1)?\)<\/script>/g, '');
|
|
56
|
+
// the inline swap runtime (serialized function, minified or not)
|
|
57
|
+
html = html.replace(/<script>\(function[\s\S]*?<\/script>/g, '');
|
|
58
|
+
|
|
59
|
+
let match: RegExpMatchArray | null;
|
|
60
|
+
while (match = html.match(SLOT_OPEN_RE)) {
|
|
61
|
+
const id = match[1];
|
|
62
|
+
const content = templates.get(id);
|
|
63
|
+
if (content === undefined) {
|
|
64
|
+
throw new Error(`no chunk streamed for slot ${id}`);
|
|
65
|
+
}
|
|
66
|
+
const start = match.index!;
|
|
67
|
+
const comment_re = /<!--([^>]*?)-->/g;
|
|
68
|
+
comment_re.lastIndex = start + match[0].length;
|
|
69
|
+
let depth = 0;
|
|
70
|
+
let end = -1;
|
|
71
|
+
let end_len = 0;
|
|
72
|
+
let m: RegExpExecArray | null;
|
|
73
|
+
while (m = comment_re.exec(html)) {
|
|
74
|
+
const data = m[1];
|
|
75
|
+
if (data === ']') {
|
|
76
|
+
if (depth === 0) {
|
|
77
|
+
end = m.index;
|
|
78
|
+
end_len = m[0].length;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
depth--;
|
|
82
|
+
} else if (data.startsWith('[')) {
|
|
83
|
+
depth++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (end === -1) {
|
|
87
|
+
throw new Error(`unterminated slot ${id}`);
|
|
88
|
+
}
|
|
89
|
+
html = html.slice(0, start) + content + html.slice(end + end_len);
|
|
90
|
+
}
|
|
91
|
+
return html;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalize(html: string): string {
|
|
95
|
+
return html.replace(
|
|
96
|
+
/<script id="__ripple_ta_[^"]+" type="application\/json">[\s\S]*?<\/script>/g,
|
|
97
|
+
'',
|
|
98
|
+
).replace(/<style data-ripple-ssr>[\s\S]*?<\/style>/g, '');
|
|
99
|
+
}
|
|
2
100
|
|
|
3
101
|
describe('create_ssr_stream', () => {
|
|
4
102
|
it('renders SSR HTML into the injected sink and exposes a web stream', async () => {
|
|
@@ -41,4 +139,616 @@ describe('create_ssr_stream', () => {
|
|
|
41
139
|
const terminal_read = await reader.read();
|
|
42
140
|
expect(terminal_read.done).toBe(true);
|
|
43
141
|
});
|
|
142
|
+
|
|
143
|
+
it('does not emit the swap runtime for fully synchronous output', async () => {
|
|
144
|
+
function App() @{
|
|
145
|
+
<div>{'all sync'}</div>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { chunks, sink, isClosed } = recordingSink();
|
|
149
|
+
await render(App, { stream: sink });
|
|
150
|
+
|
|
151
|
+
expect(chunks).toHaveLength(1);
|
|
152
|
+
expect(chunks[0]).not.toContain('__RIPPLE_S__');
|
|
153
|
+
expect(chunks[0]).not.toContain('[?');
|
|
154
|
+
expect(isClosed()).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('streaming try/pending boundaries', () => {
|
|
159
|
+
it('streams the shell with the fallback, then the settled body as a framed chunk', async () => {
|
|
160
|
+
const d = deferred<string>();
|
|
161
|
+
|
|
162
|
+
function Content() @{
|
|
163
|
+
let &[data] = trackAsync(() => d.promise);
|
|
164
|
+
<p>{data}</p>
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function App() @{
|
|
168
|
+
<>
|
|
169
|
+
<h1>{'shell'}</h1>
|
|
170
|
+
@try {
|
|
171
|
+
<>
|
|
172
|
+
<Content />
|
|
173
|
+
<footer>{'static-after'}</footer>
|
|
174
|
+
</>
|
|
175
|
+
} @pending {
|
|
176
|
+
<p class="loading">{'loading...'}</p>
|
|
177
|
+
}
|
|
178
|
+
</>
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const { chunks, sink, isClosed } = recordingSink();
|
|
182
|
+
const result = render(App, { stream: sink });
|
|
183
|
+
|
|
184
|
+
// the shell is flushed synchronously, before any async work settles
|
|
185
|
+
expect(chunks).toHaveLength(1);
|
|
186
|
+
const shell = chunks[0];
|
|
187
|
+
expect(shell).toContain('<h1>shell</h1>');
|
|
188
|
+
expect(shell).toContain('<p class="loading">loading...</p>');
|
|
189
|
+
expect(shell).toMatch(SLOT_OPEN_RE);
|
|
190
|
+
// the swap runtime ships with the shell when open slots remain
|
|
191
|
+
expect(shell).toContain('__RIPPLE_S__');
|
|
192
|
+
// nothing of the real body may leak into the shell
|
|
193
|
+
expect(shell).not.toContain('static-after');
|
|
194
|
+
|
|
195
|
+
d.resolve('async-data');
|
|
196
|
+
await result;
|
|
197
|
+
|
|
198
|
+
expect(isClosed()).toBe(true);
|
|
199
|
+
expect(chunks).toHaveLength(2);
|
|
200
|
+
|
|
201
|
+
const slot_match = shell.match(SLOT_OPEN_RE);
|
|
202
|
+
expect(slot_match).not.toBeNull();
|
|
203
|
+
const id = slot_match![1];
|
|
204
|
+
const chunk = chunks[1];
|
|
205
|
+
expect(chunk).toContain(`<template data-ripple-chunk="${id}">`);
|
|
206
|
+
expect(chunk).toContain('<p>async-data</p>');
|
|
207
|
+
// static markup after the suspension point belongs to the chunk
|
|
208
|
+
// (regression: it used to be lost when the boundary cleared its buffer)
|
|
209
|
+
expect(chunk).toContain('<footer>static-after</footer>');
|
|
210
|
+
expect(chunk).toContain(`<script>__RIPPLE_S__(${id})</script>`);
|
|
211
|
+
// the trackAsync envelope ships in the same chunk, ahead of the template
|
|
212
|
+
expect(chunk).toMatch(
|
|
213
|
+
/<script id="__ripple_ta_[^"]+" type="application\/json">[\s\S]*?<\/script>[\s\S]*<template data-ripple-chunk/,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('emits an empty slot for a catch-only boundary with an async body', async () => {
|
|
218
|
+
const d = deferred<string>();
|
|
219
|
+
|
|
220
|
+
function Content() @{
|
|
221
|
+
let &[data] = trackAsync(() => d.promise);
|
|
222
|
+
<p>{data}</p>
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function App() @{
|
|
226
|
+
@try {
|
|
227
|
+
<Content />
|
|
228
|
+
} @catch (e) {
|
|
229
|
+
<em>{'failed'}</em>
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { chunks, sink } = recordingSink();
|
|
234
|
+
const result = render(App, { stream: sink });
|
|
235
|
+
|
|
236
|
+
expect(chunks).toHaveLength(1);
|
|
237
|
+
// no pending fallback: the slot is an empty comment pair
|
|
238
|
+
expect(chunks[0]).toMatch(/<!--\[\?(\d+)--><!--\]-->/);
|
|
239
|
+
expect(chunks[0]).not.toContain('failed');
|
|
240
|
+
|
|
241
|
+
d.resolve('body');
|
|
242
|
+
await result;
|
|
243
|
+
|
|
244
|
+
const chunk = chunks[chunks.length - 1];
|
|
245
|
+
expect(chunk).toContain('<p>body</p>');
|
|
246
|
+
expect(chunk).not.toContain('failed');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('streams the server-rendered catch into the slot when the async body rejects', async () => {
|
|
250
|
+
const d = deferred<string>();
|
|
251
|
+
|
|
252
|
+
function Content() @{
|
|
253
|
+
let &[data] = trackAsync(() => d.promise);
|
|
254
|
+
<p>{data}</p>
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function App() @{
|
|
258
|
+
@try {
|
|
259
|
+
<Content />
|
|
260
|
+
} @pending {
|
|
261
|
+
<p>{'loading...'}</p>
|
|
262
|
+
} @catch (e: Error) {
|
|
263
|
+
<em>{e.message}</em>
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const { chunks, sink } = recordingSink();
|
|
268
|
+
const result = render(App, { stream: sink });
|
|
269
|
+
|
|
270
|
+
d.reject(new Error('boom'));
|
|
271
|
+
await result;
|
|
272
|
+
|
|
273
|
+
const all = chunks.join('');
|
|
274
|
+
const chunk = chunks[chunks.length - 1];
|
|
275
|
+
expect(chunk).toContain('<em>boom</em>');
|
|
276
|
+
expect(chunk).toContain('<template data-ripple-chunk=');
|
|
277
|
+
// the rejected trackAsync envelope reaches the wire before the chunk
|
|
278
|
+
// that hydrates the catch branch from it
|
|
279
|
+
const envelope_index = all.indexOf('__ripple_ta_');
|
|
280
|
+
const template_index = all.indexOf('<template data-ripple-chunk=');
|
|
281
|
+
expect(envelope_index).toBeGreaterThan(-1);
|
|
282
|
+
expect(envelope_index).toBeLessThan(template_index);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('emits a unit error envelope when the catch boundary region is already streamed', async () => {
|
|
286
|
+
const d = deferred<string>();
|
|
287
|
+
|
|
288
|
+
function Content() @{
|
|
289
|
+
let &[data] = trackAsync(() => d.promise);
|
|
290
|
+
<p>{data}</p>
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function App() @{
|
|
294
|
+
@try {
|
|
295
|
+
<Content />
|
|
296
|
+
} @pending {
|
|
297
|
+
<p>{'loading...'}</p>
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function RootCatch({ error }: { error: unknown }) @{
|
|
302
|
+
<h2>
|
|
303
|
+
{'root caught: ' + (error instanceof Error ? error.message : String(error))}
|
|
304
|
+
</h2>
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const { chunks, sink } = recordingSink();
|
|
308
|
+
const result = render(App, { stream: sink, rootBoundary: { catch: RootCatch } });
|
|
309
|
+
|
|
310
|
+
const shell = chunks[0];
|
|
311
|
+
const slot_match = shell.match(SLOT_OPEN_RE);
|
|
312
|
+
expect(slot_match).not.toBeNull();
|
|
313
|
+
const id = slot_match![1];
|
|
314
|
+
|
|
315
|
+
d.reject(new Error('no local catch'));
|
|
316
|
+
const { topLevelError } = await result;
|
|
317
|
+
|
|
318
|
+
// the nearest catch boundary is the root, whose region (the shell) is
|
|
319
|
+
// already on the wire — the server cannot re-render it, so it emits a
|
|
320
|
+
// unit error envelope for the client to route into the live boundary
|
|
321
|
+
// during hydration instead
|
|
322
|
+
expect(topLevelError).toBe(null);
|
|
323
|
+
const all = chunks.join('');
|
|
324
|
+
expect(all).not.toContain('root caught');
|
|
325
|
+
expect(all).toContain(`<script id="__ripple_te_${id}" type="application/json">`);
|
|
326
|
+
expect(all).toContain('no local catch');
|
|
327
|
+
expect(all).toContain(`__RIPPLE_S__(${id},1)`);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('streaming nested and sibling boundaries', () => {
|
|
332
|
+
function makeNestedApp(d_outer: Deferred<string>, d_inner: Deferred<string>) {
|
|
333
|
+
function OuterData() @{
|
|
334
|
+
let &[data] = trackAsync(() => d_outer.promise);
|
|
335
|
+
<p class="outer">{data}</p>
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function InnerData() @{
|
|
339
|
+
let &[data] = trackAsync(() => d_inner.promise);
|
|
340
|
+
<p class="inner">{data}</p>
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function App() @{
|
|
344
|
+
@try {
|
|
345
|
+
<>
|
|
346
|
+
<OuterData />
|
|
347
|
+
@try {
|
|
348
|
+
<InnerData />
|
|
349
|
+
} @pending {
|
|
350
|
+
<p>{'inner-loading'}</p>
|
|
351
|
+
}
|
|
352
|
+
</>
|
|
353
|
+
} @pending {
|
|
354
|
+
<p>{'outer-loading'}</p>
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return App;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
it('coalesces a nested boundary that settles before its parent into one chunk', async () => {
|
|
362
|
+
const d_outer = deferred<string>();
|
|
363
|
+
const d_inner = deferred<string>();
|
|
364
|
+
const App = makeNestedApp(d_outer, d_inner);
|
|
365
|
+
|
|
366
|
+
const { chunks, sink } = recordingSink();
|
|
367
|
+
const result = render(App, { stream: sink });
|
|
368
|
+
|
|
369
|
+
expect(chunks).toHaveLength(1);
|
|
370
|
+
expect(chunks[0]).toContain('outer-loading');
|
|
371
|
+
expect(chunks[0]).not.toContain('inner-loading');
|
|
372
|
+
|
|
373
|
+
d_inner.resolve('inner-done');
|
|
374
|
+
await d_inner.promise;
|
|
375
|
+
await Promise.resolve();
|
|
376
|
+
// the inner boundary settled but its slot never went on the wire —
|
|
377
|
+
// nothing may stream until the parent settles
|
|
378
|
+
expect(chunks).toHaveLength(1);
|
|
379
|
+
|
|
380
|
+
d_outer.resolve('outer-done');
|
|
381
|
+
await result;
|
|
382
|
+
|
|
383
|
+
expect(chunks).toHaveLength(2);
|
|
384
|
+
const chunk = chunks[1];
|
|
385
|
+
expect(chunk).toContain('<p class="outer">outer-done</p>');
|
|
386
|
+
expect(chunk).toContain('<p class="inner">inner-done</p>');
|
|
387
|
+
// coalesced: the inner boundary is inlined, no open slot, no fallback
|
|
388
|
+
expect(chunk).not.toContain('inner-loading');
|
|
389
|
+
expect(chunk).not.toMatch(SLOT_OPEN_RE);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('streams the parent chunk with a nested open slot before the child chunk', async () => {
|
|
393
|
+
const d_outer = deferred<string>();
|
|
394
|
+
const d_inner = deferred<string>();
|
|
395
|
+
const App = makeNestedApp(d_outer, d_inner);
|
|
396
|
+
|
|
397
|
+
const { chunks, sink } = recordingSink();
|
|
398
|
+
const result = render(App, { stream: sink });
|
|
399
|
+
|
|
400
|
+
d_outer.resolve('outer-done');
|
|
401
|
+
await d_outer.promise;
|
|
402
|
+
await Promise.resolve();
|
|
403
|
+
await Promise.resolve();
|
|
404
|
+
|
|
405
|
+
expect(chunks).toHaveLength(2);
|
|
406
|
+
const parent_chunk = chunks[1];
|
|
407
|
+
expect(parent_chunk).toContain('<p class="outer">outer-done</p>');
|
|
408
|
+
// the child is still pending: its slot (wrapper + fallback) ships
|
|
409
|
+
// inside the parent chunk
|
|
410
|
+
expect(parent_chunk).toContain('inner-loading');
|
|
411
|
+
expect(parent_chunk).toMatch(SLOT_OPEN_RE);
|
|
412
|
+
|
|
413
|
+
d_inner.resolve('inner-done');
|
|
414
|
+
await result;
|
|
415
|
+
|
|
416
|
+
expect(chunks).toHaveLength(3);
|
|
417
|
+
expect(chunks[2]).toContain('<p class="inner">inner-done</p>');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('streams sibling boundaries out of order, as each settles', async () => {
|
|
421
|
+
const d_first = deferred<string>();
|
|
422
|
+
const d_second = deferred<string>();
|
|
423
|
+
|
|
424
|
+
function First() @{
|
|
425
|
+
let &[data] = trackAsync(() => d_first.promise);
|
|
426
|
+
<p>
|
|
427
|
+
{'first: ' + data}
|
|
428
|
+
</p>
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function Second() @{
|
|
432
|
+
let &[data] = trackAsync(() => d_second.promise);
|
|
433
|
+
<p>
|
|
434
|
+
{'second: ' + data}
|
|
435
|
+
</p>
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function App() @{
|
|
439
|
+
<>
|
|
440
|
+
@try {
|
|
441
|
+
<First />
|
|
442
|
+
} @pending {
|
|
443
|
+
<p>{'first-loading'}</p>
|
|
444
|
+
}
|
|
445
|
+
@try {
|
|
446
|
+
<Second />
|
|
447
|
+
} @pending {
|
|
448
|
+
<p>{'second-loading'}</p>
|
|
449
|
+
}
|
|
450
|
+
</>
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const { chunks, sink } = recordingSink();
|
|
454
|
+
const result = render(App, { stream: sink });
|
|
455
|
+
|
|
456
|
+
d_second.resolve('B');
|
|
457
|
+
await d_second.promise;
|
|
458
|
+
await Promise.resolve();
|
|
459
|
+
await Promise.resolve();
|
|
460
|
+
|
|
461
|
+
expect(chunks).toHaveLength(2);
|
|
462
|
+
expect(chunks[1]).toContain('second: B');
|
|
463
|
+
expect(chunks[1]).not.toContain('first');
|
|
464
|
+
|
|
465
|
+
d_first.resolve('A');
|
|
466
|
+
await result;
|
|
467
|
+
|
|
468
|
+
expect(chunks).toHaveLength(3);
|
|
469
|
+
expect(chunks[2]).toContain('first: A');
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it(
|
|
473
|
+
'produces the same document as a non-streaming render regardless of settle order',
|
|
474
|
+
async () => {
|
|
475
|
+
function makeApp(ds: Deferred<string>[]) {
|
|
476
|
+
function A() @{
|
|
477
|
+
let &[data] = trackAsync(() => ds[0].promise);
|
|
478
|
+
<p>
|
|
479
|
+
{'A:' + data}
|
|
480
|
+
</p>
|
|
481
|
+
}
|
|
482
|
+
function B() @{
|
|
483
|
+
let &[data] = trackAsync(() => ds[1].promise);
|
|
484
|
+
<p>
|
|
485
|
+
{'B:' + data}
|
|
486
|
+
</p>
|
|
487
|
+
}
|
|
488
|
+
function C() @{
|
|
489
|
+
let &[data] = trackAsync(() => ds[2].promise);
|
|
490
|
+
<p>
|
|
491
|
+
{'C:' + data}
|
|
492
|
+
</p>
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function App() @{
|
|
496
|
+
<main>
|
|
497
|
+
<h1>{'title'}</h1>
|
|
498
|
+
@try {
|
|
499
|
+
<>
|
|
500
|
+
<A />
|
|
501
|
+
@try {
|
|
502
|
+
<B />
|
|
503
|
+
} @pending {
|
|
504
|
+
<span>{'b-loading'}</span>
|
|
505
|
+
}
|
|
506
|
+
</>
|
|
507
|
+
} @pending {
|
|
508
|
+
<span>{'a-loading'}</span>
|
|
509
|
+
}
|
|
510
|
+
@try {
|
|
511
|
+
<C />
|
|
512
|
+
} @pending {
|
|
513
|
+
<span>{'c-loading'}</span>
|
|
514
|
+
}
|
|
515
|
+
</main>
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return App;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// adversarial settle orders, including child-before-parent
|
|
522
|
+
const orders = [
|
|
523
|
+
[0, 1, 2],
|
|
524
|
+
[2, 1, 0],
|
|
525
|
+
[1, 2, 0],
|
|
526
|
+
];
|
|
527
|
+
|
|
528
|
+
for (const order of orders) {
|
|
529
|
+
const streaming_ds = [deferred<string>(), deferred<string>(), deferred<string>()];
|
|
530
|
+
const { chunks, sink } = recordingSink();
|
|
531
|
+
const streaming = render(makeApp(streaming_ds), { stream: sink });
|
|
532
|
+
for (const index of order) {
|
|
533
|
+
streaming_ds[index].resolve('v' + index);
|
|
534
|
+
await streaming_ds[index].promise;
|
|
535
|
+
await Promise.resolve();
|
|
536
|
+
}
|
|
537
|
+
await streaming;
|
|
538
|
+
|
|
539
|
+
const sync_ds = [deferred<string>(), deferred<string>(), deferred<string>()];
|
|
540
|
+
const sync_render = render(makeApp(sync_ds));
|
|
541
|
+
sync_ds.forEach((d, index) => d.resolve('v' + index));
|
|
542
|
+
const { body } = await sync_render;
|
|
543
|
+
|
|
544
|
+
expect(normalize(applyStream(chunks))).toBe(normalize(body));
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
describe('streaming root boundary', () => {
|
|
551
|
+
it('suspends at the root boundary when no user try wraps the async read', async () => {
|
|
552
|
+
const d = deferred<string>();
|
|
553
|
+
|
|
554
|
+
function App() @{
|
|
555
|
+
let &[data] = trackAsync(() => d.promise);
|
|
556
|
+
<p>
|
|
557
|
+
{'app: ' + data}
|
|
558
|
+
</p>
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function RootPending() @{
|
|
562
|
+
<p>{'root-loading'}</p>
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const { chunks, sink } = recordingSink();
|
|
566
|
+
const result = render(App, { stream: sink, rootBoundary: { pending: RootPending } });
|
|
567
|
+
|
|
568
|
+
expect(chunks).toHaveLength(1);
|
|
569
|
+
expect(chunks[0]).toContain('root-loading');
|
|
570
|
+
expect(chunks[0]).toMatch(SLOT_OPEN_RE);
|
|
571
|
+
|
|
572
|
+
d.resolve('ready');
|
|
573
|
+
await result;
|
|
574
|
+
|
|
575
|
+
expect(chunks).toHaveLength(2);
|
|
576
|
+
expect(chunks[1]).toContain('app: ready');
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
describe('streaming with a document template', () => {
|
|
581
|
+
it('wraps the shell in the scaffold and appends the suffix on close', async () => {
|
|
582
|
+
const d = deferred<string>();
|
|
583
|
+
|
|
584
|
+
function Content() @{
|
|
585
|
+
let &[data] = trackAsync(() => d.promise);
|
|
586
|
+
<p>{data}</p>
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function App() @{
|
|
590
|
+
@try {
|
|
591
|
+
<Content />
|
|
592
|
+
} @pending {
|
|
593
|
+
<p>{'loading...'}</p>
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const { chunks, sink, isClosed } = recordingSink();
|
|
598
|
+
const result = render(App, {
|
|
599
|
+
stream: sink,
|
|
600
|
+
streamTemplate: {
|
|
601
|
+
before: '<html><head><title>t</title>',
|
|
602
|
+
between: '</head><body><div id="root">',
|
|
603
|
+
after: '</div></body></html>',
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
expect(chunks).toHaveLength(1);
|
|
608
|
+
expect(chunks[0].startsWith('<html><head><title>t</title>')).toBe(true);
|
|
609
|
+
expect(chunks[0]).toContain('</head><body><div id="root">');
|
|
610
|
+
// the shell body follows the scaffold's body opening
|
|
611
|
+
expect(chunks[0].indexOf('<div id="root">')).toBeLessThan(chunks[0].indexOf('loading...'));
|
|
612
|
+
expect(chunks[0]).not.toContain('</html>');
|
|
613
|
+
|
|
614
|
+
d.resolve('done');
|
|
615
|
+
await result;
|
|
616
|
+
|
|
617
|
+
expect(isClosed()).toBe(true);
|
|
618
|
+
// the suffix is the final push, after the last chunk
|
|
619
|
+
expect(chunks[chunks.length - 1]).toBe('</div></body></html>');
|
|
620
|
+
expect(chunks[chunks.length - 2]).toContain('<p>done</p>');
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
describe('streaming head content', () => {
|
|
625
|
+
it('ships head writes from async content as a head template in the chunk', async () => {
|
|
626
|
+
const d = deferred<string>();
|
|
627
|
+
|
|
628
|
+
function LateHead() @{
|
|
629
|
+
let &[data] = trackAsync(() => d.promise);
|
|
630
|
+
<>
|
|
631
|
+
@if (data) {
|
|
632
|
+
<>
|
|
633
|
+
<head>
|
|
634
|
+
<title>
|
|
635
|
+
{'title:' + data}
|
|
636
|
+
</title>
|
|
637
|
+
</head>
|
|
638
|
+
<p class="has-head">{data}</p>
|
|
639
|
+
</>
|
|
640
|
+
}
|
|
641
|
+
</>
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function App() @{
|
|
645
|
+
@try {
|
|
646
|
+
<LateHead />
|
|
647
|
+
} @pending {
|
|
648
|
+
<p>{'loading...'}</p>
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const { chunks, sink } = recordingSink();
|
|
653
|
+
const result = render(App, { stream: sink });
|
|
654
|
+
|
|
655
|
+
expect(chunks).toHaveLength(1);
|
|
656
|
+
expect(chunks[0]).not.toContain('<title>');
|
|
657
|
+
|
|
658
|
+
d.resolve('late');
|
|
659
|
+
await result;
|
|
660
|
+
|
|
661
|
+
expect(chunks).toHaveLength(2);
|
|
662
|
+
const chunk = chunks[1];
|
|
663
|
+
expect(chunk).toMatch(
|
|
664
|
+
/<template data-ripple-head="\d+">[\s\S]*?<title>title:late<\/title>[\s\S]*?<\/template>/,
|
|
665
|
+
);
|
|
666
|
+
expect(chunk).toContain('<p class="has-head">late</p>');
|
|
667
|
+
// the head template precedes the content template
|
|
668
|
+
expect(chunk.indexOf('data-ripple-head')).toBeLessThan(chunk.indexOf('data-ripple-chunk'));
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
describe('streaming CSS', () => {
|
|
673
|
+
it('sends sync CSS with the shell and async-only CSS with its chunk, deduplicated', async () => {
|
|
674
|
+
const d_gate = deferred<boolean>();
|
|
675
|
+
const d_gate2 = deferred<boolean>();
|
|
676
|
+
|
|
677
|
+
function LateStyled() @{
|
|
678
|
+
<>
|
|
679
|
+
<style>
|
|
680
|
+
.late-styled {
|
|
681
|
+
color: rgb(1, 2, 3);
|
|
682
|
+
}
|
|
683
|
+
</style>
|
|
684
|
+
<div class="late-styled">{'late'}</div>
|
|
685
|
+
</>
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function GateOne() @{
|
|
689
|
+
let &[ready] = trackAsync(() => d_gate.promise);
|
|
690
|
+
<>
|
|
691
|
+
@if (ready) {
|
|
692
|
+
<LateStyled />
|
|
693
|
+
}
|
|
694
|
+
</>
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function GateTwo() @{
|
|
698
|
+
let &[ready] = trackAsync(() => d_gate2.promise);
|
|
699
|
+
<>
|
|
700
|
+
@if (ready) {
|
|
701
|
+
<LateStyled />
|
|
702
|
+
}
|
|
703
|
+
</>
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function App() @{
|
|
707
|
+
<>
|
|
708
|
+
<style>
|
|
709
|
+
.shell-styled {
|
|
710
|
+
color: rgb(9, 9, 9);
|
|
711
|
+
}
|
|
712
|
+
</style>
|
|
713
|
+
<div class="shell-styled">{'shell'}</div>
|
|
714
|
+
@try {
|
|
715
|
+
<GateOne />
|
|
716
|
+
} @pending {
|
|
717
|
+
<p>{'one-loading'}</p>
|
|
718
|
+
}
|
|
719
|
+
@try {
|
|
720
|
+
<GateTwo />
|
|
721
|
+
} @pending {
|
|
722
|
+
<p>{'two-loading'}</p>
|
|
723
|
+
}
|
|
724
|
+
</>
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const { chunks, sink } = recordingSink();
|
|
728
|
+
const result = render(App, { stream: sink });
|
|
729
|
+
|
|
730
|
+
expect(chunks).toHaveLength(1);
|
|
731
|
+
expect(chunks[0]).toContain('.shell-styled');
|
|
732
|
+
expect(chunks[0]).not.toContain('.late-styled');
|
|
733
|
+
|
|
734
|
+
d_gate.resolve(true);
|
|
735
|
+
await d_gate.promise;
|
|
736
|
+
await Promise.resolve();
|
|
737
|
+
await Promise.resolve();
|
|
738
|
+
|
|
739
|
+
expect(chunks).toHaveLength(2);
|
|
740
|
+
// the chunk carries the style it needs, ahead of its template
|
|
741
|
+
const style_index = chunks[1].indexOf('.late-styled');
|
|
742
|
+
const template_index = chunks[1].indexOf('<template data-ripple-chunk=');
|
|
743
|
+
expect(style_index).toBeGreaterThan(-1);
|
|
744
|
+
expect(style_index).toBeLessThan(template_index);
|
|
745
|
+
|
|
746
|
+
d_gate2.resolve(true);
|
|
747
|
+
await result;
|
|
748
|
+
|
|
749
|
+
expect(chunks).toHaveLength(3);
|
|
750
|
+
// the second chunk renders the same component but the css was already sent
|
|
751
|
+
expect(chunks[2]).toContain('late');
|
|
752
|
+
expect(chunks[2]).not.toContain('.late-styled');
|
|
753
|
+
});
|
|
44
754
|
});
|