@oomfware/jsx 0.1.3 → 0.1.5
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 +143 -58
- package/dist/index.mjs.map +1 -1
- package/dist/jsx-dev-runtime.d.mts +2 -0
- package/dist/jsx-dev-runtime.mjs +3 -0
- package/dist/{jsx-runtime-DEmZCKgt.d.mts → jsx-runtime-CcUWZKzW.d.mts} +2 -16
- package/dist/{jsx-runtime-DEmZCKgt.d.mts.map → jsx-runtime-CcUWZKzW.d.mts.map} +1 -1
- package/dist/jsx-runtime.d.mts +2 -2
- package/dist/jsx-runtime.mjs +1 -15
- package/dist/jsx-runtime.mjs.map +1 -1
- package/package.json +5 -1
- package/src/index.ts +9 -2
- package/src/jsx-dev-runtime.ts +2 -0
- package/src/jsx-runtime.ts +1 -15
- package/src/lib/context.ts +1 -50
- package/src/lib/intrinsic-elements.ts +2 -2
- package/src/lib/render-context.ts +115 -0
- package/src/lib/render.ts +142 -63
- package/src/lib/stream.test.tsx +407 -1
- package/src/lib/suspense.ts +15 -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
|
+
type RenderContext,
|
|
21
|
+
type Segment,
|
|
22
|
+
setActiveRenderContext,
|
|
23
|
+
} from './render-context.ts';
|
|
24
|
+
import { ErrorBoundary, type ErrorBoundaryProps, Suspense, 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
|
}
|
|
@@ -313,10 +318,9 @@ function buildComponentSegment(
|
|
|
313
318
|
// call component
|
|
314
319
|
const result = type(props);
|
|
315
320
|
// if component called provide(), push frame before rendering children
|
|
316
|
-
const hadFrame =
|
|
317
|
-
pushContextFrame();
|
|
321
|
+
const hadFrame = pushContextFrame();
|
|
318
322
|
try {
|
|
319
|
-
return
|
|
323
|
+
return buildSegmentInner(result, ctx, path);
|
|
320
324
|
} finally {
|
|
321
325
|
popContextFrame(hadFrame);
|
|
322
326
|
}
|
|
@@ -348,18 +352,40 @@ function buildSuspenseSegment(props: SuspenseProps, ctx: RenderContext, path: st
|
|
|
348
352
|
content: null,
|
|
349
353
|
};
|
|
350
354
|
|
|
355
|
+
// snapshot context stack for async re-render (parent frames will be popped
|
|
356
|
+
// by the time the promise resolves)
|
|
357
|
+
const asyncCtx: RenderContext = {
|
|
358
|
+
...ctx,
|
|
359
|
+
contextStack: [...ctx.contextStack],
|
|
360
|
+
currentFrame: null,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// re-render function that handles subsequent promise throws
|
|
364
|
+
const rerender = (attempt: number): Promise<void> | void => {
|
|
365
|
+
if (attempt >= MAX_SUSPENSE_ATTEMPTS) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`suspense boundary exceeded maximum retry attempts (${MAX_SUSPENSE_ATTEMPTS})`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
seg.content = buildSegment(props.children, asyncCtx, suspenseId);
|
|
372
|
+
} catch (err) {
|
|
373
|
+
if (err instanceof Promise) {
|
|
374
|
+
// component threw another promise - wait and retry
|
|
375
|
+
return err.then(() => rerender(attempt + 1));
|
|
376
|
+
}
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
351
381
|
// 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
|
-
});
|
|
382
|
+
const pending = thrown.then(() => rerender(1));
|
|
356
383
|
seg.pending = pending;
|
|
357
384
|
|
|
358
385
|
// track for streaming
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
});
|
|
386
|
+
const tracked = pending.then(() => seg.content!);
|
|
387
|
+
tracked.catch(() => {}); // prevent unhandled rejection if resolveBlocking catches first
|
|
388
|
+
ctx.pendingSuspense.push({ id: suspenseId, promise: tracked });
|
|
363
389
|
|
|
364
390
|
return seg;
|
|
365
391
|
}
|
|
@@ -368,11 +394,42 @@ function buildSuspenseSegment(props: SuspenseProps, ctx: RenderContext, path: st
|
|
|
368
394
|
}
|
|
369
395
|
}
|
|
370
396
|
|
|
397
|
+
function buildErrorBoundarySegment(
|
|
398
|
+
props: ErrorBoundaryProps,
|
|
399
|
+
ctx: RenderContext,
|
|
400
|
+
path: string,
|
|
401
|
+
): Segment {
|
|
402
|
+
// snapshot context for potential async fallback rendering
|
|
403
|
+
const asyncCtx: RenderContext = {
|
|
404
|
+
...ctx,
|
|
405
|
+
contextStack: [...ctx.contextStack],
|
|
406
|
+
currentFrame: null,
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const children = buildSegment(props.children, ctx, path);
|
|
411
|
+
return {
|
|
412
|
+
kind: 'error-boundary',
|
|
413
|
+
children,
|
|
414
|
+
fallbackFn: props.fallback,
|
|
415
|
+
renderContext: asyncCtx,
|
|
416
|
+
path,
|
|
417
|
+
fallbackSegment: null,
|
|
418
|
+
};
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (error instanceof Promise) {
|
|
421
|
+
throw error; // let Suspense handle it
|
|
422
|
+
}
|
|
423
|
+
// sync error - render fallback immediately
|
|
424
|
+
return buildSegment(props.fallback(error), ctx, path);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
371
428
|
// #endregion
|
|
372
429
|
|
|
373
430
|
// #region Serialization
|
|
374
431
|
|
|
375
|
-
/** resolve all blocking suspense boundaries */
|
|
432
|
+
/** resolve all blocking suspense boundaries and error boundaries */
|
|
376
433
|
async function resolveBlocking(segment: Segment): Promise<void> {
|
|
377
434
|
if (segment.kind === 'suspense') {
|
|
378
435
|
if (segment.pending) {
|
|
@@ -384,6 +441,18 @@ async function resolveBlocking(segment: Segment): Promise<void> {
|
|
|
384
441
|
}
|
|
385
442
|
return;
|
|
386
443
|
}
|
|
444
|
+
if (segment.kind === 'error-boundary') {
|
|
445
|
+
try {
|
|
446
|
+
await resolveBlocking(segment.children);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
segment.fallbackSegment = buildSegment(
|
|
449
|
+
segment.fallbackFn(error),
|
|
450
|
+
segment.renderContext,
|
|
451
|
+
segment.path,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
387
456
|
if (segment.kind === 'composite') {
|
|
388
457
|
for (const part of segment.parts) {
|
|
389
458
|
await resolveBlocking(part);
|
|
@@ -399,6 +468,9 @@ function serializeSegment(seg: Segment): string {
|
|
|
399
468
|
if (seg.kind === 'composite') {
|
|
400
469
|
return seg.parts.map(serializeSegment).join('');
|
|
401
470
|
}
|
|
471
|
+
if (seg.kind === 'error-boundary') {
|
|
472
|
+
return serializeSegment(seg.fallbackSegment ?? seg.children);
|
|
473
|
+
}
|
|
402
474
|
// suspense - always render fallback; resolved content streams in template
|
|
403
475
|
return `<!--$s:${seg.id}-->${serializeSegment(seg.fallback)}<!--/$s:${seg.id}-->`;
|
|
404
476
|
}
|
|
@@ -415,27 +487,34 @@ const SUSPENSE_RUNTIME = `<script>${SR}=(t,i,s,e)=>{i="$s:"+t.dataset.suspense;s
|
|
|
415
487
|
async function streamPendingSuspense(
|
|
416
488
|
context: RenderContext,
|
|
417
489
|
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
418
|
-
encoder: TextEncoder,
|
|
419
490
|
): Promise<void> {
|
|
420
|
-
|
|
421
|
-
|
|
491
|
+
controller.enqueue(encodeUtf8(SUSPENSE_RUNTIME));
|
|
492
|
+
|
|
422
493
|
while (true) {
|
|
423
|
-
const batch = context.pendingSuspense
|
|
424
|
-
if (batch.length === 0)
|
|
494
|
+
const batch = context.pendingSuspense;
|
|
495
|
+
if (batch.length === 0) {
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
context.pendingSuspense = [];
|
|
499
|
+
|
|
425
500
|
await Promise.all(
|
|
426
501
|
batch.map(async ({ id, promise }) => {
|
|
427
|
-
|
|
502
|
+
let resolvedSegment: Segment;
|
|
503
|
+
try {
|
|
504
|
+
resolvedSegment = await promise;
|
|
505
|
+
} catch {
|
|
506
|
+
// promise rejected - error was caught by an error boundary
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
428
510
|
try {
|
|
429
|
-
const resolvedSegment = await promise;
|
|
430
511
|
await resolveBlocking(resolvedSegment);
|
|
512
|
+
|
|
431
513
|
const html = serializeSegment(resolvedSegment);
|
|
432
|
-
|
|
433
|
-
const runtime = runtimeInjected ? '' : SUSPENSE_RUNTIME;
|
|
434
|
-
runtimeInjected = true;
|
|
435
|
-
// stream template + call to swap function
|
|
514
|
+
|
|
436
515
|
controller.enqueue(
|
|
437
|
-
|
|
438
|
-
|
|
516
|
+
encodeUtf8(
|
|
517
|
+
`<template data-suspense="${id}">${html}</template>` +
|
|
439
518
|
`<script>${SR}(document.currentScript.previousElementSibling)</script>`,
|
|
440
519
|
),
|
|
441
520
|
);
|