hadars 0.1.40 → 0.2.0
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/README.md +1 -55
- package/dist/{chunk-2ENP7IAW.js → chunk-LY5MTHFV.js} +360 -203
- package/dist/cli.js +424 -262
- package/dist/lambda.cjs +387 -229
- package/dist/lambda.js +63 -45
- package/dist/slim-react/index.cjs +361 -203
- package/dist/slim-react/index.d.cts +24 -8
- package/dist/slim-react/index.d.ts +24 -8
- package/dist/slim-react/index.js +3 -1
- package/dist/ssr-render-worker.js +349 -219
- package/package.json +1 -1
- package/src/build.ts +7 -6
- package/src/lambda.ts +6 -2
- package/src/slim-react/context.ts +2 -1
- package/src/slim-react/index.ts +21 -18
- package/src/slim-react/render.ts +379 -240
- package/src/slim-react/renderContext.ts +105 -45
- package/src/ssr-render-worker.ts +10 -43
- package/src/utils/cookies.ts +1 -1
- package/src/utils/response.tsx +62 -33
- package/src/utils/serve.ts +29 -27
- package/src/utils/ssrHandler.ts +54 -25
- package/src/utils/staticFile.ts +2 -7
package/src/slim-react/render.ts
CHANGED
|
@@ -36,7 +36,8 @@ import {
|
|
|
36
36
|
getContextValue,
|
|
37
37
|
swapContextMap,
|
|
38
38
|
captureMap,
|
|
39
|
-
|
|
39
|
+
captureUnsuspend,
|
|
40
|
+
restoreUnsuspend,
|
|
40
41
|
} from "./renderContext";
|
|
41
42
|
import { installDispatcher, restoreDispatcher } from "./dispatcher";
|
|
42
43
|
|
|
@@ -62,22 +63,58 @@ const VOID_ELEMENTS = new Set([
|
|
|
62
63
|
]);
|
|
63
64
|
|
|
64
65
|
const HTML_ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', "'": ''' };
|
|
66
|
+
const HTML_ESC_RE = /[&<>']/;
|
|
65
67
|
function escapeHtml(str: string): string {
|
|
68
|
+
// Fast path: avoid regex replace + callback allocation when there's nothing to escape.
|
|
69
|
+
if (!HTML_ESC_RE.test(str)) return str;
|
|
66
70
|
return str.replace(/[&<>']/g, c => HTML_ESC[c]!);
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
const ATTR_ESC: Record<string, string> = { '&': '&', '"': '"', '<': '<', '>': '>' };
|
|
74
|
+
const ATTR_ESC_RE = /[&"<>]/;
|
|
70
75
|
function escapeAttr(str: string): string {
|
|
76
|
+
if (!ATTR_ESC_RE.test(str)) return str;
|
|
71
77
|
return str.replace(/[&"<>]/g, c => ATTR_ESC[c]!);
|
|
72
78
|
}
|
|
73
79
|
|
|
80
|
+
/**
|
|
81
|
+
* CSS properties that accept plain numbers without a `px` suffix.
|
|
82
|
+
* Matches React's internal unitless-number list so SSR output agrees with
|
|
83
|
+
* client-side React during hydration.
|
|
84
|
+
*/
|
|
85
|
+
const UNITLESS_CSS = new Set([
|
|
86
|
+
'animationIterationCount', 'aspectRatio', 'borderImageOutset', 'borderImageSlice',
|
|
87
|
+
'borderImageWidth', 'boxFlex', 'boxFlexGroup', 'boxOrdinalGroup', 'columnCount',
|
|
88
|
+
'columns', 'flex', 'flexGrow', 'flexPositive', 'flexShrink', 'flexNegative',
|
|
89
|
+
'flexOrder', 'gridArea', 'gridRow', 'gridRowEnd', 'gridRowSpan', 'gridRowStart',
|
|
90
|
+
'gridColumn', 'gridColumnEnd', 'gridColumnSpan', 'gridColumnStart', 'fontWeight',
|
|
91
|
+
'lineClamp', 'lineHeight', 'opacity', 'order', 'orphans', 'scale', 'tabSize',
|
|
92
|
+
'widows', 'zIndex', 'zoom', 'fillOpacity', 'floodOpacity', 'stopOpacity',
|
|
93
|
+
'strokeDasharray', 'strokeDashoffset', 'strokeMiterlimit', 'strokeOpacity',
|
|
94
|
+
'strokeWidth',
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/** Intern camelCase → kebab-case CSS property name conversions. */
|
|
98
|
+
const _cssKeyCache = new Map<string, string>();
|
|
74
99
|
function styleObjectToString(style: Record<string, any>): string {
|
|
75
100
|
let result = '';
|
|
76
101
|
for (const key in style) {
|
|
102
|
+
const value = style[key];
|
|
103
|
+
// Skip null, undefined and boolean values (React behaviour).
|
|
104
|
+
if (value == null || typeof value === 'boolean') continue;
|
|
77
105
|
if (result) result += ';';
|
|
78
|
-
// camelCase → kebab-case
|
|
79
|
-
|
|
80
|
-
|
|
106
|
+
// camelCase → kebab-case, cached to avoid repeated regex per render.
|
|
107
|
+
let cssKey = _cssKeyCache.get(key);
|
|
108
|
+
if (cssKey === undefined) {
|
|
109
|
+
cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
110
|
+
_cssKeyCache.set(key, cssKey);
|
|
111
|
+
}
|
|
112
|
+
// Append 'px' for numeric values on non-unitless properties (React behaviour).
|
|
113
|
+
if (typeof value === 'number' && value !== 0 && !UNITLESS_CSS.has(key)) {
|
|
114
|
+
result += cssKey + ':' + value + 'px';
|
|
115
|
+
} else {
|
|
116
|
+
result += cssKey + ':' + value;
|
|
117
|
+
}
|
|
81
118
|
}
|
|
82
119
|
return result;
|
|
83
120
|
}
|
|
@@ -224,39 +261,41 @@ const SVG_ATTR_MAP: Record<string, string> = {
|
|
|
224
261
|
yChannelSelector: "yChannelSelector",
|
|
225
262
|
};
|
|
226
263
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
"
|
|
237
|
-
"
|
|
238
|
-
"
|
|
239
|
-
"
|
|
240
|
-
"text", "textPath", "title", "tspan", "use", "view",
|
|
264
|
+
// Pre-allocated skip-sets for special host elements that strip certain props
|
|
265
|
+
// before delegating to writeAttributes. Module-level so they are created once.
|
|
266
|
+
const TEXTAREA_SKIP_PROPS = new Set(["value", "defaultValue", "children"]);
|
|
267
|
+
const SELECT_SKIP_PROPS = new Set(["value", "defaultValue"]);
|
|
268
|
+
|
|
269
|
+
// Internal React props that must never be serialised as HTML attributes.
|
|
270
|
+
// A Set lookup (one hash probe) replaces six sequential string comparisons
|
|
271
|
+
// for every attribute on every element — the hottest path in the renderer.
|
|
272
|
+
const INTERNAL_PROPS = new Set([
|
|
273
|
+
"children", "key", "ref",
|
|
274
|
+
"dangerouslySetInnerHTML",
|
|
275
|
+
"suppressHydrationWarning",
|
|
276
|
+
"suppressContentEditableWarning",
|
|
241
277
|
]);
|
|
242
278
|
|
|
243
|
-
|
|
244
|
-
|
|
279
|
+
/**
|
|
280
|
+
* Write element attributes directly into the writer, skipping the
|
|
281
|
+
* intermediate `attrs` string that `renderAttributes` used to return.
|
|
282
|
+
* Eliminates one heap string allocation per element.
|
|
283
|
+
*
|
|
284
|
+
* @param skip - Optional set of prop names to exclude (used by textarea/select).
|
|
285
|
+
*/
|
|
286
|
+
function writeAttributes(writer: Writer, props: Record<string, any>, isSvg: boolean, skip?: ReadonlySet<string>): void {
|
|
245
287
|
for (const key in props) {
|
|
288
|
+
if (skip !== undefined && skip.has(key)) continue;
|
|
246
289
|
const value = props[key];
|
|
247
|
-
// Skip internal / non-attribute props
|
|
290
|
+
// Skip internal / non-attribute props — one hash probe replaces 6 comparisons.
|
|
291
|
+
if (INTERNAL_PROPS.has(key)) continue;
|
|
292
|
+
// Skip event handlers (onClick, onChange, …) — use charCodeAt for speed.
|
|
248
293
|
if (
|
|
249
|
-
key
|
|
250
|
-
key ===
|
|
251
|
-
key ===
|
|
252
|
-
key
|
|
253
|
-
|
|
254
|
-
key === "suppressContentEditableWarning"
|
|
255
|
-
)
|
|
256
|
-
continue;
|
|
257
|
-
// Skip event handlers (onClick, onChange, …)
|
|
258
|
-
if (key.startsWith("on") && key.length > 2 && key[2] === key[2]!.toUpperCase())
|
|
259
|
-
continue;
|
|
294
|
+
key.length > 2 &&
|
|
295
|
+
key.charCodeAt(0) === 111 /*o*/ &&
|
|
296
|
+
key.charCodeAt(1) === 110 /*n*/ &&
|
|
297
|
+
key.charCodeAt(2) >= 65 && key.charCodeAt(2) <= 90 /*A-Z*/
|
|
298
|
+
) continue;
|
|
260
299
|
|
|
261
300
|
// Prop-name mapping
|
|
262
301
|
let attrName: string;
|
|
@@ -264,45 +303,37 @@ function renderAttributes(props: Record<string, any>, isSvg: boolean): string {
|
|
|
264
303
|
attrName = SVG_ATTR_MAP[key]!;
|
|
265
304
|
} else {
|
|
266
305
|
attrName =
|
|
267
|
-
key === "className"
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
: key === "defaultValue"
|
|
274
|
-
? "value"
|
|
275
|
-
: key === "defaultChecked"
|
|
276
|
-
? "checked"
|
|
277
|
-
: key;
|
|
306
|
+
key === "className" ? "class"
|
|
307
|
+
: key === "htmlFor" ? "for"
|
|
308
|
+
: key === "tabIndex" ? "tabindex"
|
|
309
|
+
: key === "defaultValue" ? "value"
|
|
310
|
+
: key === "defaultChecked" ? "checked"
|
|
311
|
+
: key;
|
|
278
312
|
}
|
|
279
313
|
|
|
280
314
|
if (value === false || value == null) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
attrs += ` ${attrName}="false"`;
|
|
315
|
+
if (value === false && (attrName.charCodeAt(0) === 97 /*a*/ && attrName.startsWith("aria-") ||
|
|
316
|
+
attrName.charCodeAt(0) === 100 /*d*/ && attrName.startsWith("data-"))) {
|
|
317
|
+
writer.write(` ${attrName}="false"`);
|
|
285
318
|
}
|
|
286
319
|
continue;
|
|
287
320
|
}
|
|
288
321
|
if (value === true) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
attrs += ` ${attrName}="true"`;
|
|
322
|
+
if (attrName.charCodeAt(0) === 97 /*a*/ && attrName.startsWith("aria-") ||
|
|
323
|
+
attrName.charCodeAt(0) === 100 /*d*/ && attrName.startsWith("data-")) {
|
|
324
|
+
writer.write(` ${attrName}="true"`);
|
|
293
325
|
} else {
|
|
294
|
-
|
|
326
|
+
writer.write(` ${attrName}=""`);
|
|
295
327
|
}
|
|
296
328
|
continue;
|
|
297
329
|
}
|
|
298
330
|
if (key === "style" && typeof value === "object") {
|
|
299
331
|
const styleStr = styleObjectToString(value);
|
|
300
|
-
if (styleStr)
|
|
332
|
+
if (styleStr) writer.write(` style="${escapeAttr(styleStr)}"`);
|
|
301
333
|
continue;
|
|
302
334
|
}
|
|
303
|
-
|
|
335
|
+
writer.write(` ${attrName}="${escapeAttr(typeof value === 'string' ? value : String(value))}"`);
|
|
304
336
|
}
|
|
305
|
-
return attrs;
|
|
306
337
|
}
|
|
307
338
|
|
|
308
339
|
// ---------------------------------------------------------------------------
|
|
@@ -316,22 +347,34 @@ interface Writer {
|
|
|
316
347
|
text(s: string): void;
|
|
317
348
|
/** True if the last thing written was a text node (not markup). */
|
|
318
349
|
lastWasText: boolean;
|
|
350
|
+
/**
|
|
351
|
+
* Optional: encode and flush any internal string buffer downstream.
|
|
352
|
+
* Called at natural streaming boundaries (Suspense completions, end of render).
|
|
353
|
+
* Writers that don't buffer (e.g. BufferWriter, NullWriter) leave this undefined.
|
|
354
|
+
*/
|
|
355
|
+
flush?(): void;
|
|
319
356
|
}
|
|
320
357
|
|
|
321
358
|
class BufferWriter implements Writer {
|
|
322
|
-
|
|
359
|
+
data = "";
|
|
323
360
|
lastWasText = false;
|
|
324
361
|
write(chunk: string) {
|
|
325
|
-
this.
|
|
362
|
+
this.data += chunk;
|
|
326
363
|
this.lastWasText = false;
|
|
327
364
|
}
|
|
328
365
|
text(s: string) {
|
|
329
|
-
this.
|
|
366
|
+
this.data += s;
|
|
330
367
|
this.lastWasText = true;
|
|
331
368
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
369
|
+
/** Flush accumulated output into a parent writer and reset. */
|
|
370
|
+
flushTo(target: Writer) {
|
|
371
|
+
if (!this.data) return; // nothing buffered — preserve target's lastWasText
|
|
372
|
+
// Single write call — the entire buffered string in one shot.
|
|
373
|
+
if (target instanceof BufferWriter) {
|
|
374
|
+
target.data += this.data;
|
|
375
|
+
} else {
|
|
376
|
+
target.write(this.data);
|
|
377
|
+
}
|
|
335
378
|
target.lastWasText = this.lastWasText;
|
|
336
379
|
}
|
|
337
380
|
}
|
|
@@ -369,27 +412,19 @@ function renderNode(
|
|
|
369
412
|
return renderChildArray(node, writer, isSvg);
|
|
370
413
|
}
|
|
371
414
|
|
|
415
|
+
// At this point node is guaranteed to be a non-null object — null/boolean/
|
|
416
|
+
// string/number/Array are all handled above. The iterable and $$typeof
|
|
417
|
+
// branches no longer need to re-test typeof/null.
|
|
418
|
+
const obj = node as any;
|
|
419
|
+
|
|
372
420
|
// --- iterables (Set, generator, …) ---
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
node !== null &&
|
|
376
|
-
Symbol.iterator in node &&
|
|
377
|
-
!("$$typeof" in node)
|
|
378
|
-
) {
|
|
379
|
-
return renderChildArray(
|
|
380
|
-
Array.from(node as Iterable<SlimNode>),
|
|
381
|
-
writer,
|
|
382
|
-
isSvg,
|
|
383
|
-
);
|
|
421
|
+
if (Symbol.iterator in obj && !("$$typeof" in obj)) {
|
|
422
|
+
return renderChildArray(Array.from(obj as Iterable<SlimNode>), writer, isSvg);
|
|
384
423
|
}
|
|
385
424
|
|
|
386
425
|
// --- SlimElement (accepts both the classic and React 19 transitional symbols) ---
|
|
387
|
-
if (
|
|
388
|
-
|
|
389
|
-
node !== null &&
|
|
390
|
-
"$$typeof" in node
|
|
391
|
-
) {
|
|
392
|
-
const elType = (node as any)["$$typeof"] as symbol;
|
|
426
|
+
if ("$$typeof" in obj) {
|
|
427
|
+
const elType = obj["$$typeof"] as symbol;
|
|
393
428
|
if (elType !== SLIM_ELEMENT && elType !== REACT19_ELEMENT) return;
|
|
394
429
|
const element = node as SlimElement;
|
|
395
430
|
const { type, props } = element;
|
|
@@ -404,6 +439,12 @@ function renderNode(
|
|
|
404
439
|
return renderSuspense(props, writer, isSvg);
|
|
405
440
|
}
|
|
406
441
|
|
|
442
|
+
// HTML / SVG element — most common; check string before function to
|
|
443
|
+
// hit the branch earlier for the majority of nodes.
|
|
444
|
+
if (typeof type === "string") {
|
|
445
|
+
return renderHostElement(type, props, writer, isSvg);
|
|
446
|
+
}
|
|
447
|
+
|
|
407
448
|
// Function / class component
|
|
408
449
|
if (typeof type === "function") {
|
|
409
450
|
return renderComponent(type, props, writer, isSvg);
|
|
@@ -415,11 +456,6 @@ function renderNode(
|
|
|
415
456
|
if (typeof type === "object" && type !== null) {
|
|
416
457
|
return renderComponent(type as unknown as Function, props, writer, isSvg);
|
|
417
458
|
}
|
|
418
|
-
|
|
419
|
-
// HTML / SVG element
|
|
420
|
-
if (typeof type === "string") {
|
|
421
|
-
return renderHostElement(type, props, writer, isSvg);
|
|
422
|
-
}
|
|
423
459
|
}
|
|
424
460
|
}
|
|
425
461
|
|
|
@@ -462,17 +498,14 @@ function renderHostElement(
|
|
|
462
498
|
writer: Writer,
|
|
463
499
|
isSvg: boolean,
|
|
464
500
|
): MaybePromise {
|
|
465
|
-
const
|
|
466
|
-
const childSvg = isSvg || enteringSvg;
|
|
501
|
+
const childSvg = isSvg || tag === "svg";
|
|
467
502
|
|
|
468
503
|
// ── <textarea> ────────────────────────────────────────────────────────────
|
|
469
504
|
if (tag === "textarea") {
|
|
470
505
|
const textContent = props.value ?? props.defaultValue ?? props.children ?? "";
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
writer.write(`<textarea${renderAttributes(filteredProps, false)}>`);
|
|
506
|
+
writer.write("<textarea");
|
|
507
|
+
writeAttributes(writer, props, false, TEXTAREA_SKIP_PROPS);
|
|
508
|
+
writer.write(">");
|
|
476
509
|
writer.text(escapeHtml(String(textContent)));
|
|
477
510
|
writer.write("</textarea>");
|
|
478
511
|
return;
|
|
@@ -483,11 +516,9 @@ function renderHostElement(
|
|
|
483
516
|
// matching <option> as `selected`.
|
|
484
517
|
if (tag === "select") {
|
|
485
518
|
const selectedValue = props.value ?? props.defaultValue;
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
writer.write(`<select${renderAttributes(filteredProps, false)}>`);
|
|
519
|
+
writer.write("<select");
|
|
520
|
+
writeAttributes(writer, props, false, SELECT_SKIP_PROPS);
|
|
521
|
+
writer.write(">");
|
|
491
522
|
// Normalise selectedValue to a Set of strings to handle both single values
|
|
492
523
|
// and arrays (multi-select with defaultValue={['a','b']}).
|
|
493
524
|
const selectedSet: Set<string> | null =
|
|
@@ -510,7 +541,8 @@ function renderHostElement(
|
|
|
510
541
|
|
|
511
542
|
// React 19 does not inject xmlns on <svg> — browsers handle SVG namespaces
|
|
512
543
|
// automatically for inline HTML5 SVG, so we match React's behaviour.
|
|
513
|
-
writer.write(`<${tag}
|
|
544
|
+
writer.write(`<${tag}`);
|
|
545
|
+
writeAttributes(writer, props, childSvg);
|
|
514
546
|
|
|
515
547
|
// Void elements are self-closing (matching React's output format).
|
|
516
548
|
if (VOID_ELEMENTS.has(tag)) {
|
|
@@ -542,14 +574,34 @@ const REACT_CONTEXT = Symbol.for("react.context"); // React 19: context IS
|
|
|
542
574
|
const REACT_CONSUMER = Symbol.for("react.consumer"); // React 19 Consumer object
|
|
543
575
|
const REACT_LAZY = Symbol.for("react.lazy"); // React.lazy()
|
|
544
576
|
|
|
577
|
+
// Sentinel thrown by renderComponent when a component exceeds its per-boundary
|
|
578
|
+
// suspension retry limit. Caught by renderSuspense to trigger fallback rendering.
|
|
579
|
+
// Using a unique object (not a subclass) keeps the check a fast reference equality.
|
|
580
|
+
const SUSPENSE_RETRY_LIMIT: unique symbol = Symbol("SuspenseRetryLimit");
|
|
581
|
+
const MAX_COMPONENT_SUSPENSE_RETRIES = 25;
|
|
582
|
+
|
|
583
|
+
/** React 19 `use()` protocol — patch a thrown promise with status tracking so
|
|
584
|
+
* that `use(promise)` can return the resolved value synchronously on retry. */
|
|
585
|
+
function patchPromiseStatus(p: Promise<unknown>): void {
|
|
586
|
+
const w = p as Promise<unknown> & { status?: string; value?: unknown; reason?: unknown };
|
|
587
|
+
if (w.status) return; // already tracked (e.g. React.lazy payload)
|
|
588
|
+
w.status = "pending";
|
|
589
|
+
w.then(
|
|
590
|
+
(v) => { w.status = "fulfilled"; w.value = v; },
|
|
591
|
+
(r) => { w.status = "rejected"; w.reason = r; },
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
545
595
|
/** Render a function or class component. */
|
|
546
596
|
function renderComponent(
|
|
547
597
|
type: Function,
|
|
548
598
|
props: Record<string, any>,
|
|
549
599
|
writer: Writer,
|
|
550
600
|
isSvg: boolean,
|
|
601
|
+
_suspenseRetries = 0,
|
|
551
602
|
): MaybePromise {
|
|
552
|
-
|
|
603
|
+
// type is always a defined Function — the optional chain is never needed.
|
|
604
|
+
const typeOf = (type as any).$$typeof;
|
|
553
605
|
|
|
554
606
|
// React.memo — unwrap and re-render the inner type
|
|
555
607
|
if (typeOf === REACT_MEMO) {
|
|
@@ -567,7 +619,22 @@ function renderComponent(
|
|
|
567
619
|
// React.lazy — initialise via the _init/_payload protocol; may suspend.
|
|
568
620
|
if (typeOf === REACT_LAZY) {
|
|
569
621
|
// _init returns the resolved module (or throws a Promise/Error).
|
|
570
|
-
|
|
622
|
+
let resolved: any;
|
|
623
|
+
try {
|
|
624
|
+
resolved = (type as any)._init((type as any)._payload);
|
|
625
|
+
} catch (e) {
|
|
626
|
+
// Module not yet loaded — treat as a component-level suspension.
|
|
627
|
+
if (e && typeof (e as any).then === "function") {
|
|
628
|
+
if (_suspenseRetries + 1 >= MAX_COMPONENT_SUSPENSE_RETRIES) throw SUSPENSE_RETRY_LIMIT;
|
|
629
|
+
patchPromiseStatus(e as Promise<unknown>);
|
|
630
|
+
const m = captureMap(); const u = captureUnsuspend();
|
|
631
|
+
return (e as Promise<unknown>).then(() => {
|
|
632
|
+
swapContextMap(m); restoreUnsuspend(u);
|
|
633
|
+
return renderComponent(type, props, writer, isSvg, _suspenseRetries + 1);
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
throw e;
|
|
637
|
+
}
|
|
571
638
|
// The module may export `.default` or be the component directly.
|
|
572
639
|
const LazyComp = resolved?.default ?? resolved;
|
|
573
640
|
return renderComponent(LazyComp, props, writer, isSvg);
|
|
@@ -619,10 +686,10 @@ function renderComponent(
|
|
|
619
686
|
};
|
|
620
687
|
const r = renderChildren(props.children, writer, isSvg);
|
|
621
688
|
if (r && typeof (r as any).then === "function") {
|
|
622
|
-
const m = captureMap();
|
|
689
|
+
const m = captureMap(); const u = captureUnsuspend();
|
|
623
690
|
return (r as Promise<void>).then(
|
|
624
|
-
() => { swapContextMap(m); finish(); },
|
|
625
|
-
(e) => { swapContextMap(m); finish(); throw e; },
|
|
691
|
+
() => { swapContextMap(m); restoreUnsuspend(u); finish(); },
|
|
692
|
+
(e) => { swapContextMap(m); restoreUnsuspend(u); finish(); throw e; },
|
|
626
693
|
);
|
|
627
694
|
}
|
|
628
695
|
finish();
|
|
@@ -647,6 +714,20 @@ function renderComponent(
|
|
|
647
714
|
restoreDispatcher(prevDispatcher);
|
|
648
715
|
popComponentScope(savedScope);
|
|
649
716
|
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
717
|
+
// Suspense protocol: the component threw a Promise (e.g. useServerData without
|
|
718
|
+
// a <Suspense> wrapper). Context is fully restored at this point — dispatcher,
|
|
719
|
+
// component scope and context value are all popped back to pre-component state.
|
|
720
|
+
// Convert the throw into a returned Promise so the parent never sees a throw and
|
|
721
|
+
// no root restart is needed: we await the promise then retry ONLY this component.
|
|
722
|
+
if (e && typeof (e as any).then === "function") {
|
|
723
|
+
if (_suspenseRetries + 1 >= MAX_COMPONENT_SUSPENSE_RETRIES) throw SUSPENSE_RETRY_LIMIT;
|
|
724
|
+
patchPromiseStatus(e as Promise<unknown>);
|
|
725
|
+
const m = captureMap(); const u = captureUnsuspend();
|
|
726
|
+
return (e as Promise<unknown>).then(() => {
|
|
727
|
+
swapContextMap(m); restoreUnsuspend(u);
|
|
728
|
+
return renderComponent(type, props, writer, isSvg, _suspenseRetries + 1);
|
|
729
|
+
});
|
|
730
|
+
}
|
|
650
731
|
throw e;
|
|
651
732
|
}
|
|
652
733
|
restoreDispatcher(prevDispatcher);
|
|
@@ -656,98 +737,92 @@ function renderComponent(
|
|
|
656
737
|
// `pushTreeContext(keyPath, 1, 0)` call inside finishFunctionComponent.
|
|
657
738
|
// This ensures that useId IDs produced by child components of a useId-calling
|
|
658
739
|
// component are tree-positioned identically to React's own renderer.
|
|
659
|
-
let savedIdTree:
|
|
740
|
+
let savedIdTree: number | undefined;
|
|
660
741
|
if (!(result instanceof Promise) && componentCalledUseId()) {
|
|
661
742
|
savedIdTree = pushTreeContext(1, 0);
|
|
662
743
|
}
|
|
663
744
|
|
|
664
|
-
const finish = () => {
|
|
665
|
-
if (savedIdTree !== undefined) popTreeContext(savedIdTree);
|
|
666
|
-
popComponentScope(savedScope);
|
|
667
|
-
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
668
|
-
};
|
|
669
|
-
|
|
670
745
|
// Async component
|
|
671
746
|
if (result instanceof Promise) {
|
|
672
|
-
const m = captureMap();
|
|
747
|
+
const m = captureMap(); const u = captureUnsuspend();
|
|
673
748
|
return result.then((resolved) => {
|
|
674
|
-
swapContextMap(m);
|
|
749
|
+
swapContextMap(m); restoreUnsuspend(u);
|
|
675
750
|
// Check useId after the async body has finished executing.
|
|
676
|
-
let asyncSavedIdTree:
|
|
751
|
+
let asyncSavedIdTree: number | undefined;
|
|
677
752
|
if (componentCalledUseId()) {
|
|
678
753
|
asyncSavedIdTree = pushTreeContext(1, 0);
|
|
679
754
|
}
|
|
680
|
-
const asyncFinish = () => {
|
|
681
|
-
if (asyncSavedIdTree !== undefined) popTreeContext(asyncSavedIdTree);
|
|
682
|
-
popComponentScope(savedScope);
|
|
683
|
-
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
684
|
-
};
|
|
685
755
|
const r = renderNode(resolved, writer, isSvg);
|
|
686
756
|
if (r && typeof (r as any).then === "function") {
|
|
687
|
-
const m2 = captureMap();
|
|
757
|
+
const m2 = captureMap(); const u2 = captureUnsuspend();
|
|
758
|
+
// Only allocate cleanup closures when actually going async.
|
|
688
759
|
return (r as Promise<void>).then(
|
|
689
|
-
() => {
|
|
690
|
-
|
|
760
|
+
() => {
|
|
761
|
+
swapContextMap(m2); restoreUnsuspend(u2);
|
|
762
|
+
if (asyncSavedIdTree !== undefined) popTreeContext(asyncSavedIdTree);
|
|
763
|
+
popComponentScope(savedScope);
|
|
764
|
+
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
765
|
+
},
|
|
766
|
+
(e) => {
|
|
767
|
+
swapContextMap(m2); restoreUnsuspend(u2);
|
|
768
|
+
if (asyncSavedIdTree !== undefined) popTreeContext(asyncSavedIdTree);
|
|
769
|
+
popComponentScope(savedScope);
|
|
770
|
+
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
771
|
+
throw e;
|
|
772
|
+
},
|
|
691
773
|
);
|
|
692
774
|
}
|
|
693
|
-
|
|
694
|
-
|
|
775
|
+
// Sync result from async component — inline cleanup.
|
|
776
|
+
if (asyncSavedIdTree !== undefined) popTreeContext(asyncSavedIdTree);
|
|
777
|
+
popComponentScope(savedScope);
|
|
778
|
+
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
779
|
+
}, (e) => {
|
|
780
|
+
swapContextMap(m); restoreUnsuspend(u);
|
|
781
|
+
// savedIdTree is always undefined here (async component skips the push).
|
|
782
|
+
popComponentScope(savedScope);
|
|
783
|
+
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
784
|
+
throw e;
|
|
785
|
+
});
|
|
695
786
|
}
|
|
696
787
|
|
|
697
788
|
const r = renderNode(result, writer, isSvg);
|
|
698
789
|
|
|
699
790
|
if (r && typeof (r as any).then === "function") {
|
|
700
|
-
const m = captureMap();
|
|
791
|
+
const m = captureMap(); const u = captureUnsuspend();
|
|
792
|
+
// Only allocate cleanup closures when actually going async.
|
|
701
793
|
return (r as Promise<void>).then(
|
|
702
|
-
() => {
|
|
703
|
-
|
|
794
|
+
() => {
|
|
795
|
+
swapContextMap(m); restoreUnsuspend(u);
|
|
796
|
+
if (savedIdTree !== undefined) popTreeContext(savedIdTree);
|
|
797
|
+
popComponentScope(savedScope);
|
|
798
|
+
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
799
|
+
},
|
|
800
|
+
(e) => {
|
|
801
|
+
swapContextMap(m); restoreUnsuspend(u);
|
|
802
|
+
if (savedIdTree !== undefined) popTreeContext(savedIdTree);
|
|
803
|
+
popComponentScope(savedScope);
|
|
804
|
+
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
805
|
+
throw e;
|
|
806
|
+
},
|
|
704
807
|
);
|
|
705
808
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
* Render an array of children, pushing tree-context for each child
|
|
711
|
-
* so that `useId` produces deterministic, position-based IDs.
|
|
712
|
-
* Goes async only when a child actually returns a Promise.
|
|
713
|
-
*/
|
|
714
|
-
/** Returns true for nodes that become DOM text nodes (string or number). */
|
|
715
|
-
function isTextLike(node: SlimNode): boolean {
|
|
716
|
-
return typeof node === "string" || typeof node === "number";
|
|
809
|
+
// Sync path — inline cleanup, no closure allocation.
|
|
810
|
+
if (savedIdTree !== undefined) popTreeContext(savedIdTree);
|
|
811
|
+
popComponentScope(savedScope);
|
|
812
|
+
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
717
813
|
}
|
|
718
814
|
|
|
815
|
+
/** Render an array of children, pushing tree-context for each child
|
|
816
|
+
* so that `useId` produces deterministic, position-based IDs. */
|
|
719
817
|
function renderChildArray(
|
|
720
818
|
children: SlimNode[],
|
|
721
819
|
writer: Writer,
|
|
722
820
|
isSvg: boolean,
|
|
723
821
|
): MaybePromise {
|
|
724
|
-
|
|
725
|
-
for (let i = 0; i < totalChildren; i++) {
|
|
726
|
-
// React inserts <!-- --> between adjacent text nodes to force the browser
|
|
727
|
-
// to preserve distinct DOM text nodes — required for correct hydration.
|
|
728
|
-
// We use writer.lastWasText instead of inspecting the previous VDOM node
|
|
729
|
-
// so that text emitted at the end of a nested array or fragment is also
|
|
730
|
-
// accounted for (fixes the {["a","b"]}{"c"} adjacency edge case).
|
|
731
|
-
if (isTextLike(children[i]) && writer.lastWasText) {
|
|
732
|
-
writer.write("<!-- -->");
|
|
733
|
-
}
|
|
734
|
-
const savedTree = pushTreeContext(totalChildren, i);
|
|
735
|
-
const r = renderNode(children[i], writer, isSvg);
|
|
736
|
-
if (r && typeof (r as any).then === "function") {
|
|
737
|
-
// One child went async – continue the rest asynchronously
|
|
738
|
-
const m = captureMap();
|
|
739
|
-
return (r as Promise<void>).then(() => {
|
|
740
|
-
swapContextMap(m);
|
|
741
|
-
popTreeContext(savedTree);
|
|
742
|
-
// Continue with remaining children
|
|
743
|
-
return renderChildArrayFrom(children, i + 1, writer, isSvg);
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
popTreeContext(savedTree);
|
|
747
|
-
}
|
|
822
|
+
return renderChildArrayFrom(children, 0, writer, isSvg);
|
|
748
823
|
}
|
|
749
824
|
|
|
750
|
-
/**
|
|
825
|
+
/** Core child-array loop. Used by both the initial call and async continuations. */
|
|
751
826
|
function renderChildArrayFrom(
|
|
752
827
|
children: SlimNode[],
|
|
753
828
|
startIndex: number,
|
|
@@ -756,15 +831,17 @@ function renderChildArrayFrom(
|
|
|
756
831
|
): MaybePromise {
|
|
757
832
|
const totalChildren = children.length;
|
|
758
833
|
for (let i = startIndex; i < totalChildren; i++) {
|
|
759
|
-
|
|
834
|
+
// Inline isTextLike — avoids a function call on every child in every array.
|
|
835
|
+
const child = children[i];
|
|
836
|
+
if ((typeof child === "string" || typeof child === "number") && writer.lastWasText) {
|
|
760
837
|
writer.write("<!-- -->");
|
|
761
838
|
}
|
|
762
839
|
const savedTree = pushTreeContext(totalChildren, i);
|
|
763
|
-
const r = renderNode(
|
|
840
|
+
const r = renderNode(child, writer, isSvg);
|
|
764
841
|
if (r && typeof (r as any).then === "function") {
|
|
765
|
-
const m = captureMap();
|
|
842
|
+
const m = captureMap(); const u = captureUnsuspend();
|
|
766
843
|
return (r as Promise<void>).then(() => {
|
|
767
|
-
swapContextMap(m);
|
|
844
|
+
swapContextMap(m); restoreUnsuspend(u);
|
|
768
845
|
popTreeContext(savedTree);
|
|
769
846
|
return renderChildArrayFrom(children, i + 1, writer, isSvg);
|
|
770
847
|
});
|
|
@@ -793,54 +870,64 @@ function renderChildren(
|
|
|
793
870
|
// their results in document order once each resolves.
|
|
794
871
|
// ---------------------------------------------------------------------------
|
|
795
872
|
|
|
796
|
-
const MAX_SUSPENSE_RETRIES = 25;
|
|
797
|
-
|
|
798
873
|
async function renderSuspense(
|
|
799
874
|
props: Record<string, any>,
|
|
800
875
|
writer: Writer,
|
|
801
876
|
isSvg = false,
|
|
802
877
|
): Promise<void> {
|
|
803
878
|
const { children, fallback } = props;
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
// Snapshot the render context so we can reset between retries.
|
|
879
|
+
// Snapshot tree-context so we can restore it if we need to render the fallback.
|
|
807
880
|
const snap = snapshotContext();
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
881
|
+
// Shallow-clone the context map so we can restore Provider values on fallback.
|
|
882
|
+
// Provider push/pop pairs inside the failed children may not complete
|
|
883
|
+
// symmetrically when SUSPENSE_RETRY_LIMIT is thrown. The clone is a shallow
|
|
884
|
+
// copy of a small Map (one entry per active Provider), so the cost is negligible.
|
|
885
|
+
const savedMap = captureMap();
|
|
886
|
+
const savedMapClone = savedMap ? new Map(savedMap) : null;
|
|
887
|
+
// Collect all output into a buffer so we can discard it if the boundary
|
|
888
|
+
// falls back to the loading state.
|
|
889
|
+
const buffer = new BufferWriter();
|
|
890
|
+
|
|
891
|
+
// Components handle their own Promise throws (see renderComponent catch block),
|
|
892
|
+
// so renderNode either resolves synchronously or returns a Promise — it never
|
|
893
|
+
// throws a Promise here. SUSPENSE_RETRY_LIMIT is thrown when a component
|
|
894
|
+
// exhausts its retry budget, signalling us to render the fallback instead.
|
|
895
|
+
try {
|
|
896
|
+
const r = renderNode(children, buffer, isSvg);
|
|
897
|
+
if (r && typeof (r as any).then === "function") {
|
|
898
|
+
const m = captureMap(); const u = captureUnsuspend();
|
|
899
|
+
await r;
|
|
900
|
+
swapContextMap(m); restoreUnsuspend(u);
|
|
901
|
+
}
|
|
902
|
+
// Success – wrap with React's Suspense boundary markers so hydrateRoot
|
|
903
|
+
// can locate the boundary in the DOM (<!--$--> … <!--/$-->).
|
|
904
|
+
writer.write("<!--$-->");
|
|
905
|
+
buffer.flushTo(writer);
|
|
906
|
+
writer.write("<!--/$-->");
|
|
907
|
+
// Tell a streaming writer it can encode and enqueue everything accumulated
|
|
908
|
+
// so far — this is a natural boundary where partial HTML is complete.
|
|
909
|
+
writer.flush?.();
|
|
910
|
+
} catch (error) {
|
|
911
|
+
if ((error as any) === SUSPENSE_RETRY_LIMIT) {
|
|
912
|
+
// A component inside this boundary exhausted its retry budget.
|
|
913
|
+
// Restore context to Suspense-entry state and render the fallback.
|
|
914
|
+
restoreContext(snap);
|
|
915
|
+
// Restore the context map to its pre-boundary state.
|
|
916
|
+
swapContextMap(savedMapClone);
|
|
917
|
+
writer.write("<!--$?-->");
|
|
918
|
+
if (fallback) {
|
|
919
|
+
const r = renderNode(fallback, writer, isSvg);
|
|
920
|
+
if (r && typeof (r as any).then === "function") {
|
|
921
|
+
const m = captureMap(); const u = captureUnsuspend();
|
|
922
|
+
await r;
|
|
923
|
+
swapContextMap(m); restoreUnsuspend(u);
|
|
924
|
+
}
|
|
817
925
|
}
|
|
818
|
-
// Success – wrap with React's Suspense boundary markers so hydrateRoot
|
|
819
|
-
// can locate the boundary in the DOM (<!--$--> … <!--/$-->).
|
|
820
|
-
writer.write("<!--$-->");
|
|
821
|
-
buffer.flush(writer);
|
|
822
926
|
writer.write("<!--/$-->");
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
if (error && typeof (error as any).then === "function") {
|
|
826
|
-
const m = captureMap(); await (error as Promise<unknown>); swapContextMap(m);
|
|
827
|
-
attempts++;
|
|
828
|
-
} else {
|
|
829
|
-
throw error;
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
// Exhausted retries → render the fallback (boundary stays in loading state).
|
|
835
|
-
restoreContext(snap);
|
|
836
|
-
writer.write("<!--$?-->");
|
|
837
|
-
if (fallback) {
|
|
838
|
-
const r = renderNode(fallback, writer, isSvg);
|
|
839
|
-
if (r && typeof (r as any).then === "function") {
|
|
840
|
-
const m = captureMap(); await r; swapContextMap(m);
|
|
927
|
+
} else {
|
|
928
|
+
throw error;
|
|
841
929
|
}
|
|
842
930
|
}
|
|
843
|
-
writer.write("<!--/$-->");
|
|
844
931
|
}
|
|
845
932
|
|
|
846
933
|
// ---------------------------------------------------------------------------
|
|
@@ -856,6 +943,9 @@ export interface RenderOptions {
|
|
|
856
943
|
identifierPrefix?: string;
|
|
857
944
|
}
|
|
858
945
|
|
|
946
|
+
// Module-level encoder — one instance shared across all renderToStream calls.
|
|
947
|
+
const _streamEncoder = new TextEncoder();
|
|
948
|
+
|
|
859
949
|
/**
|
|
860
950
|
* Render a component tree to a `ReadableStream<Uint8Array>`.
|
|
861
951
|
*
|
|
@@ -866,24 +956,28 @@ export function renderToStream(
|
|
|
866
956
|
element: SlimNode,
|
|
867
957
|
options?: RenderOptions,
|
|
868
958
|
): ReadableStream<Uint8Array> {
|
|
869
|
-
const encoder = new TextEncoder();
|
|
870
959
|
const idPrefix = options?.identifierPrefix ?? "";
|
|
871
960
|
|
|
872
|
-
const contextMap = new Map<object, unknown>();
|
|
873
961
|
return new ReadableStream({
|
|
874
962
|
async start(controller) {
|
|
875
963
|
resetRenderState(idPrefix);
|
|
876
|
-
|
|
877
|
-
|
|
964
|
+
// Start with null — pushContextValue lazily creates the Map only if a
|
|
965
|
+
// Context.Provider is actually rendered, eliminating the allocation on
|
|
966
|
+
// the common (no-provider) path.
|
|
967
|
+
const prev = swapContextMap(null);
|
|
968
|
+
|
|
969
|
+
// Buffer writes into a string; only encode+enqueue in flush() so that
|
|
970
|
+
// a sync render produces one Uint8Array instead of thousands of tiny ones.
|
|
971
|
+
let _buf = "";
|
|
878
972
|
const writer: Writer = {
|
|
879
973
|
lastWasText: false,
|
|
880
|
-
write(chunk: string) {
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
974
|
+
write(chunk: string) { _buf += chunk; this.lastWasText = false; },
|
|
975
|
+
text(s: string) { _buf += s; this.lastWasText = true; },
|
|
976
|
+
flush() {
|
|
977
|
+
if (_buf.length > 0) {
|
|
978
|
+
controller.enqueue(_streamEncoder.encode(_buf));
|
|
979
|
+
_buf = "";
|
|
980
|
+
}
|
|
887
981
|
},
|
|
888
982
|
};
|
|
889
983
|
|
|
@@ -892,6 +986,7 @@ export function renderToStream(
|
|
|
892
986
|
if (r && typeof (r as any).then === "function") {
|
|
893
987
|
const m = captureMap(); await r; swapContextMap(m);
|
|
894
988
|
}
|
|
989
|
+
writer.flush!(); // encode everything accumulated (sync renders: the whole page)
|
|
895
990
|
controller.close();
|
|
896
991
|
} catch (error) {
|
|
897
992
|
controller.error(error);
|
|
@@ -902,43 +997,87 @@ export function renderToStream(
|
|
|
902
997
|
});
|
|
903
998
|
}
|
|
904
999
|
|
|
1000
|
+
// ---------------------------------------------------------------------------
|
|
1001
|
+
// Preflight renderer
|
|
1002
|
+
// ---------------------------------------------------------------------------
|
|
1003
|
+
|
|
1004
|
+
/** A writer that discards all output — only side-effects (cache warming, head
|
|
1005
|
+
* population) are preserved. Used as the no-op sink for Pass 1. */
|
|
1006
|
+
const NULL_WRITER: Writer = {
|
|
1007
|
+
lastWasText: false,
|
|
1008
|
+
write(_c: string) {},
|
|
1009
|
+
text(_s: string) {},
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Pass-1 preflight render.
|
|
1014
|
+
*
|
|
1015
|
+
* Walks the component tree with a NullWriter (discards all HTML output) so
|
|
1016
|
+
* that all `useServerData` promises are resolved into the `__hadarsUnsuspend`
|
|
1017
|
+
* cache and all `context.head` mutations are applied.
|
|
1018
|
+
*
|
|
1019
|
+
* Components self-retry on suspension at the component level (see
|
|
1020
|
+
* `renderComponent` catch block), so a single tree walk is sufficient.
|
|
1021
|
+
*
|
|
1022
|
+
* Call this before `renderToString` / `renderToStream` to guarantee a
|
|
1023
|
+
* suspension-free, fully-synchronous second pass.
|
|
1024
|
+
*/
|
|
1025
|
+
export async function renderPreflight(
|
|
1026
|
+
element: SlimNode,
|
|
1027
|
+
options?: RenderOptions,
|
|
1028
|
+
): Promise<void> {
|
|
1029
|
+
const idPrefix = options?.identifierPrefix ?? "";
|
|
1030
|
+
// Start with null — pushContextValue lazily creates the Map only if a
|
|
1031
|
+
// Context.Provider is actually rendered.
|
|
1032
|
+
const prev = swapContextMap(null);
|
|
1033
|
+
try {
|
|
1034
|
+
resetRenderState(idPrefix);
|
|
1035
|
+
NULL_WRITER.lastWasText = false;
|
|
1036
|
+
// Components self-retry on suspension (see renderComponent catch block),
|
|
1037
|
+
// so a single pass is guaranteed to complete with all promises resolved.
|
|
1038
|
+
const r = renderNode(element, NULL_WRITER);
|
|
1039
|
+
if (r && typeof (r as any).then === "function") {
|
|
1040
|
+
const m = captureMap(); await r; swapContextMap(m);
|
|
1041
|
+
}
|
|
1042
|
+
} finally {
|
|
1043
|
+
swapContextMap(prev);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
905
1047
|
/**
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
1048
|
+
* Render a component tree to a complete HTML string.
|
|
1049
|
+
*
|
|
1050
|
+
* Components self-retry on suspension at the component level (see
|
|
1051
|
+
* `renderComponent` catch block), so a single tree walk is sufficient
|
|
1052
|
+
* even when `useServerData` or similar hooks are used without an explicit
|
|
1053
|
+
* `<Suspense>` wrapper.
|
|
909
1054
|
*/
|
|
910
1055
|
export async function renderToString(
|
|
911
1056
|
element: SlimNode,
|
|
912
1057
|
options?: RenderOptions,
|
|
913
1058
|
): Promise<string> {
|
|
914
1059
|
const idPrefix = options?.identifierPrefix ?? "";
|
|
915
|
-
|
|
916
|
-
|
|
1060
|
+
// Start with null — pushContextValue lazily creates the Map only if a
|
|
1061
|
+
// Context.Provider is actually rendered.
|
|
1062
|
+
const prev = swapContextMap(null);
|
|
1063
|
+
// Use a single mutable string rather than a chunks array + join() —
|
|
1064
|
+
// JSC/V8 use rope strings for += that are flattened once at return time,
|
|
1065
|
+
// avoiding all the array bookkeeping and the final allocation at join().
|
|
1066
|
+
let output = "";
|
|
1067
|
+
const writer: Writer = {
|
|
1068
|
+
lastWasText: false,
|
|
1069
|
+
write(c) { output += c; this.lastWasText = false; },
|
|
1070
|
+
text(s) { output += s; this.lastWasText = true; },
|
|
1071
|
+
};
|
|
917
1072
|
try {
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
write(c) { chunks.push(c); this.lastWasText = false; },
|
|
925
|
-
text(s) { chunks.push(s); this.lastWasText = true; },
|
|
926
|
-
};
|
|
927
|
-
try {
|
|
928
|
-
const r = renderNode(element, writer);
|
|
929
|
-
if (r && typeof (r as any).then === "function") {
|
|
930
|
-
const m = captureMap(); await r; swapContextMap(m);
|
|
931
|
-
}
|
|
932
|
-
return chunks.join("");
|
|
933
|
-
} catch (error) {
|
|
934
|
-
if (error && typeof (error as any).then === "function") {
|
|
935
|
-
const m = captureMap(); await (error as Promise<unknown>); swapContextMap(m);
|
|
936
|
-
continue;
|
|
937
|
-
}
|
|
938
|
-
throw error;
|
|
939
|
-
}
|
|
1073
|
+
resetRenderState(idPrefix);
|
|
1074
|
+
// Components self-retry on suspension (see renderComponent catch block),
|
|
1075
|
+
// so a single pass is guaranteed to complete with all promises resolved.
|
|
1076
|
+
const r = renderNode(element, writer);
|
|
1077
|
+
if (r && typeof (r as any).then === "function") {
|
|
1078
|
+
const m = captureMap(); await r; swapContextMap(m);
|
|
940
1079
|
}
|
|
941
|
-
|
|
1080
|
+
return output;
|
|
942
1081
|
} finally {
|
|
943
1082
|
swapContextMap(prev);
|
|
944
1083
|
}
|