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.
@@ -36,7 +36,8 @@ import {
36
36
  getContextValue,
37
37
  swapContextMap,
38
38
  captureMap,
39
- type TreeContext,
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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#x27;' };
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> = { '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' };
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
- const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
80
- result += cssKey + ':' + style[key];
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
- /** Set of known SVG element tag names. */
228
- const SVG_ELEMENTS = new Set([
229
- "svg", "animate", "animateMotion", "animateTransform", "circle",
230
- "clipPath", "defs", "desc", "ellipse", "feBlend", "feColorMatrix",
231
- "feComponentTransfer", "feComposite", "feConvolveMatrix",
232
- "feDiffuseLighting", "feDisplacementMap", "feDistantLight",
233
- "feDropShadow", "feFlood", "feFuncA", "feFuncB", "feFuncG",
234
- "feFuncR", "feGaussianBlur", "feImage", "feMerge", "feMergeNode",
235
- "feMorphology", "feOffset", "fePointLight", "feSpecularLighting",
236
- "feSpotLight", "feTile", "feTurbulence", "filter", "foreignObject",
237
- "g", "image", "line", "linearGradient", "marker", "mask",
238
- "metadata", "mpath", "path", "pattern", "polygon", "polyline",
239
- "radialGradient", "rect", "set", "stop", "switch", "symbol",
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
- function renderAttributes(props: Record<string, any>, isSvg: boolean): string {
244
- let attrs = "";
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 === "children" ||
250
- key === "key" ||
251
- key === "ref" ||
252
- key === "dangerouslySetInnerHTML" ||
253
- key === "suppressHydrationWarning" ||
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
- ? "class"
269
- : key === "htmlFor"
270
- ? "for"
271
- : key === "tabIndex"
272
- ? "tabindex"
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
- // aria-* and data-* attributes treat `false` as the string "false"
282
- // (omitting them would change semantics, e.g. aria-hidden="false" ≠ absent).
283
- if (value === false && (attrName.startsWith("aria-") || attrName.startsWith("data-"))) {
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
- // aria-* and data-* are string attributes: true must serialize to "true".
290
- // HTML boolean attributes (disabled, hidden, checked, …) use attr="" (present-without-value).
291
- if (attrName.startsWith("aria-") || attrName.startsWith("data-")) {
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
- attrs += ` ${attrName}=""`;
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) attrs += ` style="${escapeAttr(styleStr)}"`;
332
+ if (styleStr) writer.write(` style="${escapeAttr(styleStr)}"`);
301
333
  continue;
302
334
  }
303
- attrs += ` ${attrName}="${escapeAttr(String(value))}"`;
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
- chunks: string[] = [];
359
+ data = "";
323
360
  lastWasText = false;
324
361
  write(chunk: string) {
325
- this.chunks.push(chunk);
362
+ this.data += chunk;
326
363
  this.lastWasText = false;
327
364
  }
328
365
  text(s: string) {
329
- this.chunks.push(s);
366
+ this.data += s;
330
367
  this.lastWasText = true;
331
368
  }
332
- flush(target: Writer) {
333
- for (const c of this.chunks) target.write(c);
334
- // Propagate the text-node tracking state from the buffer's last write.
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
- typeof node === "object" &&
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
- typeof node === "object" &&
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 enteringSvg = tag === "svg";
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
- const filteredProps: Record<string, any> = {};
472
- for (const k of Object.keys(props)) {
473
- if (k !== "value" && k !== "defaultValue" && k !== "children") filteredProps[k] = props[k];
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
- const filteredProps: Record<string, any> = {};
487
- for (const k of Object.keys(props)) {
488
- if (k !== "value" && k !== "defaultValue") filteredProps[k] = props[k];
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}${renderAttributes(props, childSvg)}`);
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
- const typeOf = (type as any)?.$$typeof;
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
- const resolved = (type as any)._init((type as any)._payload);
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: TreeContext | undefined;
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: TreeContext | undefined;
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
- () => { swapContextMap(m2); asyncFinish(); },
690
- (e) => { swapContextMap(m2); asyncFinish(); throw e; },
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
- asyncFinish();
694
- }, (e) => { swapContextMap(m); finish(); throw e; });
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
- () => { swapContextMap(m); finish(); },
703
- (e) => { swapContextMap(m); finish(); throw e; },
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
- finish();
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
- const totalChildren = children.length;
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
- /** Resume renderChildArray from a given index (after async child). */
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
- if (isTextLike(children[i]) && writer.lastWasText) {
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(children[i], writer, isSvg);
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
- let attempts = 0;
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
- while (attempts < MAX_SUSPENSE_RETRIES) {
810
- // Restore context to the state it was in when we entered <Suspense>.
811
- restoreContext(snap);
812
- let buffer = new BufferWriter();
813
- try {
814
- const r = renderNode(children, buffer, isSvg);
815
- if (r && typeof (r as any).then === "function") {
816
- const m = captureMap(); await r; swapContextMap(m);
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
- return;
824
- } catch (error: unknown) {
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
- const prev = swapContextMap(contextMap);
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
- controller.enqueue(encoder.encode(chunk));
882
- this.lastWasText = false;
883
- },
884
- text(s: string) {
885
- controller.enqueue(encoder.encode(s));
886
- this.lastWasText = true;
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
- * Convenience: render to a complete HTML string.
907
- * Retries the full tree when a component throws a Promise (Suspense protocol),
908
- * so useServerData and similar hooks work without requiring explicit <Suspense>.
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
- const contextMap = new Map<object, unknown>();
916
- const prev = swapContextMap(contextMap);
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
- for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
919
- resetRenderState(idPrefix);
920
- swapContextMap(contextMap); // re-activate our map on each retry
921
- const chunks: string[] = [];
922
- const writer: Writer = {
923
- lastWasText: false,
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
- throw new Error("[slim-react] renderToString exceeded maximum retries");
1080
+ return output;
942
1081
  } finally {
943
1082
  swapContextMap(prev);
944
1083
  }