hadars 0.1.17 → 0.1.18
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 +40 -0
- package/dist/cli.js +656 -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 +874 -0
- package/dist/slim-react/index.d.ts +180 -0
- package/dist/slim-react/index.js +784 -0
- package/dist/slim-react/jsx-runtime.cjs +51 -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 +619 -108
- package/dist/ssr-watch.js +34 -13
- package/dist/utils/Head.tsx +3 -6
- package/index.ts +1 -1
- package/package.json +2 -2
- 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 +686 -0
- package/src/slim-react/renderContext.ts +105 -0
- package/src/slim-react/types.ts +27 -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,686 @@
|
|
|
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
|
+
FRAGMENT_TYPE,
|
|
20
|
+
SUSPENSE_TYPE,
|
|
21
|
+
type SlimElement,
|
|
22
|
+
type SlimNode,
|
|
23
|
+
} from "./types";
|
|
24
|
+
import {
|
|
25
|
+
resetRenderState,
|
|
26
|
+
pushTreeContext,
|
|
27
|
+
popTreeContext,
|
|
28
|
+
pushComponentScope,
|
|
29
|
+
popComponentScope,
|
|
30
|
+
snapshotContext,
|
|
31
|
+
restoreContext,
|
|
32
|
+
} from "./renderContext";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// HTML helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const VOID_ELEMENTS = new Set([
|
|
39
|
+
"area",
|
|
40
|
+
"base",
|
|
41
|
+
"br",
|
|
42
|
+
"col",
|
|
43
|
+
"embed",
|
|
44
|
+
"hr",
|
|
45
|
+
"img",
|
|
46
|
+
"input",
|
|
47
|
+
"link",
|
|
48
|
+
"meta",
|
|
49
|
+
"param",
|
|
50
|
+
"source",
|
|
51
|
+
"track",
|
|
52
|
+
"wbr",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
function escapeHtml(str: string): string {
|
|
56
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function escapeAttr(str: string): string {
|
|
60
|
+
return str
|
|
61
|
+
.replace(/&/g, "&")
|
|
62
|
+
.replace(/"/g, """)
|
|
63
|
+
.replace(/</g, "<")
|
|
64
|
+
.replace(/>/g, ">");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function styleObjectToString(style: Record<string, any>): string {
|
|
68
|
+
return Object.entries(style)
|
|
69
|
+
.map(([key, value]) => {
|
|
70
|
+
// camelCase → kebab-case
|
|
71
|
+
const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
72
|
+
return `${cssKey}:${value}`;
|
|
73
|
+
})
|
|
74
|
+
.join(";");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// SVG attribute name mappings
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* React camelCase prop → actual SVG attribute.
|
|
83
|
+
* Covers the most commonly used SVG attributes.
|
|
84
|
+
*/
|
|
85
|
+
const SVG_ATTR_MAP: Record<string, string> = {
|
|
86
|
+
// Presentation / geometry
|
|
87
|
+
accentHeight: "accent-height",
|
|
88
|
+
alignmentBaseline: "alignment-baseline",
|
|
89
|
+
arabicForm: "arabic-form",
|
|
90
|
+
baselineShift: "baseline-shift",
|
|
91
|
+
capHeight: "cap-height",
|
|
92
|
+
clipPath: "clip-path",
|
|
93
|
+
clipRule: "clip-rule",
|
|
94
|
+
colorInterpolation: "color-interpolation",
|
|
95
|
+
colorInterpolationFilters: "color-interpolation-filters",
|
|
96
|
+
colorProfile: "color-profile",
|
|
97
|
+
dominantBaseline: "dominant-baseline",
|
|
98
|
+
enableBackground: "enable-background",
|
|
99
|
+
fillOpacity: "fill-opacity",
|
|
100
|
+
fillRule: "fill-rule",
|
|
101
|
+
floodColor: "flood-color",
|
|
102
|
+
floodOpacity: "flood-opacity",
|
|
103
|
+
fontFamily: "font-family",
|
|
104
|
+
fontSize: "font-size",
|
|
105
|
+
fontSizeAdjust: "font-size-adjust",
|
|
106
|
+
fontStretch: "font-stretch",
|
|
107
|
+
fontStyle: "font-style",
|
|
108
|
+
fontVariant: "font-variant",
|
|
109
|
+
fontWeight: "font-weight",
|
|
110
|
+
glyphName: "glyph-name",
|
|
111
|
+
glyphOrientationHorizontal: "glyph-orientation-horizontal",
|
|
112
|
+
glyphOrientationVertical: "glyph-orientation-vertical",
|
|
113
|
+
horizAdvX: "horiz-adv-x",
|
|
114
|
+
horizOriginX: "horiz-origin-x",
|
|
115
|
+
imageRendering: "image-rendering",
|
|
116
|
+
letterSpacing: "letter-spacing",
|
|
117
|
+
lightingColor: "lighting-color",
|
|
118
|
+
markerEnd: "marker-end",
|
|
119
|
+
markerMid: "marker-mid",
|
|
120
|
+
markerStart: "marker-start",
|
|
121
|
+
overlinePosition: "overline-position",
|
|
122
|
+
overlineThickness: "overline-thickness",
|
|
123
|
+
paintOrder: "paint-order",
|
|
124
|
+
panose1: "panose-1",
|
|
125
|
+
pointerEvents: "pointer-events",
|
|
126
|
+
renderingIntent: "rendering-intent",
|
|
127
|
+
shapeRendering: "shape-rendering",
|
|
128
|
+
stopColor: "stop-color",
|
|
129
|
+
stopOpacity: "stop-opacity",
|
|
130
|
+
strikethroughPosition: "strikethrough-position",
|
|
131
|
+
strikethroughThickness: "strikethrough-thickness",
|
|
132
|
+
strokeDasharray: "stroke-dasharray",
|
|
133
|
+
strokeDashoffset: "stroke-dashoffset",
|
|
134
|
+
strokeLinecap: "stroke-linecap",
|
|
135
|
+
strokeLinejoin: "stroke-linejoin",
|
|
136
|
+
strokeMiterlimit: "stroke-miterlimit",
|
|
137
|
+
strokeOpacity: "stroke-opacity",
|
|
138
|
+
strokeWidth: "stroke-width",
|
|
139
|
+
textAnchor: "text-anchor",
|
|
140
|
+
textDecoration: "text-decoration",
|
|
141
|
+
textRendering: "text-rendering",
|
|
142
|
+
underlinePosition: "underline-position",
|
|
143
|
+
underlineThickness: "underline-thickness",
|
|
144
|
+
unicodeBidi: "unicode-bidi",
|
|
145
|
+
unicodeRange: "unicode-range",
|
|
146
|
+
unitsPerEm: "units-per-em",
|
|
147
|
+
vAlphabetic: "v-alphabetic",
|
|
148
|
+
vHanging: "v-hanging",
|
|
149
|
+
vIdeographic: "v-ideographic",
|
|
150
|
+
vMathematical: "v-mathematical",
|
|
151
|
+
vertAdvY: "vert-adv-y",
|
|
152
|
+
vertOriginX: "vert-origin-x",
|
|
153
|
+
vertOriginY: "vert-origin-y",
|
|
154
|
+
wordSpacing: "word-spacing",
|
|
155
|
+
writingMode: "writing-mode",
|
|
156
|
+
xHeight: "x-height",
|
|
157
|
+
|
|
158
|
+
// Namespace-prefixed
|
|
159
|
+
xlinkActuate: "xlink:actuate",
|
|
160
|
+
xlinkArcrole: "xlink:arcrole",
|
|
161
|
+
xlinkHref: "xlink:href",
|
|
162
|
+
xlinkRole: "xlink:role",
|
|
163
|
+
xlinkShow: "xlink:show",
|
|
164
|
+
xlinkTitle: "xlink:title",
|
|
165
|
+
xlinkType: "xlink:type",
|
|
166
|
+
xmlBase: "xml:base",
|
|
167
|
+
xmlLang: "xml:lang",
|
|
168
|
+
xmlSpace: "xml:space",
|
|
169
|
+
xmlns: "xmlns",
|
|
170
|
+
xmlnsXlink: "xmlns:xlink",
|
|
171
|
+
|
|
172
|
+
// Filter / lighting
|
|
173
|
+
baseFrequency: "baseFrequency",
|
|
174
|
+
colorInterpolation_filters: "color-interpolation-filters",
|
|
175
|
+
diffuseConstant: "diffuseConstant",
|
|
176
|
+
edgeMode: "edgeMode",
|
|
177
|
+
filterUnits: "filterUnits",
|
|
178
|
+
gradientTransform: "gradientTransform",
|
|
179
|
+
gradientUnits: "gradientUnits",
|
|
180
|
+
kernelMatrix: "kernelMatrix",
|
|
181
|
+
kernelUnitLength: "kernelUnitLength",
|
|
182
|
+
lengthAdjust: "lengthAdjust",
|
|
183
|
+
limitingConeAngle: "limitingConeAngle",
|
|
184
|
+
markerHeight: "markerHeight",
|
|
185
|
+
markerWidth: "markerWidth",
|
|
186
|
+
maskContentUnits: "maskContentUnits",
|
|
187
|
+
maskUnits: "maskUnits",
|
|
188
|
+
numOctaves: "numOctaves",
|
|
189
|
+
pathLength: "pathLength",
|
|
190
|
+
patternContentUnits: "patternContentUnits",
|
|
191
|
+
patternTransform: "patternTransform",
|
|
192
|
+
patternUnits: "patternUnits",
|
|
193
|
+
pointsAtX: "pointsAtX",
|
|
194
|
+
pointsAtY: "pointsAtY",
|
|
195
|
+
pointsAtZ: "pointsAtZ",
|
|
196
|
+
preserveAspectRatio: "preserveAspectRatio",
|
|
197
|
+
primitiveUnits: "primitiveUnits",
|
|
198
|
+
refX: "refX",
|
|
199
|
+
refY: "refY",
|
|
200
|
+
repeatCount: "repeatCount",
|
|
201
|
+
repeatDur: "repeatDur",
|
|
202
|
+
specularConstant: "specularConstant",
|
|
203
|
+
specularExponent: "specularExponent",
|
|
204
|
+
spreadMethod: "spreadMethod",
|
|
205
|
+
startOffset: "startOffset",
|
|
206
|
+
stdDeviation: "stdDeviation",
|
|
207
|
+
stitchTiles: "stitchTiles",
|
|
208
|
+
surfaceScale: "surfaceScale",
|
|
209
|
+
systemLanguage: "systemLanguage",
|
|
210
|
+
tableValues: "tableValues",
|
|
211
|
+
targetX: "targetX",
|
|
212
|
+
targetY: "targetY",
|
|
213
|
+
textLength: "textLength",
|
|
214
|
+
viewBox: "viewBox",
|
|
215
|
+
xChannelSelector: "xChannelSelector",
|
|
216
|
+
yChannelSelector: "yChannelSelector",
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** Set of known SVG element tag names. */
|
|
220
|
+
const SVG_ELEMENTS = new Set([
|
|
221
|
+
"svg", "animate", "animateMotion", "animateTransform", "circle",
|
|
222
|
+
"clipPath", "defs", "desc", "ellipse", "feBlend", "feColorMatrix",
|
|
223
|
+
"feComponentTransfer", "feComposite", "feConvolveMatrix",
|
|
224
|
+
"feDiffuseLighting", "feDisplacementMap", "feDistantLight",
|
|
225
|
+
"feDropShadow", "feFlood", "feFuncA", "feFuncB", "feFuncG",
|
|
226
|
+
"feFuncR", "feGaussianBlur", "feImage", "feMerge", "feMergeNode",
|
|
227
|
+
"feMorphology", "feOffset", "fePointLight", "feSpecularLighting",
|
|
228
|
+
"feSpotLight", "feTile", "feTurbulence", "filter", "foreignObject",
|
|
229
|
+
"g", "image", "line", "linearGradient", "marker", "mask",
|
|
230
|
+
"metadata", "mpath", "path", "pattern", "polygon", "polyline",
|
|
231
|
+
"radialGradient", "rect", "set", "stop", "switch", "symbol",
|
|
232
|
+
"text", "textPath", "title", "tspan", "use", "view",
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
function renderAttributes(props: Record<string, any>, isSvg: boolean): string {
|
|
236
|
+
let attrs = "";
|
|
237
|
+
for (const [key, value] of Object.entries(props)) {
|
|
238
|
+
// Skip internal / non-attribute props
|
|
239
|
+
if (
|
|
240
|
+
key === "children" ||
|
|
241
|
+
key === "key" ||
|
|
242
|
+
key === "ref" ||
|
|
243
|
+
key === "dangerouslySetInnerHTML"
|
|
244
|
+
)
|
|
245
|
+
continue;
|
|
246
|
+
// Skip event handlers (onClick, onChange, …)
|
|
247
|
+
if (key.startsWith("on") && key.length > 2 && key[2] === key[2]!.toUpperCase())
|
|
248
|
+
continue;
|
|
249
|
+
|
|
250
|
+
// Prop-name mapping
|
|
251
|
+
let attrName: string;
|
|
252
|
+
if (isSvg && key in SVG_ATTR_MAP) {
|
|
253
|
+
attrName = SVG_ATTR_MAP[key]!;
|
|
254
|
+
} else {
|
|
255
|
+
attrName =
|
|
256
|
+
key === "className"
|
|
257
|
+
? "class"
|
|
258
|
+
: key === "htmlFor"
|
|
259
|
+
? "for"
|
|
260
|
+
: key === "tabIndex"
|
|
261
|
+
? "tabindex"
|
|
262
|
+
: key;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (value === false || value == null) continue;
|
|
266
|
+
if (value === true) {
|
|
267
|
+
attrs += ` ${attrName}`;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (key === "style" && typeof value === "object") {
|
|
271
|
+
attrs += ` style="${escapeAttr(styleObjectToString(value))}"`;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
attrs += ` ${attrName}="${escapeAttr(String(value))}"`;
|
|
275
|
+
}
|
|
276
|
+
return attrs;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Writer abstraction (stream vs buffer)
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
interface Writer {
|
|
284
|
+
write(chunk: string): void;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
class BufferWriter implements Writer {
|
|
288
|
+
chunks: string[] = [];
|
|
289
|
+
write(chunk: string) {
|
|
290
|
+
this.chunks.push(chunk);
|
|
291
|
+
}
|
|
292
|
+
flush(target: Writer) {
|
|
293
|
+
for (const c of this.chunks) target.write(c);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Core recursive renderer (sync-first design)
|
|
299
|
+
//
|
|
300
|
+
// `renderNode` is synchronous for the fast path (plain HTML elements,
|
|
301
|
+
// text, fragments, pure function components). It only returns a
|
|
302
|
+
// Promise when something actually async happens (Suspense throw,
|
|
303
|
+
// async component). This eliminates thousands of unnecessary
|
|
304
|
+
// microtask bounces for a typical component tree.
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
type MaybePromise = void | Promise<void>;
|
|
308
|
+
|
|
309
|
+
function renderNode(
|
|
310
|
+
node: SlimNode,
|
|
311
|
+
writer: Writer,
|
|
312
|
+
isSvg = false,
|
|
313
|
+
): MaybePromise {
|
|
314
|
+
// --- primitives / nullish ---
|
|
315
|
+
if (node == null || typeof node === "boolean") return;
|
|
316
|
+
if (typeof node === "string") {
|
|
317
|
+
writer.write(escapeHtml(node));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (typeof node === "number") {
|
|
321
|
+
writer.write(String(node));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- arrays ---
|
|
326
|
+
if (Array.isArray(node)) {
|
|
327
|
+
return renderChildArray(node, writer, isSvg);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- iterables (Set, generator, …) ---
|
|
331
|
+
if (
|
|
332
|
+
typeof node === "object" &&
|
|
333
|
+
node !== null &&
|
|
334
|
+
Symbol.iterator in node &&
|
|
335
|
+
!("$$typeof" in node)
|
|
336
|
+
) {
|
|
337
|
+
return renderChildArray(
|
|
338
|
+
Array.from(node as Iterable<SlimNode>),
|
|
339
|
+
writer,
|
|
340
|
+
isSvg,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// --- SlimElement ---
|
|
345
|
+
if (
|
|
346
|
+
typeof node === "object" &&
|
|
347
|
+
node !== null &&
|
|
348
|
+
"$$typeof" in node &&
|
|
349
|
+
(node as SlimElement).$$typeof === SLIM_ELEMENT
|
|
350
|
+
) {
|
|
351
|
+
const element = node as SlimElement;
|
|
352
|
+
const { type, props } = element;
|
|
353
|
+
|
|
354
|
+
// Fragment
|
|
355
|
+
if (type === FRAGMENT_TYPE) {
|
|
356
|
+
return renderChildren(props.children, writer, isSvg);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Suspense – always async
|
|
360
|
+
if (type === SUSPENSE_TYPE) {
|
|
361
|
+
return renderSuspense(props, writer, isSvg);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Function / class component
|
|
365
|
+
if (typeof type === "function") {
|
|
366
|
+
return renderComponent(type, props, writer, isSvg);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// HTML / SVG element
|
|
370
|
+
if (typeof type === "string") {
|
|
371
|
+
return renderHostElement(type, props, writer, isSvg);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Render a host (HTML/SVG) element. Sync when children are sync. */
|
|
377
|
+
function renderHostElement(
|
|
378
|
+
tag: string,
|
|
379
|
+
props: Record<string, any>,
|
|
380
|
+
writer: Writer,
|
|
381
|
+
isSvg: boolean,
|
|
382
|
+
): MaybePromise {
|
|
383
|
+
const enteringSvg = tag === "svg";
|
|
384
|
+
const childSvg = isSvg || enteringSvg;
|
|
385
|
+
|
|
386
|
+
const effectiveProps =
|
|
387
|
+
enteringSvg && !props.xmlns
|
|
388
|
+
? { xmlns: "http://www.w3.org/2000/svg", ...props }
|
|
389
|
+
: props;
|
|
390
|
+
|
|
391
|
+
writer.write(`<${tag}${renderAttributes(effectiveProps, childSvg)}>`);
|
|
392
|
+
|
|
393
|
+
const childContext = tag === "foreignObject" ? false : childSvg;
|
|
394
|
+
|
|
395
|
+
let inner: MaybePromise = undefined;
|
|
396
|
+
if (props.dangerouslySetInnerHTML) {
|
|
397
|
+
writer.write(props.dangerouslySetInnerHTML.__html);
|
|
398
|
+
} else {
|
|
399
|
+
inner = renderChildren(props.children, writer, childContext);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
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
|
+
});
|
|
406
|
+
}
|
|
407
|
+
if (!VOID_ELEMENTS.has(tag)) writer.write(`</${tag}>`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// React special $$typeof symbols for memo, forwardRef, and context/provider
|
|
411
|
+
const REACT_MEMO = Symbol.for("react.memo");
|
|
412
|
+
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
|
|
415
|
+
|
|
416
|
+
/** Render a function or class component. */
|
|
417
|
+
function renderComponent(
|
|
418
|
+
type: Function,
|
|
419
|
+
props: Record<string, any>,
|
|
420
|
+
writer: Writer,
|
|
421
|
+
isSvg: boolean,
|
|
422
|
+
): MaybePromise {
|
|
423
|
+
const typeOf = (type as any)?.$$typeof;
|
|
424
|
+
|
|
425
|
+
// React.memo — unwrap and re-render the inner type
|
|
426
|
+
if (typeOf === REACT_MEMO) {
|
|
427
|
+
return renderNode(
|
|
428
|
+
{ $$typeof: SLIM_ELEMENT, type: (type as any).type, props, key: null } as any,
|
|
429
|
+
writer, isSvg,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// React.forwardRef — call the wrapped render function
|
|
434
|
+
if (typeOf === REACT_FORWARD_REF) {
|
|
435
|
+
return renderComponent((type as any).render, props, writer, isSvg);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Provider detection:
|
|
439
|
+
// slim-react: Provider function has `_context` property
|
|
440
|
+
// React 18: Provider object has $$typeof === react.provider and ._context
|
|
441
|
+
// React 19: Context object itself is the provider ($$typeof === react.context + value prop)
|
|
442
|
+
const isProvider =
|
|
443
|
+
"_context" in type ||
|
|
444
|
+
typeOf === REACT_PROVIDER ||
|
|
445
|
+
(typeOf === REACT_CONTEXT && "value" in props);
|
|
446
|
+
|
|
447
|
+
let prevCtxValue: any;
|
|
448
|
+
let ctx: any;
|
|
449
|
+
|
|
450
|
+
if (isProvider) {
|
|
451
|
+
// Resolve the actual context object from any provider variant
|
|
452
|
+
ctx = (type as any)._context ?? type;
|
|
453
|
+
prevCtxValue = ctx._currentValue;
|
|
454
|
+
ctx._currentValue = props.value;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Each component gets a fresh local-ID counter (for multiple useId calls).
|
|
458
|
+
const savedScope = pushComponentScope();
|
|
459
|
+
|
|
460
|
+
let result: SlimNode;
|
|
461
|
+
try {
|
|
462
|
+
if (type.prototype && typeof type.prototype.render === "function") {
|
|
463
|
+
const instance = new (type as any)(props);
|
|
464
|
+
result = instance.render();
|
|
465
|
+
} else {
|
|
466
|
+
result = type(props);
|
|
467
|
+
}
|
|
468
|
+
} catch (e) {
|
|
469
|
+
popComponentScope(savedScope);
|
|
470
|
+
if (isProvider) ctx._currentValue = prevCtxValue;
|
|
471
|
+
throw e;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const finish = () => {
|
|
475
|
+
popComponentScope(savedScope);
|
|
476
|
+
if (isProvider) ctx._currentValue = prevCtxValue;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// Async component
|
|
480
|
+
if (result instanceof Promise) {
|
|
481
|
+
return result.then((resolved) => {
|
|
482
|
+
const r = renderNode(resolved, writer, isSvg);
|
|
483
|
+
if (r && typeof (r as any).then === "function") {
|
|
484
|
+
return (r as Promise<void>).then(finish);
|
|
485
|
+
}
|
|
486
|
+
finish();
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const r = renderNode(result, writer, isSvg);
|
|
491
|
+
|
|
492
|
+
if (r && typeof (r as any).then === "function") {
|
|
493
|
+
return (r as Promise<void>).then(finish);
|
|
494
|
+
}
|
|
495
|
+
finish();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Render an array of children, pushing tree-context for each child
|
|
500
|
+
* so that `useId` produces deterministic, position-based IDs.
|
|
501
|
+
* Goes async only when a child actually returns a Promise.
|
|
502
|
+
*/
|
|
503
|
+
/** Returns true for nodes that become DOM text nodes (string or number). */
|
|
504
|
+
function isTextLike(node: SlimNode): boolean {
|
|
505
|
+
return typeof node === "string" || typeof node === "number";
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function renderChildArray(
|
|
509
|
+
children: SlimNode[],
|
|
510
|
+
writer: Writer,
|
|
511
|
+
isSvg: boolean,
|
|
512
|
+
): MaybePromise {
|
|
513
|
+
const totalChildren = children.length;
|
|
514
|
+
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])) {
|
|
518
|
+
writer.write("<!-- -->");
|
|
519
|
+
}
|
|
520
|
+
const savedTree = pushTreeContext(totalChildren, i);
|
|
521
|
+
const r = renderNode(children[i], writer, isSvg);
|
|
522
|
+
if (r && typeof (r as any).then === "function") {
|
|
523
|
+
// One child went async – continue the rest asynchronously
|
|
524
|
+
return (r as Promise<void>).then(() => {
|
|
525
|
+
popTreeContext(savedTree);
|
|
526
|
+
// Continue with remaining children
|
|
527
|
+
return renderChildArrayFrom(children, i + 1, writer, isSvg);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
popTreeContext(savedTree);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Resume renderChildArray from a given index (after async child). */
|
|
535
|
+
function renderChildArrayFrom(
|
|
536
|
+
children: SlimNode[],
|
|
537
|
+
startIndex: number,
|
|
538
|
+
writer: Writer,
|
|
539
|
+
isSvg: boolean,
|
|
540
|
+
): MaybePromise {
|
|
541
|
+
const totalChildren = children.length;
|
|
542
|
+
for (let i = startIndex; i < totalChildren; i++) {
|
|
543
|
+
if (i > 0 && isTextLike(children[i]) && isTextLike(children[i - 1])) {
|
|
544
|
+
writer.write("<!-- -->");
|
|
545
|
+
}
|
|
546
|
+
const savedTree = pushTreeContext(totalChildren, i);
|
|
547
|
+
const r = renderNode(children[i], writer, isSvg);
|
|
548
|
+
if (r && typeof (r as any).then === "function") {
|
|
549
|
+
return (r as Promise<void>).then(() => {
|
|
550
|
+
popTreeContext(savedTree);
|
|
551
|
+
return renderChildArrayFrom(children, i + 1, writer, isSvg);
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
popTreeContext(savedTree);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function renderChildren(
|
|
559
|
+
children: SlimNode,
|
|
560
|
+
writer: Writer,
|
|
561
|
+
isSvg = false,
|
|
562
|
+
): MaybePromise {
|
|
563
|
+
if (children == null) return;
|
|
564
|
+
if (Array.isArray(children)) {
|
|
565
|
+
return renderChildArray(children, writer, isSvg);
|
|
566
|
+
}
|
|
567
|
+
return renderNode(children, writer, isSvg);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
// Suspense boundary renderer
|
|
572
|
+
//
|
|
573
|
+
// Sibling Suspense boundaries within the same parent are resolved
|
|
574
|
+
// **in parallel**: we kick off all of them concurrently and stream
|
|
575
|
+
// their results in document order once each resolves.
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
const MAX_SUSPENSE_RETRIES = 25;
|
|
579
|
+
|
|
580
|
+
async function renderSuspense(
|
|
581
|
+
props: Record<string, any>,
|
|
582
|
+
writer: Writer,
|
|
583
|
+
isSvg = false,
|
|
584
|
+
): Promise<void> {
|
|
585
|
+
const { children, fallback } = props;
|
|
586
|
+
let attempts = 0;
|
|
587
|
+
|
|
588
|
+
// Snapshot the render context so we can reset between retries.
|
|
589
|
+
const snap = snapshotContext();
|
|
590
|
+
|
|
591
|
+
while (attempts < MAX_SUSPENSE_RETRIES) {
|
|
592
|
+
// Restore context to the state it was in when we entered <Suspense>.
|
|
593
|
+
restoreContext(snap);
|
|
594
|
+
try {
|
|
595
|
+
const buffer = new BufferWriter();
|
|
596
|
+
const r = renderNode(children, buffer, isSvg);
|
|
597
|
+
if (r && typeof (r as any).then === "function") {
|
|
598
|
+
await r;
|
|
599
|
+
}
|
|
600
|
+
// Success – wrap with React's Suspense boundary markers so hydrateRoot
|
|
601
|
+
// can locate the boundary in the DOM (<!--$--> … <!--/$-->).
|
|
602
|
+
writer.write("<!--$-->");
|
|
603
|
+
buffer.flush(writer);
|
|
604
|
+
writer.write("<!--/$-->");
|
|
605
|
+
return;
|
|
606
|
+
} catch (error: unknown) {
|
|
607
|
+
if (error && typeof (error as any).then === "function") {
|
|
608
|
+
await (error as Promise<unknown>);
|
|
609
|
+
attempts++;
|
|
610
|
+
} else {
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Exhausted retries → render the fallback (boundary stays in loading state).
|
|
617
|
+
restoreContext(snap);
|
|
618
|
+
writer.write("<!--$?-->");
|
|
619
|
+
if (fallback) {
|
|
620
|
+
const r = renderNode(fallback, writer, isSvg);
|
|
621
|
+
if (r && typeof (r as any).then === "function") await r;
|
|
622
|
+
}
|
|
623
|
+
writer.write("<!--/$-->");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
// Public API
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Render a component tree to a `ReadableStream<Uint8Array>`.
|
|
632
|
+
*
|
|
633
|
+
* The stream pauses at `<Suspense>` boundaries until the suspended
|
|
634
|
+
* promise resolves, then continues writing HTML.
|
|
635
|
+
*/
|
|
636
|
+
export function renderToStream(element: SlimNode): ReadableStream<Uint8Array> {
|
|
637
|
+
const encoder = new TextEncoder();
|
|
638
|
+
|
|
639
|
+
return new ReadableStream({
|
|
640
|
+
async start(controller) {
|
|
641
|
+
resetRenderState();
|
|
642
|
+
|
|
643
|
+
const writer: Writer = {
|
|
644
|
+
write(chunk: string) {
|
|
645
|
+
controller.enqueue(encoder.encode(chunk));
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
const r = renderNode(element, writer);
|
|
651
|
+
if (r && typeof (r as any).then === "function") await r;
|
|
652
|
+
controller.close();
|
|
653
|
+
} catch (error) {
|
|
654
|
+
controller.error(error);
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Convenience: render to a complete HTML string.
|
|
662
|
+
* Retries the full tree when a component throws a Promise (Suspense protocol),
|
|
663
|
+
* so useServerData and similar hooks work without requiring explicit <Suspense>.
|
|
664
|
+
*/
|
|
665
|
+
export async function renderToString(element: SlimNode): Promise<string> {
|
|
666
|
+
for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
|
|
667
|
+
resetRenderState();
|
|
668
|
+
const chunks: string[] = [];
|
|
669
|
+
const writer: Writer = { write(c) { chunks.push(c); } };
|
|
670
|
+
try {
|
|
671
|
+
const r = renderNode(element, writer);
|
|
672
|
+
if (r && typeof (r as any).then === "function") await r;
|
|
673
|
+
return chunks.join("");
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (error && typeof (error as any).then === "function") {
|
|
676
|
+
await (error as Promise<unknown>);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
throw error;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
throw new Error("[slim-react] renderToString exceeded maximum retries");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Alias matching React 18+ server API naming. */
|
|
686
|
+
export { renderToStream as renderToReadableStream };
|