@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.
@@ -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>&lt;script>alert("xss")&lt;/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>&quot;xss&quot;</script>"></div>');
187
+ });
188
+
189
+ it('escapes ampersands', async () => {
190
+ const html = await renderToString(<div>{'foo & bar'}</div>);
191
+ expect(html).toBe('<div>foo &amp; 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
+ }