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.
- package/dist/{chunk-TGSIYGY2.js → chunk-OS3V4CPN.js} +2 -0
- package/dist/cli.js +140 -19
- package/dist/slim-react/index.cjs +146 -19
- package/dist/slim-react/index.js +147 -20
- package/dist/slim-react/jsx-runtime.cjs +1 -0
- package/dist/slim-react/jsx-runtime.js +1 -1
- package/dist/ssr-render-worker.js +140 -19
- package/package.json +2 -2
- package/src/slim-react/render.ts +204 -27
- package/src/slim-react/types.ts +9 -3
package/src/slim-react/render.ts
CHANGED
|
@@ -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
|
|
57
|
+
return str
|
|
58
|
+
.replace(/&/g, "&")
|
|
59
|
+
.replace(/</g, "<")
|
|
60
|
+
.replace(/>/g, ">")
|
|
61
|
+
.replace(/'/g, "'");
|
|
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)
|
|
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
|
-
|
|
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.
|
|
349
|
+
writer.text(escapeHtml(node));
|
|
318
350
|
return;
|
|
319
351
|
}
|
|
320
352
|
if (typeof node === "number") {
|
|
321
|
-
writer.
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
524
|
+
writer.write(`</${tag}>`);
|
|
408
525
|
}
|
|
409
526
|
|
|
410
|
-
// React special $$typeof symbols for memo, forwardRef,
|
|
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");
|
|
414
|
-
const REACT_CONTEXT = Symbol.for("react.context");
|
|
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
|
|
516
|
-
//
|
|
517
|
-
|
|
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 (
|
|
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 = {
|
|
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;
|
package/src/slim-react/types.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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;
|