hadars 0.1.18 → 0.1.19

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.
@@ -16,6 +16,7 @@
16
16
 
17
17
  import {
18
18
  SLIM_ELEMENT,
19
+ REACT19_ELEMENT,
19
20
  FRAGMENT_TYPE,
20
21
  SUSPENSE_TYPE,
21
22
  type SlimElement,
@@ -53,7 +54,11 @@ const VOID_ELEMENTS = new Set([
53
54
  ]);
54
55
 
55
56
  function escapeHtml(str: string): string {
56
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
57
+ return str
58
+ .replace(/&/g, "&amp;")
59
+ .replace(/</g, "&lt;")
60
+ .replace(/>/g, "&gt;")
61
+ .replace(/'/g, "&#x27;");
57
62
  }
58
63
 
59
64
  function escapeAttr(str: string): string {
@@ -240,7 +245,9 @@ function renderAttributes(props: Record<string, any>, isSvg: boolean): string {
240
245
  key === "children" ||
241
246
  key === "key" ||
242
247
  key === "ref" ||
243
- key === "dangerouslySetInnerHTML"
248
+ key === "dangerouslySetInnerHTML" ||
249
+ key === "suppressHydrationWarning" ||
250
+ key === "suppressContentEditableWarning"
244
251
  )
245
252
  continue;
246
253
  // Skip event handlers (onClick, onChange, …)
@@ -259,12 +266,24 @@ function renderAttributes(props: Record<string, any>, isSvg: boolean): string {
259
266
  ? "for"
260
267
  : key === "tabIndex"
261
268
  ? "tabindex"
262
- : key;
269
+ : key === "defaultValue"
270
+ ? "value"
271
+ : key === "defaultChecked"
272
+ ? "checked"
273
+ : key;
263
274
  }
264
275
 
265
- if (value === false || value == null) continue;
276
+ if (value === false || value == null) {
277
+ // aria-* and data-* attributes treat `false` as the string "false"
278
+ // (omitting them would change semantics, e.g. aria-hidden="false" ≠ absent).
279
+ if (value === false && (attrName.startsWith("aria-") || attrName.startsWith("data-"))) {
280
+ attrs += ` ${attrName}="false"`;
281
+ }
282
+ continue;
283
+ }
266
284
  if (value === true) {
267
- attrs += ` ${attrName}`;
285
+ // Emit as attr="" to match React's server output exactly.
286
+ attrs += ` ${attrName}=""`;
268
287
  continue;
269
288
  }
270
289
  if (key === "style" && typeof value === "object") {
@@ -281,16 +300,29 @@ function renderAttributes(props: Record<string, any>, isSvg: boolean): string {
281
300
  // ---------------------------------------------------------------------------
282
301
 
283
302
  interface Writer {
303
+ /** Write raw HTML markup. Resets lastWasText to false. */
284
304
  write(chunk: string): void;
305
+ /** Write escaped text content. Sets lastWasText to true. */
306
+ text(s: string): void;
307
+ /** True if the last thing written was a text node (not markup). */
308
+ lastWasText: boolean;
285
309
  }
286
310
 
287
311
  class BufferWriter implements Writer {
288
312
  chunks: string[] = [];
313
+ lastWasText = false;
289
314
  write(chunk: string) {
290
315
  this.chunks.push(chunk);
316
+ this.lastWasText = false;
317
+ }
318
+ text(s: string) {
319
+ this.chunks.push(s);
320
+ this.lastWasText = true;
291
321
  }
292
322
  flush(target: Writer) {
293
323
  for (const c of this.chunks) target.write(c);
324
+ // Propagate the text-node tracking state from the buffer's last write.
325
+ target.lastWasText = this.lastWasText;
294
326
  }
295
327
  }
296
328
 
@@ -314,11 +346,11 @@ function renderNode(
314
346
  // --- primitives / nullish ---
315
347
  if (node == null || typeof node === "boolean") return;
316
348
  if (typeof node === "string") {
317
- writer.write(escapeHtml(node));
349
+ writer.text(escapeHtml(node));
318
350
  return;
319
351
  }
320
352
  if (typeof node === "number") {
321
- writer.write(String(node));
353
+ writer.text(String(node));
322
354
  return;
323
355
  }
324
356
 
@@ -341,13 +373,14 @@ function renderNode(
341
373
  );
342
374
  }
343
375
 
344
- // --- SlimElement ---
376
+ // --- SlimElement (accepts both the classic and React 19 transitional symbols) ---
345
377
  if (
346
378
  typeof node === "object" &&
347
379
  node !== null &&
348
- "$$typeof" in node &&
349
- (node as SlimElement).$$typeof === SLIM_ELEMENT
380
+ "$$typeof" in node
350
381
  ) {
382
+ const elType = (node as any)["$$typeof"] as symbol;
383
+ if (elType !== SLIM_ELEMENT && elType !== REACT19_ELEMENT) return;
351
384
  const element = node as SlimElement;
352
385
  const { type, props } = element;
353
386
 
@@ -366,6 +399,13 @@ function renderNode(
366
399
  return renderComponent(type, props, writer, isSvg);
367
400
  }
368
401
 
402
+ // Object component wrappers: React.memo, React.forwardRef,
403
+ // Context.Provider (React 19: the context IS the provider),
404
+ // Context.Consumer — all identified by their own $$typeof.
405
+ if (typeof type === "object" && type !== null) {
406
+ return renderComponent(type as unknown as Function, props, writer, isSvg);
407
+ }
408
+
369
409
  // HTML / SVG element
370
410
  if (typeof type === "string") {
371
411
  return renderHostElement(type, props, writer, isSvg);
@@ -373,6 +413,38 @@ function renderNode(
373
413
  }
374
414
  }
375
415
 
416
+ /**
417
+ * Recursively clone `<option>` / `<optgroup>` nodes inside a `<select>` tree,
418
+ * stamping `selected` on options whose value is in `selectedValues`.
419
+ * Handles both single-select and multi-select (defaultValue array).
420
+ */
421
+ function markSelectedOptionsMulti(children: SlimNode, selectedValues: Set<string>): SlimNode {
422
+ if (children == null || typeof children === "boolean") return children;
423
+ if (typeof children === "string" || typeof children === "number") return children;
424
+ if (Array.isArray(children)) {
425
+ return children.map((c) => markSelectedOptionsMulti(c, selectedValues));
426
+ }
427
+ if (
428
+ typeof children === "object" &&
429
+ "$$typeof" in children
430
+ ) {
431
+ const elType = (children as any)["$$typeof"] as symbol;
432
+ if (elType !== SLIM_ELEMENT && elType !== REACT19_ELEMENT) return children;
433
+ const el = children as SlimElement;
434
+ if (el.type === "option") {
435
+ // Option value falls back to its text children if no value prop.
436
+ const optValue = el.props.value !== undefined ? el.props.value : el.props.children;
437
+ const isSelected = selectedValues.has(String(optValue));
438
+ return { ...el, props: { ...el.props, selected: isSelected || undefined } };
439
+ }
440
+ if (el.type === "optgroup" || el.type === FRAGMENT_TYPE) {
441
+ const newChildren = markSelectedOptionsMulti(el.props.children, selectedValues);
442
+ return { ...el, props: { ...el.props, children: newChildren } };
443
+ }
444
+ }
445
+ return children;
446
+ }
447
+
376
448
  /** Render a host (HTML/SVG) element. Sync when children are sync. */
377
449
  function renderHostElement(
378
450
  tag: string,
@@ -383,13 +455,60 @@ function renderHostElement(
383
455
  const enteringSvg = tag === "svg";
384
456
  const childSvg = isSvg || enteringSvg;
385
457
 
386
- const effectiveProps =
387
- enteringSvg && !props.xmlns
388
- ? { xmlns: "http://www.w3.org/2000/svg", ...props }
389
- : props;
458
+ // ── <textarea> ────────────────────────────────────────────────────────────
459
+ if (tag === "textarea") {
460
+ const textContent = props.value ?? props.defaultValue ?? props.children ?? "";
461
+ const filteredProps: Record<string, any> = {};
462
+ for (const k of Object.keys(props)) {
463
+ if (k !== "value" && k !== "defaultValue" && k !== "children") filteredProps[k] = props[k];
464
+ }
465
+ writer.write(`<textarea${renderAttributes(filteredProps, false)}>`);
466
+ writer.text(escapeHtml(String(textContent)));
467
+ writer.write("</textarea>");
468
+ return;
469
+ }
470
+
471
+ // ── <select> ──────────────────────────────────────────────────────────────
472
+ // React never emits a `value` attribute on <select>; instead it marks the
473
+ // matching <option> as `selected`.
474
+ if (tag === "select") {
475
+ const selectedValue = props.value ?? props.defaultValue;
476
+ const filteredProps: Record<string, any> = {};
477
+ for (const k of Object.keys(props)) {
478
+ if (k !== "value" && k !== "defaultValue") filteredProps[k] = props[k];
479
+ }
480
+ writer.write(`<select${renderAttributes(filteredProps, false)}>`);
481
+ // Normalise selectedValue to a Set of strings to handle both single values
482
+ // and arrays (multi-select with defaultValue={['a','b']}).
483
+ const selectedSet: Set<string> | null =
484
+ selectedValue == null
485
+ ? null
486
+ : Array.isArray(selectedValue)
487
+ ? new Set((selectedValue as unknown[]).map(String))
488
+ : new Set([String(selectedValue)]);
489
+ const patchedChildren =
490
+ selectedSet != null
491
+ ? markSelectedOptionsMulti(props.children, selectedSet)
492
+ : props.children;
493
+ const inner = renderChildren(patchedChildren, writer, false);
494
+ if (inner && typeof (inner as any).then === "function") {
495
+ return (inner as Promise<void>).then(() => { writer.write("</select>"); });
496
+ }
497
+ writer.write("</select>");
498
+ return;
499
+ }
500
+
501
+ // React 19 does not inject xmlns on <svg> — browsers handle SVG namespaces
502
+ // automatically for inline HTML5 SVG, so we match React's behaviour.
503
+ writer.write(`<${tag}${renderAttributes(props, childSvg)}`);
390
504
 
391
- writer.write(`<${tag}${renderAttributes(effectiveProps, childSvg)}>`);
505
+ // Void elements are self-closing (matching React's output format).
506
+ if (VOID_ELEMENTS.has(tag)) {
507
+ writer.write("/>");
508
+ return;
509
+ }
392
510
 
511
+ writer.write(">");
393
512
  const childContext = tag === "foreignObject" ? false : childSvg;
394
513
 
395
514
  let inner: MaybePromise = undefined;
@@ -400,18 +519,18 @@ function renderHostElement(
400
519
  }
401
520
 
402
521
  if (inner && typeof (inner as any).then === "function") {
403
- return (inner as Promise<void>).then(() => {
404
- if (!VOID_ELEMENTS.has(tag)) writer.write(`</${tag}>`);
405
- });
522
+ return (inner as Promise<void>).then(() => { writer.write(`</${tag}>`); });
406
523
  }
407
- if (!VOID_ELEMENTS.has(tag)) writer.write(`</${tag}>`);
524
+ writer.write(`</${tag}>`);
408
525
  }
409
526
 
410
- // React special $$typeof symbols for memo, forwardRef, and context/provider
527
+ // React special $$typeof symbols for memo, forwardRef, context/provider/consumer, lazy
411
528
  const REACT_MEMO = Symbol.for("react.memo");
412
529
  const REACT_FORWARD_REF = Symbol.for("react.forward_ref");
413
- const REACT_PROVIDER = Symbol.for("react.provider"); // React 18 Provider object
414
- const REACT_CONTEXT = Symbol.for("react.context"); // React 19 context-as-provider
530
+ const REACT_PROVIDER = Symbol.for("react.provider"); // React 18 Provider object
531
+ const REACT_CONTEXT = Symbol.for("react.context"); // React 19: context IS provider
532
+ const REACT_CONSUMER = Symbol.for("react.consumer"); // React 19 Consumer object
533
+ const REACT_LAZY = Symbol.for("react.lazy"); // React.lazy()
415
534
 
416
535
  /** Render a function or class component. */
417
536
  function renderComponent(
@@ -435,6 +554,31 @@ function renderComponent(
435
554
  return renderComponent((type as any).render, props, writer, isSvg);
436
555
  }
437
556
 
557
+ // React.lazy — initialise via the _init/_payload protocol; may suspend.
558
+ if (typeOf === REACT_LAZY) {
559
+ // _init returns the resolved module (or throws a Promise/Error).
560
+ const resolved = (type as any)._init((type as any)._payload);
561
+ // The module may export `.default` or be the component directly.
562
+ const LazyComp = resolved?.default ?? resolved;
563
+ return renderComponent(LazyComp, props, writer, isSvg);
564
+ }
565
+
566
+ // React.Consumer (React 19) — call the children render prop with the current value
567
+ if (typeOf === REACT_CONSUMER) {
568
+ const ctx = (type as any)._context;
569
+ const value = ctx?._currentValue;
570
+ const result: SlimNode =
571
+ typeof props.children === "function" ? props.children(value) : null;
572
+ const savedScope = pushComponentScope();
573
+ const finish = () => popComponentScope(savedScope);
574
+ const r = renderNode(result, writer, isSvg);
575
+ if (r && typeof (r as any).then === "function") {
576
+ return (r as Promise<void>).then(finish);
577
+ }
578
+ finish();
579
+ return;
580
+ }
581
+
438
582
  // Provider detection:
439
583
  // slim-react: Provider function has `_context` property
440
584
  // React 18: Provider object has $$typeof === react.provider and ._context
@@ -457,10 +601,30 @@ function renderComponent(
457
601
  // Each component gets a fresh local-ID counter (for multiple useId calls).
458
602
  const savedScope = pushComponentScope();
459
603
 
604
+ // For React 19 Provider (context object IS the provider — not callable), just
605
+ // render children directly; the context value was already pushed above.
606
+ if (isProvider && typeof type !== "function") {
607
+ const finish = () => {
608
+ popComponentScope(savedScope);
609
+ ctx._currentValue = prevCtxValue;
610
+ };
611
+ const r = renderChildren(props.children, writer, isSvg);
612
+ if (r && typeof (r as any).then === "function") {
613
+ return (r as Promise<void>).then(finish);
614
+ }
615
+ finish();
616
+ return;
617
+ }
618
+
460
619
  let result: SlimNode;
461
620
  try {
462
621
  if (type.prototype && typeof type.prototype.render === "function") {
463
622
  const instance = new (type as any)(props);
623
+ // Call getDerivedStateFromProps if defined, matching React's behaviour.
624
+ if (typeof (type as any).getDerivedStateFromProps === "function") {
625
+ const derived = (type as any).getDerivedStateFromProps(props, instance.state ?? {});
626
+ if (derived != null) instance.state = { ...(instance.state ?? {}), ...derived };
627
+ }
464
628
  result = instance.render();
465
629
  } else {
466
630
  result = type(props);
@@ -512,9 +676,12 @@ function renderChildArray(
512
676
  ): MaybePromise {
513
677
  const totalChildren = children.length;
514
678
  for (let i = 0; i < totalChildren; i++) {
515
- // React inserts <!-- --> between adjacent text-like nodes so the browser
516
- // preserves separate DOM text nodes — required for correct hydration.
517
- if (i > 0 && isTextLike(children[i]) && isTextLike(children[i - 1])) {
679
+ // React inserts <!-- --> between adjacent text nodes to force the browser
680
+ // to preserve distinct DOM text nodes — required for correct hydration.
681
+ // We use writer.lastWasText instead of inspecting the previous VDOM node
682
+ // so that text emitted at the end of a nested array or fragment is also
683
+ // accounted for (fixes the {["a","b"]}{"c"} adjacency edge case).
684
+ if (isTextLike(children[i]) && writer.lastWasText) {
518
685
  writer.write("<!-- -->");
519
686
  }
520
687
  const savedTree = pushTreeContext(totalChildren, i);
@@ -540,7 +707,7 @@ function renderChildArrayFrom(
540
707
  ): MaybePromise {
541
708
  const totalChildren = children.length;
542
709
  for (let i = startIndex; i < totalChildren; i++) {
543
- if (i > 0 && isTextLike(children[i]) && isTextLike(children[i - 1])) {
710
+ if (isTextLike(children[i]) && writer.lastWasText) {
544
711
  writer.write("<!-- -->");
545
712
  }
546
713
  const savedTree = pushTreeContext(totalChildren, i);
@@ -641,8 +808,14 @@ export function renderToStream(element: SlimNode): ReadableStream<Uint8Array> {
641
808
  resetRenderState();
642
809
 
643
810
  const writer: Writer = {
811
+ lastWasText: false,
644
812
  write(chunk: string) {
645
813
  controller.enqueue(encoder.encode(chunk));
814
+ this.lastWasText = false;
815
+ },
816
+ text(s: string) {
817
+ controller.enqueue(encoder.encode(s));
818
+ this.lastWasText = true;
646
819
  },
647
820
  };
648
821
 
@@ -666,7 +839,11 @@ export async function renderToString(element: SlimNode): Promise<string> {
666
839
  for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
667
840
  resetRenderState();
668
841
  const chunks: string[] = [];
669
- const writer: Writer = { write(c) { chunks.push(c); } };
842
+ const writer: Writer = {
843
+ lastWasText: false,
844
+ write(c) { chunks.push(c); this.lastWasText = false; },
845
+ text(s) { chunks.push(s); this.lastWasText = true; },
846
+ };
670
847
  try {
671
848
  const r = renderNode(element, writer);
672
849
  if (r && typeof (r as any).then === "function") await r;
@@ -3,9 +3,15 @@
3
3
  // with elements produced by the real React JSX runtime (e.g. when a library
4
4
  // uses React.createElement directly). This means the SSR bundle can be aliased
5
5
  // to slim-react without any element shape mismatch.
6
- export const SLIM_ELEMENT = Symbol.for("react.element");
7
- export const FRAGMENT_TYPE = Symbol.for("react.fragment");
8
- export const SUSPENSE_TYPE = Symbol.for("react.suspense");
6
+ //
7
+ // React 19 introduced "react.transitional.element" as the canonical $$typeof for
8
+ // elements created by createElement / the jsx-runtime. We keep the old
9
+ // "react.element" as slim-react's own emission symbol (unchanged wire format for
10
+ // SSR HTML — it makes no difference) and accept both in the renderer.
11
+ export const SLIM_ELEMENT = Symbol.for("react.element");
12
+ export const REACT19_ELEMENT = Symbol.for("react.transitional.element");
13
+ export const FRAGMENT_TYPE = Symbol.for("react.fragment");
14
+ export const SUSPENSE_TYPE = Symbol.for("react.suspense");
9
15
 
10
16
  // ---- Types ----
11
17
  export type ComponentFunction = (props: any) => SlimNode;