hadars 0.1.17 → 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-OS3V4CPN.js +42 -0
- package/dist/cli.js +777 -143
- package/dist/index.cjs +61 -6
- package/dist/index.d.ts +40 -1
- package/dist/index.js +58 -6
- package/dist/jsx-runtime-97ca74a5.d.ts +18 -0
- package/dist/slim-react/index.cjs +1001 -0
- package/dist/slim-react/index.d.ts +180 -0
- package/dist/slim-react/index.js +911 -0
- package/dist/slim-react/jsx-runtime.cjs +52 -0
- package/dist/slim-react/jsx-runtime.d.ts +1 -0
- package/dist/slim-react/jsx-runtime.js +10 -0
- package/dist/ssr-render-worker.js +740 -108
- package/dist/ssr-watch.js +34 -13
- package/dist/utils/Head.tsx +3 -6
- package/index.ts +1 -1
- package/package.json +3 -3
- package/src/build.ts +6 -23
- package/src/components/CacheSegment.tsx +67 -0
- package/src/index.tsx +2 -0
- package/src/slim-react/context.ts +52 -0
- package/src/slim-react/hooks.ts +137 -0
- package/src/slim-react/index.ts +225 -0
- package/src/slim-react/jsx-runtime.ts +7 -0
- package/src/slim-react/jsx.ts +53 -0
- package/src/slim-react/render.ts +863 -0
- package/src/slim-react/renderContext.ts +105 -0
- package/src/slim-react/types.ts +33 -0
- package/src/ssr-render-worker.ts +83 -118
- package/src/utils/Head.tsx +3 -6
- package/src/utils/response.tsx +42 -105
- package/src/utils/rspack.ts +42 -15
- package/src/utils/segmentCache.ts +87 -0
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming SSR renderer with Suspense support.
|
|
3
|
+
*
|
|
4
|
+
* `renderToStream` walks the virtual-node tree produced by jsx() /
|
|
5
|
+
* createElement() and writes HTML chunks into a ReadableStream.
|
|
6
|
+
*
|
|
7
|
+
* When it meets a <Suspense> boundary it:
|
|
8
|
+
* 1. Tries to render the children into a temporary buffer.
|
|
9
|
+
* 2. If a child throws a Promise (React Suspense protocol) it
|
|
10
|
+
* awaits the promise, then retries from step 1.
|
|
11
|
+
* 3. Once successful, the buffer is flushed to the real stream.
|
|
12
|
+
*
|
|
13
|
+
* The net effect is that the stream **pauses** at Suspense boundaries
|
|
14
|
+
* until the async data is ready, then continues – exactly as requested.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
SLIM_ELEMENT,
|
|
19
|
+
REACT19_ELEMENT,
|
|
20
|
+
FRAGMENT_TYPE,
|
|
21
|
+
SUSPENSE_TYPE,
|
|
22
|
+
type SlimElement,
|
|
23
|
+
type SlimNode,
|
|
24
|
+
} from "./types";
|
|
25
|
+
import {
|
|
26
|
+
resetRenderState,
|
|
27
|
+
pushTreeContext,
|
|
28
|
+
popTreeContext,
|
|
29
|
+
pushComponentScope,
|
|
30
|
+
popComponentScope,
|
|
31
|
+
snapshotContext,
|
|
32
|
+
restoreContext,
|
|
33
|
+
} from "./renderContext";
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// HTML helpers
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const VOID_ELEMENTS = new Set([
|
|
40
|
+
"area",
|
|
41
|
+
"base",
|
|
42
|
+
"br",
|
|
43
|
+
"col",
|
|
44
|
+
"embed",
|
|
45
|
+
"hr",
|
|
46
|
+
"img",
|
|
47
|
+
"input",
|
|
48
|
+
"link",
|
|
49
|
+
"meta",
|
|
50
|
+
"param",
|
|
51
|
+
"source",
|
|
52
|
+
"track",
|
|
53
|
+
"wbr",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
function escapeHtml(str: string): string {
|
|
57
|
+
return str
|
|
58
|
+
.replace(/&/g, "&")
|
|
59
|
+
.replace(/</g, "<")
|
|
60
|
+
.replace(/>/g, ">")
|
|
61
|
+
.replace(/'/g, "'");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function escapeAttr(str: string): string {
|
|
65
|
+
return str
|
|
66
|
+
.replace(/&/g, "&")
|
|
67
|
+
.replace(/"/g, """)
|
|
68
|
+
.replace(/</g, "<")
|
|
69
|
+
.replace(/>/g, ">");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function styleObjectToString(style: Record<string, any>): string {
|
|
73
|
+
return Object.entries(style)
|
|
74
|
+
.map(([key, value]) => {
|
|
75
|
+
// camelCase → kebab-case
|
|
76
|
+
const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
77
|
+
return `${cssKey}:${value}`;
|
|
78
|
+
})
|
|
79
|
+
.join(";");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// SVG attribute name mappings
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* React camelCase prop → actual SVG attribute.
|
|
88
|
+
* Covers the most commonly used SVG attributes.
|
|
89
|
+
*/
|
|
90
|
+
const SVG_ATTR_MAP: Record<string, string> = {
|
|
91
|
+
// Presentation / geometry
|
|
92
|
+
accentHeight: "accent-height",
|
|
93
|
+
alignmentBaseline: "alignment-baseline",
|
|
94
|
+
arabicForm: "arabic-form",
|
|
95
|
+
baselineShift: "baseline-shift",
|
|
96
|
+
capHeight: "cap-height",
|
|
97
|
+
clipPath: "clip-path",
|
|
98
|
+
clipRule: "clip-rule",
|
|
99
|
+
colorInterpolation: "color-interpolation",
|
|
100
|
+
colorInterpolationFilters: "color-interpolation-filters",
|
|
101
|
+
colorProfile: "color-profile",
|
|
102
|
+
dominantBaseline: "dominant-baseline",
|
|
103
|
+
enableBackground: "enable-background",
|
|
104
|
+
fillOpacity: "fill-opacity",
|
|
105
|
+
fillRule: "fill-rule",
|
|
106
|
+
floodColor: "flood-color",
|
|
107
|
+
floodOpacity: "flood-opacity",
|
|
108
|
+
fontFamily: "font-family",
|
|
109
|
+
fontSize: "font-size",
|
|
110
|
+
fontSizeAdjust: "font-size-adjust",
|
|
111
|
+
fontStretch: "font-stretch",
|
|
112
|
+
fontStyle: "font-style",
|
|
113
|
+
fontVariant: "font-variant",
|
|
114
|
+
fontWeight: "font-weight",
|
|
115
|
+
glyphName: "glyph-name",
|
|
116
|
+
glyphOrientationHorizontal: "glyph-orientation-horizontal",
|
|
117
|
+
glyphOrientationVertical: "glyph-orientation-vertical",
|
|
118
|
+
horizAdvX: "horiz-adv-x",
|
|
119
|
+
horizOriginX: "horiz-origin-x",
|
|
120
|
+
imageRendering: "image-rendering",
|
|
121
|
+
letterSpacing: "letter-spacing",
|
|
122
|
+
lightingColor: "lighting-color",
|
|
123
|
+
markerEnd: "marker-end",
|
|
124
|
+
markerMid: "marker-mid",
|
|
125
|
+
markerStart: "marker-start",
|
|
126
|
+
overlinePosition: "overline-position",
|
|
127
|
+
overlineThickness: "overline-thickness",
|
|
128
|
+
paintOrder: "paint-order",
|
|
129
|
+
panose1: "panose-1",
|
|
130
|
+
pointerEvents: "pointer-events",
|
|
131
|
+
renderingIntent: "rendering-intent",
|
|
132
|
+
shapeRendering: "shape-rendering",
|
|
133
|
+
stopColor: "stop-color",
|
|
134
|
+
stopOpacity: "stop-opacity",
|
|
135
|
+
strikethroughPosition: "strikethrough-position",
|
|
136
|
+
strikethroughThickness: "strikethrough-thickness",
|
|
137
|
+
strokeDasharray: "stroke-dasharray",
|
|
138
|
+
strokeDashoffset: "stroke-dashoffset",
|
|
139
|
+
strokeLinecap: "stroke-linecap",
|
|
140
|
+
strokeLinejoin: "stroke-linejoin",
|
|
141
|
+
strokeMiterlimit: "stroke-miterlimit",
|
|
142
|
+
strokeOpacity: "stroke-opacity",
|
|
143
|
+
strokeWidth: "stroke-width",
|
|
144
|
+
textAnchor: "text-anchor",
|
|
145
|
+
textDecoration: "text-decoration",
|
|
146
|
+
textRendering: "text-rendering",
|
|
147
|
+
underlinePosition: "underline-position",
|
|
148
|
+
underlineThickness: "underline-thickness",
|
|
149
|
+
unicodeBidi: "unicode-bidi",
|
|
150
|
+
unicodeRange: "unicode-range",
|
|
151
|
+
unitsPerEm: "units-per-em",
|
|
152
|
+
vAlphabetic: "v-alphabetic",
|
|
153
|
+
vHanging: "v-hanging",
|
|
154
|
+
vIdeographic: "v-ideographic",
|
|
155
|
+
vMathematical: "v-mathematical",
|
|
156
|
+
vertAdvY: "vert-adv-y",
|
|
157
|
+
vertOriginX: "vert-origin-x",
|
|
158
|
+
vertOriginY: "vert-origin-y",
|
|
159
|
+
wordSpacing: "word-spacing",
|
|
160
|
+
writingMode: "writing-mode",
|
|
161
|
+
xHeight: "x-height",
|
|
162
|
+
|
|
163
|
+
// Namespace-prefixed
|
|
164
|
+
xlinkActuate: "xlink:actuate",
|
|
165
|
+
xlinkArcrole: "xlink:arcrole",
|
|
166
|
+
xlinkHref: "xlink:href",
|
|
167
|
+
xlinkRole: "xlink:role",
|
|
168
|
+
xlinkShow: "xlink:show",
|
|
169
|
+
xlinkTitle: "xlink:title",
|
|
170
|
+
xlinkType: "xlink:type",
|
|
171
|
+
xmlBase: "xml:base",
|
|
172
|
+
xmlLang: "xml:lang",
|
|
173
|
+
xmlSpace: "xml:space",
|
|
174
|
+
xmlns: "xmlns",
|
|
175
|
+
xmlnsXlink: "xmlns:xlink",
|
|
176
|
+
|
|
177
|
+
// Filter / lighting
|
|
178
|
+
baseFrequency: "baseFrequency",
|
|
179
|
+
colorInterpolation_filters: "color-interpolation-filters",
|
|
180
|
+
diffuseConstant: "diffuseConstant",
|
|
181
|
+
edgeMode: "edgeMode",
|
|
182
|
+
filterUnits: "filterUnits",
|
|
183
|
+
gradientTransform: "gradientTransform",
|
|
184
|
+
gradientUnits: "gradientUnits",
|
|
185
|
+
kernelMatrix: "kernelMatrix",
|
|
186
|
+
kernelUnitLength: "kernelUnitLength",
|
|
187
|
+
lengthAdjust: "lengthAdjust",
|
|
188
|
+
limitingConeAngle: "limitingConeAngle",
|
|
189
|
+
markerHeight: "markerHeight",
|
|
190
|
+
markerWidth: "markerWidth",
|
|
191
|
+
maskContentUnits: "maskContentUnits",
|
|
192
|
+
maskUnits: "maskUnits",
|
|
193
|
+
numOctaves: "numOctaves",
|
|
194
|
+
pathLength: "pathLength",
|
|
195
|
+
patternContentUnits: "patternContentUnits",
|
|
196
|
+
patternTransform: "patternTransform",
|
|
197
|
+
patternUnits: "patternUnits",
|
|
198
|
+
pointsAtX: "pointsAtX",
|
|
199
|
+
pointsAtY: "pointsAtY",
|
|
200
|
+
pointsAtZ: "pointsAtZ",
|
|
201
|
+
preserveAspectRatio: "preserveAspectRatio",
|
|
202
|
+
primitiveUnits: "primitiveUnits",
|
|
203
|
+
refX: "refX",
|
|
204
|
+
refY: "refY",
|
|
205
|
+
repeatCount: "repeatCount",
|
|
206
|
+
repeatDur: "repeatDur",
|
|
207
|
+
specularConstant: "specularConstant",
|
|
208
|
+
specularExponent: "specularExponent",
|
|
209
|
+
spreadMethod: "spreadMethod",
|
|
210
|
+
startOffset: "startOffset",
|
|
211
|
+
stdDeviation: "stdDeviation",
|
|
212
|
+
stitchTiles: "stitchTiles",
|
|
213
|
+
surfaceScale: "surfaceScale",
|
|
214
|
+
systemLanguage: "systemLanguage",
|
|
215
|
+
tableValues: "tableValues",
|
|
216
|
+
targetX: "targetX",
|
|
217
|
+
targetY: "targetY",
|
|
218
|
+
textLength: "textLength",
|
|
219
|
+
viewBox: "viewBox",
|
|
220
|
+
xChannelSelector: "xChannelSelector",
|
|
221
|
+
yChannelSelector: "yChannelSelector",
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** Set of known SVG element tag names. */
|
|
225
|
+
const SVG_ELEMENTS = new Set([
|
|
226
|
+
"svg", "animate", "animateMotion", "animateTransform", "circle",
|
|
227
|
+
"clipPath", "defs", "desc", "ellipse", "feBlend", "feColorMatrix",
|
|
228
|
+
"feComponentTransfer", "feComposite", "feConvolveMatrix",
|
|
229
|
+
"feDiffuseLighting", "feDisplacementMap", "feDistantLight",
|
|
230
|
+
"feDropShadow", "feFlood", "feFuncA", "feFuncB", "feFuncG",
|
|
231
|
+
"feFuncR", "feGaussianBlur", "feImage", "feMerge", "feMergeNode",
|
|
232
|
+
"feMorphology", "feOffset", "fePointLight", "feSpecularLighting",
|
|
233
|
+
"feSpotLight", "feTile", "feTurbulence", "filter", "foreignObject",
|
|
234
|
+
"g", "image", "line", "linearGradient", "marker", "mask",
|
|
235
|
+
"metadata", "mpath", "path", "pattern", "polygon", "polyline",
|
|
236
|
+
"radialGradient", "rect", "set", "stop", "switch", "symbol",
|
|
237
|
+
"text", "textPath", "title", "tspan", "use", "view",
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
function renderAttributes(props: Record<string, any>, isSvg: boolean): string {
|
|
241
|
+
let attrs = "";
|
|
242
|
+
for (const [key, value] of Object.entries(props)) {
|
|
243
|
+
// Skip internal / non-attribute props
|
|
244
|
+
if (
|
|
245
|
+
key === "children" ||
|
|
246
|
+
key === "key" ||
|
|
247
|
+
key === "ref" ||
|
|
248
|
+
key === "dangerouslySetInnerHTML" ||
|
|
249
|
+
key === "suppressHydrationWarning" ||
|
|
250
|
+
key === "suppressContentEditableWarning"
|
|
251
|
+
)
|
|
252
|
+
continue;
|
|
253
|
+
// Skip event handlers (onClick, onChange, …)
|
|
254
|
+
if (key.startsWith("on") && key.length > 2 && key[2] === key[2]!.toUpperCase())
|
|
255
|
+
continue;
|
|
256
|
+
|
|
257
|
+
// Prop-name mapping
|
|
258
|
+
let attrName: string;
|
|
259
|
+
if (isSvg && key in SVG_ATTR_MAP) {
|
|
260
|
+
attrName = SVG_ATTR_MAP[key]!;
|
|
261
|
+
} else {
|
|
262
|
+
attrName =
|
|
263
|
+
key === "className"
|
|
264
|
+
? "class"
|
|
265
|
+
: key === "htmlFor"
|
|
266
|
+
? "for"
|
|
267
|
+
: key === "tabIndex"
|
|
268
|
+
? "tabindex"
|
|
269
|
+
: key === "defaultValue"
|
|
270
|
+
? "value"
|
|
271
|
+
: key === "defaultChecked"
|
|
272
|
+
? "checked"
|
|
273
|
+
: key;
|
|
274
|
+
}
|
|
275
|
+
|
|
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
|
+
}
|
|
284
|
+
if (value === true) {
|
|
285
|
+
// Emit as attr="" to match React's server output exactly.
|
|
286
|
+
attrs += ` ${attrName}=""`;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (key === "style" && typeof value === "object") {
|
|
290
|
+
attrs += ` style="${escapeAttr(styleObjectToString(value))}"`;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
attrs += ` ${attrName}="${escapeAttr(String(value))}"`;
|
|
294
|
+
}
|
|
295
|
+
return attrs;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Writer abstraction (stream vs buffer)
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
interface Writer {
|
|
303
|
+
/** Write raw HTML markup. Resets lastWasText to false. */
|
|
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;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
class BufferWriter implements Writer {
|
|
312
|
+
chunks: string[] = [];
|
|
313
|
+
lastWasText = false;
|
|
314
|
+
write(chunk: string) {
|
|
315
|
+
this.chunks.push(chunk);
|
|
316
|
+
this.lastWasText = false;
|
|
317
|
+
}
|
|
318
|
+
text(s: string) {
|
|
319
|
+
this.chunks.push(s);
|
|
320
|
+
this.lastWasText = true;
|
|
321
|
+
}
|
|
322
|
+
flush(target: Writer) {
|
|
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;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Core recursive renderer (sync-first design)
|
|
331
|
+
//
|
|
332
|
+
// `renderNode` is synchronous for the fast path (plain HTML elements,
|
|
333
|
+
// text, fragments, pure function components). It only returns a
|
|
334
|
+
// Promise when something actually async happens (Suspense throw,
|
|
335
|
+
// async component). This eliminates thousands of unnecessary
|
|
336
|
+
// microtask bounces for a typical component tree.
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
type MaybePromise = void | Promise<void>;
|
|
340
|
+
|
|
341
|
+
function renderNode(
|
|
342
|
+
node: SlimNode,
|
|
343
|
+
writer: Writer,
|
|
344
|
+
isSvg = false,
|
|
345
|
+
): MaybePromise {
|
|
346
|
+
// --- primitives / nullish ---
|
|
347
|
+
if (node == null || typeof node === "boolean") return;
|
|
348
|
+
if (typeof node === "string") {
|
|
349
|
+
writer.text(escapeHtml(node));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (typeof node === "number") {
|
|
353
|
+
writer.text(String(node));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// --- arrays ---
|
|
358
|
+
if (Array.isArray(node)) {
|
|
359
|
+
return renderChildArray(node, writer, isSvg);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- iterables (Set, generator, …) ---
|
|
363
|
+
if (
|
|
364
|
+
typeof node === "object" &&
|
|
365
|
+
node !== null &&
|
|
366
|
+
Symbol.iterator in node &&
|
|
367
|
+
!("$$typeof" in node)
|
|
368
|
+
) {
|
|
369
|
+
return renderChildArray(
|
|
370
|
+
Array.from(node as Iterable<SlimNode>),
|
|
371
|
+
writer,
|
|
372
|
+
isSvg,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// --- SlimElement (accepts both the classic and React 19 transitional symbols) ---
|
|
377
|
+
if (
|
|
378
|
+
typeof node === "object" &&
|
|
379
|
+
node !== null &&
|
|
380
|
+
"$$typeof" in node
|
|
381
|
+
) {
|
|
382
|
+
const elType = (node as any)["$$typeof"] as symbol;
|
|
383
|
+
if (elType !== SLIM_ELEMENT && elType !== REACT19_ELEMENT) return;
|
|
384
|
+
const element = node as SlimElement;
|
|
385
|
+
const { type, props } = element;
|
|
386
|
+
|
|
387
|
+
// Fragment
|
|
388
|
+
if (type === FRAGMENT_TYPE) {
|
|
389
|
+
return renderChildren(props.children, writer, isSvg);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Suspense – always async
|
|
393
|
+
if (type === SUSPENSE_TYPE) {
|
|
394
|
+
return renderSuspense(props, writer, isSvg);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Function / class component
|
|
398
|
+
if (typeof type === "function") {
|
|
399
|
+
return renderComponent(type, props, writer, isSvg);
|
|
400
|
+
}
|
|
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
|
+
|
|
409
|
+
// HTML / SVG element
|
|
410
|
+
if (typeof type === "string") {
|
|
411
|
+
return renderHostElement(type, props, writer, isSvg);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
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
|
+
|
|
448
|
+
/** Render a host (HTML/SVG) element. Sync when children are sync. */
|
|
449
|
+
function renderHostElement(
|
|
450
|
+
tag: string,
|
|
451
|
+
props: Record<string, any>,
|
|
452
|
+
writer: Writer,
|
|
453
|
+
isSvg: boolean,
|
|
454
|
+
): MaybePromise {
|
|
455
|
+
const enteringSvg = tag === "svg";
|
|
456
|
+
const childSvg = isSvg || enteringSvg;
|
|
457
|
+
|
|
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)}`);
|
|
504
|
+
|
|
505
|
+
// Void elements are self-closing (matching React's output format).
|
|
506
|
+
if (VOID_ELEMENTS.has(tag)) {
|
|
507
|
+
writer.write("/>");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
writer.write(">");
|
|
512
|
+
const childContext = tag === "foreignObject" ? false : childSvg;
|
|
513
|
+
|
|
514
|
+
let inner: MaybePromise = undefined;
|
|
515
|
+
if (props.dangerouslySetInnerHTML) {
|
|
516
|
+
writer.write(props.dangerouslySetInnerHTML.__html);
|
|
517
|
+
} else {
|
|
518
|
+
inner = renderChildren(props.children, writer, childContext);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (inner && typeof (inner as any).then === "function") {
|
|
522
|
+
return (inner as Promise<void>).then(() => { writer.write(`</${tag}>`); });
|
|
523
|
+
}
|
|
524
|
+
writer.write(`</${tag}>`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// React special $$typeof symbols for memo, forwardRef, context/provider/consumer, lazy
|
|
528
|
+
const REACT_MEMO = Symbol.for("react.memo");
|
|
529
|
+
const REACT_FORWARD_REF = Symbol.for("react.forward_ref");
|
|
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()
|
|
534
|
+
|
|
535
|
+
/** Render a function or class component. */
|
|
536
|
+
function renderComponent(
|
|
537
|
+
type: Function,
|
|
538
|
+
props: Record<string, any>,
|
|
539
|
+
writer: Writer,
|
|
540
|
+
isSvg: boolean,
|
|
541
|
+
): MaybePromise {
|
|
542
|
+
const typeOf = (type as any)?.$$typeof;
|
|
543
|
+
|
|
544
|
+
// React.memo — unwrap and re-render the inner type
|
|
545
|
+
if (typeOf === REACT_MEMO) {
|
|
546
|
+
return renderNode(
|
|
547
|
+
{ $$typeof: SLIM_ELEMENT, type: (type as any).type, props, key: null } as any,
|
|
548
|
+
writer, isSvg,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// React.forwardRef — call the wrapped render function
|
|
553
|
+
if (typeOf === REACT_FORWARD_REF) {
|
|
554
|
+
return renderComponent((type as any).render, props, writer, isSvg);
|
|
555
|
+
}
|
|
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
|
+
|
|
582
|
+
// Provider detection:
|
|
583
|
+
// slim-react: Provider function has `_context` property
|
|
584
|
+
// React 18: Provider object has $$typeof === react.provider and ._context
|
|
585
|
+
// React 19: Context object itself is the provider ($$typeof === react.context + value prop)
|
|
586
|
+
const isProvider =
|
|
587
|
+
"_context" in type ||
|
|
588
|
+
typeOf === REACT_PROVIDER ||
|
|
589
|
+
(typeOf === REACT_CONTEXT && "value" in props);
|
|
590
|
+
|
|
591
|
+
let prevCtxValue: any;
|
|
592
|
+
let ctx: any;
|
|
593
|
+
|
|
594
|
+
if (isProvider) {
|
|
595
|
+
// Resolve the actual context object from any provider variant
|
|
596
|
+
ctx = (type as any)._context ?? type;
|
|
597
|
+
prevCtxValue = ctx._currentValue;
|
|
598
|
+
ctx._currentValue = props.value;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Each component gets a fresh local-ID counter (for multiple useId calls).
|
|
602
|
+
const savedScope = pushComponentScope();
|
|
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
|
+
|
|
619
|
+
let result: SlimNode;
|
|
620
|
+
try {
|
|
621
|
+
if (type.prototype && typeof type.prototype.render === "function") {
|
|
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
|
+
}
|
|
628
|
+
result = instance.render();
|
|
629
|
+
} else {
|
|
630
|
+
result = type(props);
|
|
631
|
+
}
|
|
632
|
+
} catch (e) {
|
|
633
|
+
popComponentScope(savedScope);
|
|
634
|
+
if (isProvider) ctx._currentValue = prevCtxValue;
|
|
635
|
+
throw e;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const finish = () => {
|
|
639
|
+
popComponentScope(savedScope);
|
|
640
|
+
if (isProvider) ctx._currentValue = prevCtxValue;
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
// Async component
|
|
644
|
+
if (result instanceof Promise) {
|
|
645
|
+
return result.then((resolved) => {
|
|
646
|
+
const r = renderNode(resolved, writer, isSvg);
|
|
647
|
+
if (r && typeof (r as any).then === "function") {
|
|
648
|
+
return (r as Promise<void>).then(finish);
|
|
649
|
+
}
|
|
650
|
+
finish();
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const r = renderNode(result, writer, isSvg);
|
|
655
|
+
|
|
656
|
+
if (r && typeof (r as any).then === "function") {
|
|
657
|
+
return (r as Promise<void>).then(finish);
|
|
658
|
+
}
|
|
659
|
+
finish();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Render an array of children, pushing tree-context for each child
|
|
664
|
+
* so that `useId` produces deterministic, position-based IDs.
|
|
665
|
+
* Goes async only when a child actually returns a Promise.
|
|
666
|
+
*/
|
|
667
|
+
/** Returns true for nodes that become DOM text nodes (string or number). */
|
|
668
|
+
function isTextLike(node: SlimNode): boolean {
|
|
669
|
+
return typeof node === "string" || typeof node === "number";
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function renderChildArray(
|
|
673
|
+
children: SlimNode[],
|
|
674
|
+
writer: Writer,
|
|
675
|
+
isSvg: boolean,
|
|
676
|
+
): MaybePromise {
|
|
677
|
+
const totalChildren = children.length;
|
|
678
|
+
for (let i = 0; i < totalChildren; i++) {
|
|
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) {
|
|
685
|
+
writer.write("<!-- -->");
|
|
686
|
+
}
|
|
687
|
+
const savedTree = pushTreeContext(totalChildren, i);
|
|
688
|
+
const r = renderNode(children[i], writer, isSvg);
|
|
689
|
+
if (r && typeof (r as any).then === "function") {
|
|
690
|
+
// One child went async – continue the rest asynchronously
|
|
691
|
+
return (r as Promise<void>).then(() => {
|
|
692
|
+
popTreeContext(savedTree);
|
|
693
|
+
// Continue with remaining children
|
|
694
|
+
return renderChildArrayFrom(children, i + 1, writer, isSvg);
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
popTreeContext(savedTree);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Resume renderChildArray from a given index (after async child). */
|
|
702
|
+
function renderChildArrayFrom(
|
|
703
|
+
children: SlimNode[],
|
|
704
|
+
startIndex: number,
|
|
705
|
+
writer: Writer,
|
|
706
|
+
isSvg: boolean,
|
|
707
|
+
): MaybePromise {
|
|
708
|
+
const totalChildren = children.length;
|
|
709
|
+
for (let i = startIndex; i < totalChildren; i++) {
|
|
710
|
+
if (isTextLike(children[i]) && writer.lastWasText) {
|
|
711
|
+
writer.write("<!-- -->");
|
|
712
|
+
}
|
|
713
|
+
const savedTree = pushTreeContext(totalChildren, i);
|
|
714
|
+
const r = renderNode(children[i], writer, isSvg);
|
|
715
|
+
if (r && typeof (r as any).then === "function") {
|
|
716
|
+
return (r as Promise<void>).then(() => {
|
|
717
|
+
popTreeContext(savedTree);
|
|
718
|
+
return renderChildArrayFrom(children, i + 1, writer, isSvg);
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
popTreeContext(savedTree);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function renderChildren(
|
|
726
|
+
children: SlimNode,
|
|
727
|
+
writer: Writer,
|
|
728
|
+
isSvg = false,
|
|
729
|
+
): MaybePromise {
|
|
730
|
+
if (children == null) return;
|
|
731
|
+
if (Array.isArray(children)) {
|
|
732
|
+
return renderChildArray(children, writer, isSvg);
|
|
733
|
+
}
|
|
734
|
+
return renderNode(children, writer, isSvg);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
// Suspense boundary renderer
|
|
739
|
+
//
|
|
740
|
+
// Sibling Suspense boundaries within the same parent are resolved
|
|
741
|
+
// **in parallel**: we kick off all of them concurrently and stream
|
|
742
|
+
// their results in document order once each resolves.
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
const MAX_SUSPENSE_RETRIES = 25;
|
|
746
|
+
|
|
747
|
+
async function renderSuspense(
|
|
748
|
+
props: Record<string, any>,
|
|
749
|
+
writer: Writer,
|
|
750
|
+
isSvg = false,
|
|
751
|
+
): Promise<void> {
|
|
752
|
+
const { children, fallback } = props;
|
|
753
|
+
let attempts = 0;
|
|
754
|
+
|
|
755
|
+
// Snapshot the render context so we can reset between retries.
|
|
756
|
+
const snap = snapshotContext();
|
|
757
|
+
|
|
758
|
+
while (attempts < MAX_SUSPENSE_RETRIES) {
|
|
759
|
+
// Restore context to the state it was in when we entered <Suspense>.
|
|
760
|
+
restoreContext(snap);
|
|
761
|
+
try {
|
|
762
|
+
const buffer = new BufferWriter();
|
|
763
|
+
const r = renderNode(children, buffer, isSvg);
|
|
764
|
+
if (r && typeof (r as any).then === "function") {
|
|
765
|
+
await r;
|
|
766
|
+
}
|
|
767
|
+
// Success – wrap with React's Suspense boundary markers so hydrateRoot
|
|
768
|
+
// can locate the boundary in the DOM (<!--$--> … <!--/$-->).
|
|
769
|
+
writer.write("<!--$-->");
|
|
770
|
+
buffer.flush(writer);
|
|
771
|
+
writer.write("<!--/$-->");
|
|
772
|
+
return;
|
|
773
|
+
} catch (error: unknown) {
|
|
774
|
+
if (error && typeof (error as any).then === "function") {
|
|
775
|
+
await (error as Promise<unknown>);
|
|
776
|
+
attempts++;
|
|
777
|
+
} else {
|
|
778
|
+
throw error;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Exhausted retries → render the fallback (boundary stays in loading state).
|
|
784
|
+
restoreContext(snap);
|
|
785
|
+
writer.write("<!--$?-->");
|
|
786
|
+
if (fallback) {
|
|
787
|
+
const r = renderNode(fallback, writer, isSvg);
|
|
788
|
+
if (r && typeof (r as any).then === "function") await r;
|
|
789
|
+
}
|
|
790
|
+
writer.write("<!--/$-->");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ---------------------------------------------------------------------------
|
|
794
|
+
// Public API
|
|
795
|
+
// ---------------------------------------------------------------------------
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Render a component tree to a `ReadableStream<Uint8Array>`.
|
|
799
|
+
*
|
|
800
|
+
* The stream pauses at `<Suspense>` boundaries until the suspended
|
|
801
|
+
* promise resolves, then continues writing HTML.
|
|
802
|
+
*/
|
|
803
|
+
export function renderToStream(element: SlimNode): ReadableStream<Uint8Array> {
|
|
804
|
+
const encoder = new TextEncoder();
|
|
805
|
+
|
|
806
|
+
return new ReadableStream({
|
|
807
|
+
async start(controller) {
|
|
808
|
+
resetRenderState();
|
|
809
|
+
|
|
810
|
+
const writer: Writer = {
|
|
811
|
+
lastWasText: false,
|
|
812
|
+
write(chunk: string) {
|
|
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;
|
|
819
|
+
},
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
try {
|
|
823
|
+
const r = renderNode(element, writer);
|
|
824
|
+
if (r && typeof (r as any).then === "function") await r;
|
|
825
|
+
controller.close();
|
|
826
|
+
} catch (error) {
|
|
827
|
+
controller.error(error);
|
|
828
|
+
}
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Convenience: render to a complete HTML string.
|
|
835
|
+
* Retries the full tree when a component throws a Promise (Suspense protocol),
|
|
836
|
+
* so useServerData and similar hooks work without requiring explicit <Suspense>.
|
|
837
|
+
*/
|
|
838
|
+
export async function renderToString(element: SlimNode): Promise<string> {
|
|
839
|
+
for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
|
|
840
|
+
resetRenderState();
|
|
841
|
+
const chunks: string[] = [];
|
|
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
|
+
};
|
|
847
|
+
try {
|
|
848
|
+
const r = renderNode(element, writer);
|
|
849
|
+
if (r && typeof (r as any).then === "function") await r;
|
|
850
|
+
return chunks.join("");
|
|
851
|
+
} catch (error) {
|
|
852
|
+
if (error && typeof (error as any).then === "function") {
|
|
853
|
+
await (error as Promise<unknown>);
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
throw error;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
throw new Error("[slim-react] renderToString exceeded maximum retries");
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** Alias matching React 18+ server API naming. */
|
|
863
|
+
export { renderToStream as renderToReadableStream };
|