@oomfware/jsx 0.1.4 → 0.1.6
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/dist/index.d.mts +12 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +164 -68
- package/dist/index.mjs.map +1 -1
- package/dist/jsx-dev-runtime.d.mts +1 -1
- package/dist/{jsx-runtime-CpxZaJu6.d.mts → jsx-runtime-DNOsJEgZ.d.mts} +8 -4
- package/dist/jsx-runtime-DNOsJEgZ.d.mts.map +1 -0
- package/dist/jsx-runtime.d.mts +1 -1
- package/package.json +4 -1
- package/src/index.ts +8 -1
- package/src/lib/context.ts +1 -50
- package/src/lib/intrinsic-elements.ts +9 -4
- package/src/lib/render-context.ts +115 -0
- package/src/lib/render.ts +187 -80
- package/src/lib/stream.test.tsx +445 -2
- package/src/lib/suspense.ts +15 -1
- package/dist/jsx-runtime-CpxZaJu6.d.mts.map +0 -1
package/src/lib/render.ts
CHANGED
|
@@ -10,13 +10,22 @@
|
|
|
10
10
|
* collected and injected into <head> during finalization
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { decodeUtf8From, encodeUtf8 } from '@atcute/uint8array';
|
|
14
|
+
|
|
13
15
|
import { Fragment } from '../jsx-runtime.ts';
|
|
14
16
|
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
+
import {
|
|
18
|
+
popContextFrame,
|
|
19
|
+
pushContextFrame,
|
|
20
|
+
setActiveRenderContext,
|
|
21
|
+
type RenderContext,
|
|
22
|
+
type Segment,
|
|
23
|
+
} from './render-context.ts';
|
|
24
|
+
import { ErrorBoundary, Suspense, type ErrorBoundaryProps, type SuspenseProps } from './suspense.ts';
|
|
17
25
|
import type { Component, JSXElement, JSXNode } from './types.ts';
|
|
18
26
|
|
|
19
27
|
const HEAD_ELEMENTS = new Set(['title', 'meta', 'link', 'style']);
|
|
28
|
+
const MAX_SUSPENSE_ATTEMPTS = 20;
|
|
20
29
|
const SELF_CLOSING_TAGS = new Set([
|
|
21
30
|
'area',
|
|
22
31
|
'base',
|
|
@@ -36,24 +45,7 @@ const SELF_CLOSING_TAGS = new Set([
|
|
|
36
45
|
/** props that shouldn't be rendered as HTML attributes */
|
|
37
46
|
const FRAMEWORK_PROPS = new Set(['children', 'dangerouslySetInnerHTML']);
|
|
38
47
|
|
|
39
|
-
// #region Segment
|
|
40
|
-
|
|
41
|
-
type Segment =
|
|
42
|
-
| {
|
|
43
|
-
readonly kind: 'static';
|
|
44
|
-
readonly html: string;
|
|
45
|
-
}
|
|
46
|
-
| {
|
|
47
|
-
readonly kind: 'composite';
|
|
48
|
-
readonly parts: readonly Segment[];
|
|
49
|
-
}
|
|
50
|
-
| {
|
|
51
|
-
readonly kind: 'suspense';
|
|
52
|
-
readonly id: string;
|
|
53
|
-
readonly fallback: Segment;
|
|
54
|
-
pending?: Promise<void>;
|
|
55
|
-
content: Segment | null;
|
|
56
|
-
};
|
|
48
|
+
// #region Segment helpers
|
|
57
49
|
|
|
58
50
|
function staticSeg(html: string): Segment {
|
|
59
51
|
return { kind: 'static', html };
|
|
@@ -71,15 +63,6 @@ export interface RenderOptions {
|
|
|
71
63
|
onError?: (error: unknown) => void;
|
|
72
64
|
}
|
|
73
65
|
|
|
74
|
-
interface RenderContext {
|
|
75
|
-
headElements: string[];
|
|
76
|
-
idsByPath: Map<string, number>;
|
|
77
|
-
insideHead: boolean;
|
|
78
|
-
insideSvg: boolean;
|
|
79
|
-
onError: (error: unknown) => void;
|
|
80
|
-
pendingSuspense: Array<{ id: string; promise: Promise<Segment> }>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
66
|
/**
|
|
84
67
|
* renders JSX to a readable stream
|
|
85
68
|
* @param node JSX node to render
|
|
@@ -87,9 +70,10 @@ interface RenderContext {
|
|
|
87
70
|
* @returns readable stream of UTF-8 encoded HTML
|
|
88
71
|
*/
|
|
89
72
|
export function renderToStream(node: JSXNode, options?: RenderOptions): ReadableStream<Uint8Array> {
|
|
90
|
-
const encoder = new TextEncoder();
|
|
91
73
|
const onError = options?.onError ?? ((error) => console.error(error));
|
|
92
74
|
const context: RenderContext = {
|
|
75
|
+
contextStack: [],
|
|
76
|
+
currentFrame: null,
|
|
93
77
|
headElements: [],
|
|
94
78
|
idsByPath: new Map(),
|
|
95
79
|
insideHead: false,
|
|
@@ -97,18 +81,22 @@ export function renderToStream(node: JSXNode, options?: RenderOptions): Readable
|
|
|
97
81
|
onError,
|
|
98
82
|
pendingSuspense: [],
|
|
99
83
|
};
|
|
84
|
+
|
|
100
85
|
return new ReadableStream({
|
|
101
86
|
async start(controller) {
|
|
102
87
|
try {
|
|
103
88
|
const root = buildSegment(node, context, '');
|
|
104
89
|
await resolveBlocking(root);
|
|
90
|
+
|
|
105
91
|
const html = serializeSegment(root);
|
|
106
92
|
const finalHtml = finalizeHtml(html, context);
|
|
107
|
-
controller.enqueue(
|
|
93
|
+
controller.enqueue(encodeUtf8(finalHtml));
|
|
94
|
+
|
|
108
95
|
// stream pending suspense boundaries as they resolve
|
|
109
96
|
if (context.pendingSuspense.length > 0) {
|
|
110
|
-
await streamPendingSuspense(context, controller
|
|
97
|
+
await streamPendingSuspense(context, controller);
|
|
111
98
|
}
|
|
99
|
+
|
|
112
100
|
controller.close();
|
|
113
101
|
} catch (error) {
|
|
114
102
|
onError(error);
|
|
@@ -127,13 +115,17 @@ export function renderToStream(node: JSXNode, options?: RenderOptions): Readable
|
|
|
127
115
|
export async function renderToString(node: JSXNode, options?: RenderOptions): Promise<string> {
|
|
128
116
|
const stream = renderToStream(node, options);
|
|
129
117
|
const reader = stream.getReader();
|
|
130
|
-
|
|
118
|
+
|
|
131
119
|
let html = '';
|
|
132
120
|
while (true) {
|
|
133
121
|
const { done, value } = await reader.read();
|
|
134
|
-
if (done)
|
|
135
|
-
|
|
122
|
+
if (done) {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
html += decodeUtf8From(value);
|
|
136
127
|
}
|
|
128
|
+
|
|
137
129
|
return html;
|
|
138
130
|
}
|
|
139
131
|
|
|
@@ -165,7 +157,16 @@ function isHeadElement(tag: string): boolean {
|
|
|
165
157
|
return HEAD_ELEMENTS.has(tag);
|
|
166
158
|
}
|
|
167
159
|
|
|
168
|
-
function buildSegment(node: JSXNode,
|
|
160
|
+
function buildSegment(node: JSXNode, ctx: RenderContext, path: string): Segment {
|
|
161
|
+
const prev = setActiveRenderContext(ctx);
|
|
162
|
+
try {
|
|
163
|
+
return buildSegmentInner(node, ctx, path);
|
|
164
|
+
} finally {
|
|
165
|
+
setActiveRenderContext(prev);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function buildSegmentInner(node: JSXNode, context: RenderContext, path: string): Segment {
|
|
169
170
|
// primitives
|
|
170
171
|
if (typeof node === 'string' || typeof node === 'number' || typeof node === 'bigint') {
|
|
171
172
|
return staticSeg(escapeHtml(node, false));
|
|
@@ -177,7 +178,7 @@ function buildSegment(node: JSXNode, context: RenderContext, path: string): Segm
|
|
|
177
178
|
if (typeof node === 'object' && Symbol.iterator in node) {
|
|
178
179
|
const parts: Segment[] = [];
|
|
179
180
|
for (const child of node) {
|
|
180
|
-
parts.push(
|
|
181
|
+
parts.push(buildSegmentInner(child, context, path));
|
|
181
182
|
}
|
|
182
183
|
return compositeSeg(parts);
|
|
183
184
|
}
|
|
@@ -187,7 +188,7 @@ function buildSegment(node: JSXNode, context: RenderContext, path: string): Segm
|
|
|
187
188
|
// Fragment
|
|
188
189
|
if (type === Fragment) {
|
|
189
190
|
const children = (props as { children?: JSXNode }).children;
|
|
190
|
-
return children != null ?
|
|
191
|
+
return children != null ? buildSegmentInner(children, context, path) : EMPTY_SEGMENT;
|
|
191
192
|
}
|
|
192
193
|
// intrinsic elements (HTML tags)
|
|
193
194
|
if (typeof type === 'string') {
|
|
@@ -212,6 +213,10 @@ function buildSegment(node: JSXNode, context: RenderContext, path: string): Segm
|
|
|
212
213
|
if (type === Suspense) {
|
|
213
214
|
return buildSuspenseSegment(props as unknown as SuspenseProps, context, path);
|
|
214
215
|
}
|
|
216
|
+
// ErrorBoundary
|
|
217
|
+
if (type === ErrorBoundary) {
|
|
218
|
+
return buildErrorBoundarySegment(props as unknown as ErrorBoundaryProps, context, path);
|
|
219
|
+
}
|
|
215
220
|
return buildComponentSegment(type, props as Record<string, unknown>, context, path);
|
|
216
221
|
}
|
|
217
222
|
}
|
|
@@ -270,18 +275,65 @@ function buildHeadElementSegment(
|
|
|
270
275
|
function renderAttributes(props: Record<string, unknown>): string {
|
|
271
276
|
let attrs = '';
|
|
272
277
|
for (const key in props) {
|
|
273
|
-
if (FRAMEWORK_PROPS.has(key))
|
|
278
|
+
if (FRAMEWORK_PROPS.has(key)) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
274
282
|
const value = props[key];
|
|
275
|
-
if (value === undefined || value === null || value === false)
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
283
|
+
if (value === undefined || value === null || value === false) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (typeof value === 'function') {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (key === 'class') {
|
|
292
|
+
if (!Array.isArray(value)) {
|
|
293
|
+
attrs = ` class="${escapeHtml(value, true)}"`;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const len = value.length;
|
|
298
|
+
|
|
299
|
+
let idx = 0;
|
|
300
|
+
let str = '';
|
|
301
|
+
let val: any;
|
|
302
|
+
|
|
303
|
+
for (; idx < len; idx++) {
|
|
304
|
+
if ((val = value[idx])) {
|
|
305
|
+
str = str ? str + ' ' + val : val;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (str) {
|
|
310
|
+
attrs += ` class="${escapeHtml(str, true)}"`;
|
|
282
311
|
}
|
|
283
312
|
continue;
|
|
284
313
|
}
|
|
314
|
+
|
|
315
|
+
if (key === 'style') {
|
|
316
|
+
if (typeof value !== 'object') {
|
|
317
|
+
attrs += ` style="${escapeHtml(value, true)}"`;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let str = '';
|
|
322
|
+
let val;
|
|
323
|
+
|
|
324
|
+
for (const key in value) {
|
|
325
|
+
if ((val = (value as any)[key]) != null) {
|
|
326
|
+
str = str ? str + '; ' + key + ':' + val : key + ':' + val;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (str) {
|
|
331
|
+
attrs += ` style="${escapeHtml(str, true)}"`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
285
337
|
if (value === true) {
|
|
286
338
|
attrs += ` ${key}`;
|
|
287
339
|
} else {
|
|
@@ -291,15 +343,6 @@ function renderAttributes(props: Record<string, unknown>): string {
|
|
|
291
343
|
return attrs;
|
|
292
344
|
}
|
|
293
345
|
|
|
294
|
-
function serializeStyle(style: Record<string, string | number>): string {
|
|
295
|
-
const parts: string[] = [];
|
|
296
|
-
for (const [key, value] of Object.entries(style)) {
|
|
297
|
-
if (value == null) continue;
|
|
298
|
-
parts.push(`${key}:${value}`);
|
|
299
|
-
}
|
|
300
|
-
return parts.join(';');
|
|
301
|
-
}
|
|
302
|
-
|
|
303
346
|
// #endregion
|
|
304
347
|
|
|
305
348
|
// #region Component building
|
|
@@ -313,10 +356,9 @@ function buildComponentSegment(
|
|
|
313
356
|
// call component
|
|
314
357
|
const result = type(props);
|
|
315
358
|
// if component called provide(), push frame before rendering children
|
|
316
|
-
const hadFrame =
|
|
317
|
-
pushContextFrame();
|
|
359
|
+
const hadFrame = pushContextFrame();
|
|
318
360
|
try {
|
|
319
|
-
return
|
|
361
|
+
return buildSegmentInner(result, ctx, path);
|
|
320
362
|
} finally {
|
|
321
363
|
popContextFrame(hadFrame);
|
|
322
364
|
}
|
|
@@ -348,18 +390,38 @@ function buildSuspenseSegment(props: SuspenseProps, ctx: RenderContext, path: st
|
|
|
348
390
|
content: null,
|
|
349
391
|
};
|
|
350
392
|
|
|
393
|
+
// snapshot context stack for async re-render (parent frames will be popped
|
|
394
|
+
// by the time the promise resolves)
|
|
395
|
+
const asyncCtx: RenderContext = {
|
|
396
|
+
...ctx,
|
|
397
|
+
contextStack: [...ctx.contextStack],
|
|
398
|
+
currentFrame: null,
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// re-render function that handles subsequent promise throws
|
|
402
|
+
const rerender = (attempt: number): Promise<void> | void => {
|
|
403
|
+
if (attempt >= MAX_SUSPENSE_ATTEMPTS) {
|
|
404
|
+
throw new Error(`suspense boundary exceeded maximum retry attempts (${MAX_SUSPENSE_ATTEMPTS})`);
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
seg.content = buildSegment(props.children, asyncCtx, suspenseId);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
if (err instanceof Promise) {
|
|
410
|
+
// component threw another promise - wait and retry
|
|
411
|
+
return err.then(() => rerender(attempt + 1));
|
|
412
|
+
}
|
|
413
|
+
throw err;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
351
417
|
// set up promise to re-render children when resolved
|
|
352
|
-
const pending = thrown.then(() =>
|
|
353
|
-
// re-render children after promise resolves
|
|
354
|
-
seg.content = buildSegment(props.children, ctx, suspenseId);
|
|
355
|
-
});
|
|
418
|
+
const pending = thrown.then(() => rerender(1));
|
|
356
419
|
seg.pending = pending;
|
|
357
420
|
|
|
358
421
|
// track for streaming
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
});
|
|
422
|
+
const tracked = pending.then(() => seg.content!);
|
|
423
|
+
tracked.catch(() => {}); // prevent unhandled rejection if resolveBlocking catches first
|
|
424
|
+
ctx.pendingSuspense.push({ id: suspenseId, promise: tracked });
|
|
363
425
|
|
|
364
426
|
return seg;
|
|
365
427
|
}
|
|
@@ -368,11 +430,38 @@ function buildSuspenseSegment(props: SuspenseProps, ctx: RenderContext, path: st
|
|
|
368
430
|
}
|
|
369
431
|
}
|
|
370
432
|
|
|
433
|
+
function buildErrorBoundarySegment(props: ErrorBoundaryProps, ctx: RenderContext, path: string): Segment {
|
|
434
|
+
// snapshot context for potential async fallback rendering
|
|
435
|
+
const asyncCtx: RenderContext = {
|
|
436
|
+
...ctx,
|
|
437
|
+
contextStack: [...ctx.contextStack],
|
|
438
|
+
currentFrame: null,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
const children = buildSegment(props.children, ctx, path);
|
|
443
|
+
return {
|
|
444
|
+
kind: 'error-boundary',
|
|
445
|
+
children,
|
|
446
|
+
fallbackFn: props.fallback,
|
|
447
|
+
renderContext: asyncCtx,
|
|
448
|
+
path,
|
|
449
|
+
fallbackSegment: null,
|
|
450
|
+
};
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (error instanceof Promise) {
|
|
453
|
+
throw error; // let Suspense handle it
|
|
454
|
+
}
|
|
455
|
+
// sync error - render fallback immediately
|
|
456
|
+
return buildSegment(props.fallback(error), ctx, path);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
371
460
|
// #endregion
|
|
372
461
|
|
|
373
462
|
// #region Serialization
|
|
374
463
|
|
|
375
|
-
/** resolve all blocking suspense boundaries */
|
|
464
|
+
/** resolve all blocking suspense boundaries and error boundaries */
|
|
376
465
|
async function resolveBlocking(segment: Segment): Promise<void> {
|
|
377
466
|
if (segment.kind === 'suspense') {
|
|
378
467
|
if (segment.pending) {
|
|
@@ -384,6 +473,14 @@ async function resolveBlocking(segment: Segment): Promise<void> {
|
|
|
384
473
|
}
|
|
385
474
|
return;
|
|
386
475
|
}
|
|
476
|
+
if (segment.kind === 'error-boundary') {
|
|
477
|
+
try {
|
|
478
|
+
await resolveBlocking(segment.children);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
segment.fallbackSegment = buildSegment(segment.fallbackFn(error), segment.renderContext, segment.path);
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
387
484
|
if (segment.kind === 'composite') {
|
|
388
485
|
for (const part of segment.parts) {
|
|
389
486
|
await resolveBlocking(part);
|
|
@@ -399,6 +496,9 @@ function serializeSegment(seg: Segment): string {
|
|
|
399
496
|
if (seg.kind === 'composite') {
|
|
400
497
|
return seg.parts.map(serializeSegment).join('');
|
|
401
498
|
}
|
|
499
|
+
if (seg.kind === 'error-boundary') {
|
|
500
|
+
return serializeSegment(seg.fallbackSegment ?? seg.children);
|
|
501
|
+
}
|
|
402
502
|
// suspense - always render fallback; resolved content streams in template
|
|
403
503
|
return `<!--$s:${seg.id}-->${serializeSegment(seg.fallback)}<!--/$s:${seg.id}-->`;
|
|
404
504
|
}
|
|
@@ -415,27 +515,34 @@ const SUSPENSE_RUNTIME = `<script>${SR}=(t,i,s,e)=>{i="$s:"+t.dataset.suspense;s
|
|
|
415
515
|
async function streamPendingSuspense(
|
|
416
516
|
context: RenderContext,
|
|
417
517
|
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
418
|
-
encoder: TextEncoder,
|
|
419
518
|
): Promise<void> {
|
|
420
|
-
|
|
421
|
-
|
|
519
|
+
controller.enqueue(encodeUtf8(SUSPENSE_RUNTIME));
|
|
520
|
+
|
|
422
521
|
while (true) {
|
|
423
|
-
const batch = context.pendingSuspense
|
|
424
|
-
if (batch.length === 0)
|
|
522
|
+
const batch = context.pendingSuspense;
|
|
523
|
+
if (batch.length === 0) {
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
context.pendingSuspense = [];
|
|
527
|
+
|
|
425
528
|
await Promise.all(
|
|
426
529
|
batch.map(async ({ id, promise }) => {
|
|
427
|
-
|
|
530
|
+
let resolvedSegment: Segment;
|
|
531
|
+
try {
|
|
532
|
+
resolvedSegment = await promise;
|
|
533
|
+
} catch {
|
|
534
|
+
// promise rejected - error was caught by an error boundary
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
428
538
|
try {
|
|
429
|
-
const resolvedSegment = await promise;
|
|
430
539
|
await resolveBlocking(resolvedSegment);
|
|
540
|
+
|
|
431
541
|
const html = serializeSegment(resolvedSegment);
|
|
432
|
-
|
|
433
|
-
const runtime = runtimeInjected ? '' : SUSPENSE_RUNTIME;
|
|
434
|
-
runtimeInjected = true;
|
|
435
|
-
// stream template + call to swap function
|
|
542
|
+
|
|
436
543
|
controller.enqueue(
|
|
437
|
-
|
|
438
|
-
|
|
544
|
+
encodeUtf8(
|
|
545
|
+
`<template data-suspense="${id}">${html}</template>` +
|
|
439
546
|
`<script>${SR}(document.currentScript.previousElementSibling)</script>`,
|
|
440
547
|
),
|
|
441
548
|
);
|