@oomfware/jsx 0.1.1
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/LICENSE +14 -0
- package/README.md +164 -0
- package/dist/index.d.mts +77 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +408 -0
- package/dist/index.mjs.map +1 -0
- package/dist/jsx-runtime-BQHdv_66.d.mts +1311 -0
- package/dist/jsx-runtime-BQHdv_66.d.mts.map +1 -0
- package/dist/jsx-runtime.d.mts +2 -0
- package/dist/jsx-runtime.mjs +34 -0
- package/dist/jsx-runtime.mjs.map +1 -0
- package/package.json +46 -0
- package/src/index.ts +9 -0
- package/src/jsx-runtime.ts +33 -0
- package/src/lib/context.ts +98 -0
- package/src/lib/intrinsic-elements.ts +1592 -0
- package/src/lib/render.ts +504 -0
- package/src/lib/stream.test.tsx +625 -0
- package/src/lib/suspense.ts +57 -0
- package/src/lib/types.ts +37 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { createContext, render, renderToStream, renderToString, Suspense, use } from '../index.ts';
|
|
4
|
+
|
|
5
|
+
// suspense runtime script (injected once before first resolution)
|
|
6
|
+
const SR = '$sr';
|
|
7
|
+
const SUSPENSE_RUNTIME = `<script>${SR}=(t,i,s,e)=>{i="$s:"+t.dataset.suspense;s=document.createTreeWalker(document,128);while(e=s.nextNode())if(e.data===i){while(e.nextSibling?.data!=="/"+i)e.nextSibling.remove();e.nextSibling.replaceWith(...t.content.childNodes);e.remove();break}t.remove()}</script>`;
|
|
8
|
+
const SUSPENSE_CALL = `<script>${SR}(document.currentScript.previousElementSibling)</script>`;
|
|
9
|
+
|
|
10
|
+
// helper to drain a stream to string
|
|
11
|
+
async function drain(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
12
|
+
const reader = stream.getReader();
|
|
13
|
+
const decoder = new TextDecoder();
|
|
14
|
+
let html = '';
|
|
15
|
+
while (true) {
|
|
16
|
+
const { done, value } = await reader.read();
|
|
17
|
+
if (done) break;
|
|
18
|
+
html += decoder.decode(value);
|
|
19
|
+
}
|
|
20
|
+
return html;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('stream', () => {
|
|
24
|
+
describe('basic nodes', () => {
|
|
25
|
+
it('should render to a stream', () => {
|
|
26
|
+
const stream = renderToStream(<div>hello, world!</div>);
|
|
27
|
+
expect(stream).toBeDefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('render() returns a Response', async () => {
|
|
31
|
+
const response = render(<div>hello</div>);
|
|
32
|
+
expect(response).toBeInstanceOf(Response);
|
|
33
|
+
expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8');
|
|
34
|
+
const html = await response.text();
|
|
35
|
+
expect(html).toBe('<div>hello</div>');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('render() merges custom headers', async () => {
|
|
39
|
+
const response = render(<div>hello</div>, {
|
|
40
|
+
status: 201,
|
|
41
|
+
headers: { 'X-Custom': 'value' },
|
|
42
|
+
});
|
|
43
|
+
expect(response.status).toBe(201);
|
|
44
|
+
expect(response.headers.get('X-Custom')).toBe('value');
|
|
45
|
+
expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('render() allows overriding Content-Type', async () => {
|
|
49
|
+
const response = render(<div>hello</div>, {
|
|
50
|
+
headers: { 'Content-Type': 'text/html' },
|
|
51
|
+
});
|
|
52
|
+
expect(response.headers.get('Content-Type')).toBe('text/html');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('streams basic HTML', async () => {
|
|
56
|
+
const stream = renderToStream(<div>hello, world!</div>);
|
|
57
|
+
const html = await drain(stream);
|
|
58
|
+
expect(html).toBe('<div>hello, world!</div>');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('renders string nodes', async () => {
|
|
62
|
+
const html = await renderToString('hello, world!');
|
|
63
|
+
expect(html).toBe('hello, world!');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('renders number nodes', async () => {
|
|
67
|
+
const html = await renderToString(42);
|
|
68
|
+
expect(html).toBe('42');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('renders 0', async () => {
|
|
72
|
+
const html = await renderToString(<span>{0}</span>);
|
|
73
|
+
expect(html).toBe('<span>0</span>');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('renders bigint nodes', async () => {
|
|
77
|
+
const html = await renderToString(BigInt(9007199254740991));
|
|
78
|
+
expect(html).toBe('9007199254740991');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('renders boolean nodes as empty', async () => {
|
|
82
|
+
const html = await renderToString(true);
|
|
83
|
+
expect(html).toBe('');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('renders null nodes as empty', async () => {
|
|
87
|
+
const html = await renderToString(null);
|
|
88
|
+
expect(html).toBe('');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('renders undefined nodes as empty', async () => {
|
|
92
|
+
const html = await renderToString(undefined);
|
|
93
|
+
expect(html).toBe('');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('renders array of nodes', async () => {
|
|
97
|
+
const html = await renderToString([<div>one</div>, <span>two</span>]);
|
|
98
|
+
expect(html).toBe('<div>one</div><span>two</span>');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('renders mixed array of nodes', async () => {
|
|
102
|
+
const html = await renderToString([<div>one</div>, 'text', 42, null, undefined]);
|
|
103
|
+
expect(html).toBe('<div>one</div>text42');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('renders fragments', async () => {
|
|
107
|
+
const html = await renderToString(
|
|
108
|
+
<>
|
|
109
|
+
<h1>title</h1>
|
|
110
|
+
<p>paragraph</p>
|
|
111
|
+
<div>content</div>
|
|
112
|
+
</>,
|
|
113
|
+
);
|
|
114
|
+
expect(html).toBe('<h1>title</h1><p>paragraph</p><div>content</div>');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('component nodes', () => {
|
|
119
|
+
it('renders component nodes', async () => {
|
|
120
|
+
function Greeting({ name }: { name: string }) {
|
|
121
|
+
return <div>hello, {name}!</div>;
|
|
122
|
+
}
|
|
123
|
+
const html = await renderToString(<Greeting name="world" />);
|
|
124
|
+
expect(html).toBe('<div>hello, world!</div>');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('renders nested components', async () => {
|
|
128
|
+
function Inner() {
|
|
129
|
+
return <span>inner</span>;
|
|
130
|
+
}
|
|
131
|
+
function Outer() {
|
|
132
|
+
return (
|
|
133
|
+
<div>
|
|
134
|
+
<Inner />
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const html = await renderToString(<Outer />);
|
|
139
|
+
expect(html).toBe('<div><span>inner</span></div>');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('attributes', () => {
|
|
144
|
+
it('renders boolean attributes', async () => {
|
|
145
|
+
const html = await renderToString(<input disabled readonly />);
|
|
146
|
+
expect(html).toBe('<input disabled readonly>');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('omits false boolean attributes', async () => {
|
|
150
|
+
const html = await renderToString(<input disabled={false} />);
|
|
151
|
+
expect(html).toBe('<input>');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('omits undefined attributes', async () => {
|
|
155
|
+
const html = await renderToString(<input value={undefined} placeholder={undefined} />);
|
|
156
|
+
expect(html).toBe('<input>');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('renders data-* attributes as-is', async () => {
|
|
160
|
+
const html = await renderToString(<div data-testid="foo" data-value="123" />);
|
|
161
|
+
expect(html).toBe('<div data-testid="foo" data-value="123"></div>');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('renders aria-* attributes as-is', async () => {
|
|
165
|
+
const html = await renderToString(<button aria-label="close" aria-hidden="true" />);
|
|
166
|
+
expect(html).toBe('<button aria-label="close" aria-hidden="true"></button>');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('omits function attributes', async () => {
|
|
170
|
+
// @ts-expect-error testing runtime behavior with invalid type
|
|
171
|
+
const html = await renderToString(<button onclick={() => {}} />);
|
|
172
|
+
expect(html).toBe('<button></button>');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('escaping', () => {
|
|
177
|
+
it('escapes text content', async () => {
|
|
178
|
+
const html = await renderToString(<div>{'<script>alert("xss")</script>'}</div>);
|
|
179
|
+
// < is escaped, > doesn't need escaping in content
|
|
180
|
+
expect(html).toBe('<div><script>alert("xss")</script></div>');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('escapes attribute values', async () => {
|
|
184
|
+
const html = await renderToString(<div title={'<script>"xss"</script>'} />);
|
|
185
|
+
// " is escaped in attributes, < doesn't need escaping in attributes
|
|
186
|
+
expect(html).toBe('<div title="<script>"xss"</script>"></div>');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('escapes ampersands', async () => {
|
|
190
|
+
const html = await renderToString(<div>{'foo & bar'}</div>);
|
|
191
|
+
expect(html).toBe('<div>foo & bar</div>');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('does not escape dangerouslySetInnerHTML', async () => {
|
|
195
|
+
const html = await renderToString(
|
|
196
|
+
<div dangerouslySetInnerHTML={{ __html: '<strong>bold</strong>' }} />,
|
|
197
|
+
);
|
|
198
|
+
expect(html).toBe('<div><strong>bold</strong></div>');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('self-closing tags', () => {
|
|
203
|
+
it('renders void elements as self-closing', async () => {
|
|
204
|
+
const html = await renderToString(
|
|
205
|
+
<>
|
|
206
|
+
<br />
|
|
207
|
+
<hr />
|
|
208
|
+
<img src="test.png" />
|
|
209
|
+
<input type="text" />
|
|
210
|
+
<area shape="rect" />
|
|
211
|
+
<col span={2} />
|
|
212
|
+
</>,
|
|
213
|
+
);
|
|
214
|
+
expect(html).toBe('<br><hr><img src="test.png"><input type="text"><area shape="rect"><col span="2">');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('style attribute', () => {
|
|
219
|
+
it('renders string style as-is', async () => {
|
|
220
|
+
const html = await renderToString(<div style="color: blue; font-weight: bold" />);
|
|
221
|
+
expect(html).toBe('<div style="color: blue; font-weight: bold"></div>');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('serializes style object', async () => {
|
|
225
|
+
const html = await renderToString(
|
|
226
|
+
<div style={{ color: 'green', 'margin-top': '10px', padding: '5px' }} />,
|
|
227
|
+
);
|
|
228
|
+
expect(html).toBe('<div style="color:green;margin-top:10px;padding:5px"></div>');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('doctype', () => {
|
|
233
|
+
it('prepends DOCTYPE for html root element', async () => {
|
|
234
|
+
const html = await renderToString(
|
|
235
|
+
<html>
|
|
236
|
+
<head>
|
|
237
|
+
<title>test page</title>
|
|
238
|
+
</head>
|
|
239
|
+
<body>
|
|
240
|
+
<div>hello</div>
|
|
241
|
+
</body>
|
|
242
|
+
</html>,
|
|
243
|
+
);
|
|
244
|
+
expect(html).toBe(
|
|
245
|
+
'<!doctype html><html><head><title>test page</title></head><body><div>hello</div></body></html>',
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('does not prepend DOCTYPE for non-html root', async () => {
|
|
250
|
+
const html = await renderToString(
|
|
251
|
+
<div>
|
|
252
|
+
<html>not a root</html>
|
|
253
|
+
</div>,
|
|
254
|
+
);
|
|
255
|
+
expect(html).toBe('<div><html>not a root</html></div>');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('head hoisting', () => {
|
|
260
|
+
it('hoists title elements to head', async () => {
|
|
261
|
+
const html = await renderToString(
|
|
262
|
+
<html>
|
|
263
|
+
<body>
|
|
264
|
+
<title>page title</title>
|
|
265
|
+
<div>content</div>
|
|
266
|
+
</body>
|
|
267
|
+
</html>,
|
|
268
|
+
);
|
|
269
|
+
expect(html).toBe(
|
|
270
|
+
'<!doctype html><html><head><title>page title</title></head><body><div>content</div></body></html>',
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('hoists meta elements to head', async () => {
|
|
275
|
+
const html = await renderToString(
|
|
276
|
+
<div>
|
|
277
|
+
<meta name="description" content="test page" />
|
|
278
|
+
<h1>hello</h1>
|
|
279
|
+
</div>,
|
|
280
|
+
);
|
|
281
|
+
expect(html).toBe(
|
|
282
|
+
'<head><meta name="description" content="test page"></head><div><h1>hello</h1></div>',
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('hoists link elements to head', async () => {
|
|
287
|
+
const html = await renderToString(
|
|
288
|
+
<div>
|
|
289
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
290
|
+
<p>content</p>
|
|
291
|
+
</div>,
|
|
292
|
+
);
|
|
293
|
+
expect(html).toBe('<head><link rel="stylesheet" href="/styles.css"></head><div><p>content</p></div>');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('collects multiple head elements', async () => {
|
|
297
|
+
const html = await renderToString(
|
|
298
|
+
<div>
|
|
299
|
+
<title>my app</title>
|
|
300
|
+
<meta charset="utf-8" />
|
|
301
|
+
<p>hello</p>
|
|
302
|
+
<link rel="icon" href="/favicon.ico" />
|
|
303
|
+
<meta name="viewport" content="width=device-width" />
|
|
304
|
+
</div>,
|
|
305
|
+
);
|
|
306
|
+
expect(html).toBe(
|
|
307
|
+
'<head><title>my app</title><meta charset="utf-8"><link rel="icon" href="/favicon.ico"><meta name="viewport" content="width=device-width"></head><div><p>hello</p></div>',
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('hoists head elements from components', async () => {
|
|
312
|
+
function SEO() {
|
|
313
|
+
return (
|
|
314
|
+
<>
|
|
315
|
+
<title>component title</title>
|
|
316
|
+
<meta name="description" content="component description" />
|
|
317
|
+
</>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const html = await renderToString(
|
|
322
|
+
<div>
|
|
323
|
+
<SEO />
|
|
324
|
+
<main>content</main>
|
|
325
|
+
</div>,
|
|
326
|
+
);
|
|
327
|
+
expect(html).toBe(
|
|
328
|
+
'<head><title>component title</title><meta name="description" content="component description"></head><div><main>content</main></div>',
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('merges head elements with existing head tag', async () => {
|
|
333
|
+
const html = await renderToString(
|
|
334
|
+
<html>
|
|
335
|
+
<head>
|
|
336
|
+
<meta charset="utf-8" />
|
|
337
|
+
</head>
|
|
338
|
+
<body>
|
|
339
|
+
<title>body title</title>
|
|
340
|
+
<link rel="stylesheet" href="/app.css" />
|
|
341
|
+
<div>content</div>
|
|
342
|
+
</body>
|
|
343
|
+
</html>,
|
|
344
|
+
);
|
|
345
|
+
expect(html).toBe(
|
|
346
|
+
'<!doctype html><html><head><meta charset="utf-8"><title>body title</title><link rel="stylesheet" href="/app.css"></head><body><div>content</div></body></html>',
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('does not hoist script tags', async () => {
|
|
351
|
+
const html = await renderToString(
|
|
352
|
+
<div>
|
|
353
|
+
<h1>page title</h1>
|
|
354
|
+
<script dangerouslySetInnerHTML={{ __html: "console.log('hello')" }} />
|
|
355
|
+
<p>some content</p>
|
|
356
|
+
</div>,
|
|
357
|
+
);
|
|
358
|
+
expect(html).toBe(
|
|
359
|
+
"<div><h1>page title</h1><script>console.log('hello')</script><p>some content</p></div>",
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('context', () => {
|
|
365
|
+
it('provides and consumes context via use()', async () => {
|
|
366
|
+
const ThemeContext = createContext('light');
|
|
367
|
+
|
|
368
|
+
function ThemedButton() {
|
|
369
|
+
const theme = use(ThemeContext);
|
|
370
|
+
return <button class={theme}>click</button>;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const html = await renderToString(
|
|
374
|
+
<ThemeContext.Provider value="dark">
|
|
375
|
+
<ThemedButton />
|
|
376
|
+
</ThemeContext.Provider>,
|
|
377
|
+
);
|
|
378
|
+
expect(html).toBe('<button class="dark">click</button>');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('returns default value when no provider', async () => {
|
|
382
|
+
const CountContext = createContext(0);
|
|
383
|
+
|
|
384
|
+
function Counter() {
|
|
385
|
+
const count = use(CountContext);
|
|
386
|
+
return <span>{count}</span>;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const html = await renderToString(<Counter />);
|
|
390
|
+
expect(html).toBe('<span>0</span>');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('provides nested context', async () => {
|
|
394
|
+
const ThemeContext = createContext('light');
|
|
395
|
+
const UserContext = createContext('anonymous');
|
|
396
|
+
|
|
397
|
+
function Display() {
|
|
398
|
+
const theme = use(ThemeContext);
|
|
399
|
+
const user = use(UserContext);
|
|
400
|
+
return (
|
|
401
|
+
<p>
|
|
402
|
+
{user} uses {theme}
|
|
403
|
+
</p>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const html = await renderToString(
|
|
408
|
+
<ThemeContext.Provider value="dark">
|
|
409
|
+
<UserContext.Provider value="alice">
|
|
410
|
+
<Display />
|
|
411
|
+
</UserContext.Provider>
|
|
412
|
+
</ThemeContext.Provider>,
|
|
413
|
+
);
|
|
414
|
+
expect(html).toBe('<p>alice uses dark</p>');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('overrides context in nested providers', async () => {
|
|
418
|
+
const ThemeContext = createContext('light');
|
|
419
|
+
|
|
420
|
+
function ThemedText() {
|
|
421
|
+
const theme = use(ThemeContext);
|
|
422
|
+
return <span>{theme}</span>;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const html = await renderToString(
|
|
426
|
+
<ThemeContext.Provider value="dark">
|
|
427
|
+
<div>
|
|
428
|
+
<ThemedText />
|
|
429
|
+
<ThemeContext.Provider value="blue">
|
|
430
|
+
<ThemedText />
|
|
431
|
+
</ThemeContext.Provider>
|
|
432
|
+
<ThemedText />
|
|
433
|
+
</div>
|
|
434
|
+
</ThemeContext.Provider>,
|
|
435
|
+
);
|
|
436
|
+
expect(html).toBe('<div><span>dark</span><span>blue</span><span>dark</span></div>');
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe('suspense', () => {
|
|
441
|
+
it('renders children synchronously when no suspension', async () => {
|
|
442
|
+
const html = await renderToString(
|
|
443
|
+
<Suspense fallback={<div>loading...</div>}>
|
|
444
|
+
<div>content</div>
|
|
445
|
+
</Suspense>,
|
|
446
|
+
);
|
|
447
|
+
expect(html).toBe('<div>content</div>');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('renders fallback then streams resolved content', async () => {
|
|
451
|
+
const { promise, resolve } = Promise.withResolvers<string>();
|
|
452
|
+
|
|
453
|
+
function AsyncComponent() {
|
|
454
|
+
const data = use(promise);
|
|
455
|
+
return <div>{data}</div>;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const stream = renderToStream(
|
|
459
|
+
<Suspense fallback={<div>loading...</div>}>
|
|
460
|
+
<AsyncComponent />
|
|
461
|
+
</Suspense>,
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
// resolve before reading
|
|
465
|
+
resolve('loaded!');
|
|
466
|
+
|
|
467
|
+
const html = await drain(stream);
|
|
468
|
+
expect(html).toBe(
|
|
469
|
+
'<!--$s:s1--><div>loading...</div><!--/$s:s1-->' +
|
|
470
|
+
SUSPENSE_RUNTIME +
|
|
471
|
+
'<template data-suspense="s1"><div>loaded!</div></template>' +
|
|
472
|
+
SUSPENSE_CALL,
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('streams fragment with multiple top-level elements', async () => {
|
|
477
|
+
const { promise, resolve } = Promise.withResolvers<string>();
|
|
478
|
+
|
|
479
|
+
function AsyncComponent() {
|
|
480
|
+
const data = use(promise);
|
|
481
|
+
return (
|
|
482
|
+
<>
|
|
483
|
+
<h1>{data}</h1>
|
|
484
|
+
<p>paragraph one</p>
|
|
485
|
+
<p>paragraph two</p>
|
|
486
|
+
</>
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const stream = renderToStream(
|
|
491
|
+
<Suspense fallback={<div>loading...</div>}>
|
|
492
|
+
<AsyncComponent />
|
|
493
|
+
</Suspense>,
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
resolve('title');
|
|
497
|
+
|
|
498
|
+
const html = await drain(stream);
|
|
499
|
+
expect(html).toBe(
|
|
500
|
+
'<!--$s:s1--><div>loading...</div><!--/$s:s1-->' +
|
|
501
|
+
SUSPENSE_RUNTIME +
|
|
502
|
+
'<template data-suspense="s1"><h1>title</h1><p>paragraph one</p><p>paragraph two</p></template>' +
|
|
503
|
+
SUSPENSE_CALL,
|
|
504
|
+
);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('streams with fragment fallback', async () => {
|
|
508
|
+
const { promise, resolve } = Promise.withResolvers<string>();
|
|
509
|
+
|
|
510
|
+
function AsyncComponent() {
|
|
511
|
+
const data = use(promise);
|
|
512
|
+
return <div>{data}</div>;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const stream = renderToStream(
|
|
516
|
+
<Suspense
|
|
517
|
+
fallback={
|
|
518
|
+
<>
|
|
519
|
+
<div>loading...</div>
|
|
520
|
+
<div>please wait</div>
|
|
521
|
+
</>
|
|
522
|
+
}
|
|
523
|
+
>
|
|
524
|
+
<AsyncComponent />
|
|
525
|
+
</Suspense>,
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
resolve('done');
|
|
529
|
+
|
|
530
|
+
const html = await drain(stream);
|
|
531
|
+
expect(html).toBe(
|
|
532
|
+
'<!--$s:s1--><div>loading...</div><div>please wait</div><!--/$s:s1-->' +
|
|
533
|
+
SUSPENSE_RUNTIME +
|
|
534
|
+
'<template data-suspense="s1"><div>done</div></template>' +
|
|
535
|
+
SUSPENSE_CALL,
|
|
536
|
+
);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('injects runtime once for multiple suspense boundaries', async () => {
|
|
540
|
+
const { promise: p1, resolve: r1 } = Promise.withResolvers<string>();
|
|
541
|
+
const { promise: p2, resolve: r2 } = Promise.withResolvers<string>();
|
|
542
|
+
|
|
543
|
+
function Async1() {
|
|
544
|
+
return <div>{use(p1)}</div>;
|
|
545
|
+
}
|
|
546
|
+
function Async2() {
|
|
547
|
+
return <span>{use(p2)}</span>;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const stream = renderToStream(
|
|
551
|
+
<>
|
|
552
|
+
<Suspense fallback={<div>loading 1</div>}>
|
|
553
|
+
<Async1 />
|
|
554
|
+
</Suspense>
|
|
555
|
+
<Suspense fallback={<div>loading 2</div>}>
|
|
556
|
+
<Async2 />
|
|
557
|
+
</Suspense>
|
|
558
|
+
</>,
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
r1('first');
|
|
562
|
+
r2('second');
|
|
563
|
+
|
|
564
|
+
const html = await drain(stream);
|
|
565
|
+
expect(html).toBe(
|
|
566
|
+
'<!--$s:s1--><div>loading 1</div><!--/$s:s1-->' +
|
|
567
|
+
'<!--$s:s2--><div>loading 2</div><!--/$s:s2-->' +
|
|
568
|
+
SUSPENSE_RUNTIME +
|
|
569
|
+
'<template data-suspense="s1"><div>first</div></template>' +
|
|
570
|
+
SUSPENSE_CALL +
|
|
571
|
+
'<template data-suspense="s2"><span>second</span></template>' +
|
|
572
|
+
SUSPENSE_CALL,
|
|
573
|
+
);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('use() returns cached value on subsequent calls', async () => {
|
|
577
|
+
const promise = Promise.resolve('cached');
|
|
578
|
+
|
|
579
|
+
function AsyncComponent() {
|
|
580
|
+
const data = use(promise);
|
|
581
|
+
return <div>{data}</div>;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// first call sets up caching and throws - catch it
|
|
585
|
+
try {
|
|
586
|
+
use(promise);
|
|
587
|
+
} catch {
|
|
588
|
+
// expected - promise thrown
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// wait for promise to resolve and cache
|
|
592
|
+
await promise;
|
|
593
|
+
|
|
594
|
+
const html = await renderToString(<AsyncComponent />);
|
|
595
|
+
expect(html).toBe('<div>cached</div>');
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('use() throws rejected promise error', async () => {
|
|
599
|
+
const error = new Error('failed!');
|
|
600
|
+
const promise = Promise.reject(error);
|
|
601
|
+
|
|
602
|
+
function AsyncComponent() {
|
|
603
|
+
const data = use(promise);
|
|
604
|
+
return <div>{data}</div>;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// first call sets up caching and throws - catch it
|
|
608
|
+
try {
|
|
609
|
+
use(promise);
|
|
610
|
+
} catch {
|
|
611
|
+
// expected - promise thrown
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// let the promise settle and cache the rejection
|
|
615
|
+
await promise.catch(() => {});
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
await renderToString(<AsyncComponent />);
|
|
619
|
+
expect(true).toBe(false); // should not reach here
|
|
620
|
+
} catch (e) {
|
|
621
|
+
expect(e).toBe(error);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Fragment, jsx } from '../jsx-runtime.js';
|
|
2
|
+
|
|
3
|
+
import { inject, type Context } from './context.js';
|
|
4
|
+
import type { JSXElement, JSXNode } from './types.js';
|
|
5
|
+
|
|
6
|
+
export interface SuspenseProps {
|
|
7
|
+
fallback: JSXNode;
|
|
8
|
+
children?: JSXNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* suspense boundary - renders fallback while children are suspended
|
|
13
|
+
*/
|
|
14
|
+
export function Suspense({ children }: SuspenseProps): JSXElement {
|
|
15
|
+
// Suspense is handled specially in buildSegment, this is just for typing
|
|
16
|
+
return jsx(Fragment, { children });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** cache for resolved/rejected promise values */
|
|
20
|
+
const promiseCache = new WeakMap<
|
|
21
|
+
Promise<unknown>,
|
|
22
|
+
{ resolved: true; value: unknown } | { resolved: false; error: unknown }
|
|
23
|
+
>();
|
|
24
|
+
|
|
25
|
+
function isContext<T>(value: unknown): value is Context<T> {
|
|
26
|
+
return typeof value === 'object' && value !== null && 'defaultValue' in value && 'Provider' in value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* reads a context value or suspends until a promise resolves
|
|
31
|
+
* @param usable context or promise
|
|
32
|
+
* @returns context value or resolved promise value
|
|
33
|
+
* @throws promise if not yet resolved, or error if rejected
|
|
34
|
+
*/
|
|
35
|
+
export function use<T>(usable: Context<T>): T;
|
|
36
|
+
export function use<T>(usable: Promise<T>): T;
|
|
37
|
+
export function use<T>(usable: Context<T> | Promise<T>): T {
|
|
38
|
+
// context
|
|
39
|
+
if (isContext<T>(usable)) {
|
|
40
|
+
return inject(usable);
|
|
41
|
+
}
|
|
42
|
+
// promise
|
|
43
|
+
const cached = promiseCache.get(usable);
|
|
44
|
+
if (cached) {
|
|
45
|
+
if (cached.resolved) {
|
|
46
|
+
return cached.value as T;
|
|
47
|
+
} else {
|
|
48
|
+
throw cached.error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// not cached yet - set up caching and throw
|
|
52
|
+
usable.then(
|
|
53
|
+
(value) => promiseCache.set(usable, { resolved: true, value }),
|
|
54
|
+
(error) => promiseCache.set(usable, { resolved: false, error }),
|
|
55
|
+
);
|
|
56
|
+
throw usable;
|
|
57
|
+
}
|