@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/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 { currentFrame, popContextFrame, pushContextFrame } from './context.ts';
16
- import { Suspense, type SuspenseProps } from './suspense.ts';
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 types
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(encoder.encode(finalHtml));
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, encoder);
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
- const decoder = new TextDecoder();
118
+
131
119
  let html = '';
132
120
  while (true) {
133
121
  const { done, value } = await reader.read();
134
- if (done) break;
135
- html += decoder.decode(value);
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, context: RenderContext, path: string): Segment {
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(buildSegment(child, context, path));
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 ? buildSegment(children, context, path) : EMPTY_SEGMENT;
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)) continue;
278
+ if (FRAMEWORK_PROPS.has(key)) {
279
+ continue;
280
+ }
281
+
274
282
  const value = props[key];
275
- if (value === undefined || value === null || value === false) continue;
276
- if (typeof value === 'function') continue;
277
- // style object -> string
278
- if (key === 'style' && typeof value === 'object') {
279
- const styleStr = serializeStyle(value as Record<string, string | number>);
280
- if (styleStr) {
281
- attrs += ` style="${escapeHtml(styleStr, true)}"`;
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 = currentFrame !== null;
317
- pushContextFrame();
359
+ const hadFrame = pushContextFrame();
318
360
  try {
319
- return buildSegment(result, ctx, path);
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
- ctx.pendingSuspense.push({
360
- id: suspenseId,
361
- promise: pending.then(() => seg.content!),
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
- let runtimeInjected = false;
421
- const processed = new Set<string>();
519
+ controller.enqueue(encodeUtf8(SUSPENSE_RUNTIME));
520
+
422
521
  while (true) {
423
- const batch = context.pendingSuspense.filter(({ id }) => !processed.has(id));
424
- if (batch.length === 0) break;
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
- processed.add(id);
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
- // inject runtime once before first resolution
433
- const runtime = runtimeInjected ? '' : SUSPENSE_RUNTIME;
434
- runtimeInjected = true;
435
- // stream template + call to swap function
542
+
436
543
  controller.enqueue(
437
- encoder.encode(
438
- `${runtime}<template data-suspense="${id}">${html}</template>` +
544
+ encodeUtf8(
545
+ `<template data-suspense="${id}">${html}</template>` +
439
546
  `<script>${SR}(document.currentScript.previousElementSibling)</script>`,
440
547
  ),
441
548
  );