hadars 0.1.29 → 0.1.31
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/cli.js +28 -39
- package/dist/index.cjs +1 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -4
- package/dist/slim-react/index.cjs +16 -11
- package/dist/slim-react/index.d.ts +11 -3
- package/dist/slim-react/index.js +16 -11
- package/dist/ssr-render-worker.js +18 -25
- package/dist/utils/Head.tsx +6 -8
- package/package.json +5 -3
- package/src/build.ts +13 -8
- package/src/slim-react/dispatcher.ts +17 -2
- package/src/slim-react/index.ts +1 -1
- package/src/slim-react/render.ts +24 -6
- package/src/slim-react/renderContext.ts +8 -8
- package/src/ssr-render-worker.ts +11 -16
- package/src/utils/Head.tsx +6 -8
- package/src/utils/response.tsx +8 -23
package/dist/cli.js
CHANGED
|
@@ -141,7 +141,6 @@ var FRAGMENT_TYPE = Symbol.for("react.fragment");
|
|
|
141
141
|
var SUSPENSE_TYPE = Symbol.for("react.suspense");
|
|
142
142
|
|
|
143
143
|
// src/slim-react/jsx.ts
|
|
144
|
-
var Fragment = FRAGMENT_TYPE;
|
|
145
144
|
function createElement(type, props, ...children) {
|
|
146
145
|
const normalizedProps = { ...props || {} };
|
|
147
146
|
if (children.length === 1) {
|
|
@@ -198,10 +197,11 @@ function s() {
|
|
|
198
197
|
}
|
|
199
198
|
return g[GLOBAL_KEY];
|
|
200
199
|
}
|
|
201
|
-
function resetRenderState() {
|
|
200
|
+
function resetRenderState(idPrefix = "") {
|
|
202
201
|
const st = s();
|
|
203
202
|
st.currentTreeContext = { ...EMPTY };
|
|
204
203
|
st.localIdCounter = 0;
|
|
204
|
+
st.idPrefix = idPrefix;
|
|
205
205
|
}
|
|
206
206
|
function pushTreeContext(totalChildren, index) {
|
|
207
207
|
const st = s();
|
|
@@ -265,10 +265,10 @@ function makeId() {
|
|
|
265
265
|
const st = s();
|
|
266
266
|
const treeId = getTreeId();
|
|
267
267
|
const n = st.localIdCounter++;
|
|
268
|
-
let id = "
|
|
268
|
+
let id = "_R_" + st.idPrefix + treeId;
|
|
269
269
|
if (n > 0)
|
|
270
270
|
id += "H" + n.toString(32);
|
|
271
|
-
return id + "
|
|
271
|
+
return id + "_";
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
// src/slim-react/hooks.ts
|
|
@@ -330,8 +330,8 @@ function use(usable) {
|
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
// src/slim-react/dispatcher.ts
|
|
333
|
-
import
|
|
334
|
-
var _internals =
|
|
333
|
+
import * as ReactNS from "react";
|
|
334
|
+
var _internals = ReactNS.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
|
335
335
|
var slimDispatcher = {
|
|
336
336
|
useId: makeId,
|
|
337
337
|
readContext: (ctx) => getContextValue(ctx),
|
|
@@ -558,7 +558,9 @@ function renderAttributes(props, isSvg) {
|
|
|
558
558
|
continue;
|
|
559
559
|
}
|
|
560
560
|
if (key === "style" && typeof value === "object") {
|
|
561
|
-
|
|
561
|
+
const styleStr = styleObjectToString(value);
|
|
562
|
+
if (styleStr)
|
|
563
|
+
attrs += ` style="${escapeAttr(styleStr)}"`;
|
|
562
564
|
continue;
|
|
563
565
|
}
|
|
564
566
|
attrs += ` ${attrName}="${escapeAttr(String(value))}"`;
|
|
@@ -915,8 +917,8 @@ async function renderSuspense(props, writer, isSvg = false) {
|
|
|
915
917
|
const snap = snapshotContext();
|
|
916
918
|
while (attempts < MAX_SUSPENSE_RETRIES) {
|
|
917
919
|
restoreContext(snap);
|
|
920
|
+
let buffer = new BufferWriter();
|
|
918
921
|
try {
|
|
919
|
-
const buffer = new BufferWriter();
|
|
920
922
|
const r = renderNode(children, buffer, isSvg);
|
|
921
923
|
if (r && typeof r.then === "function") {
|
|
922
924
|
const m = captureMap();
|
|
@@ -950,12 +952,13 @@ async function renderSuspense(props, writer, isSvg = false) {
|
|
|
950
952
|
}
|
|
951
953
|
writer.write("<!--/$-->");
|
|
952
954
|
}
|
|
953
|
-
async function renderToString(element) {
|
|
955
|
+
async function renderToString(element, options) {
|
|
956
|
+
const idPrefix = options?.identifierPrefix ?? "";
|
|
954
957
|
const contextMap = /* @__PURE__ */ new Map();
|
|
955
958
|
const prev = swapContextMap(contextMap);
|
|
956
959
|
try {
|
|
957
960
|
for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
|
|
958
|
-
resetRenderState();
|
|
961
|
+
resetRenderState(idPrefix);
|
|
959
962
|
swapContextMap(contextMap);
|
|
960
963
|
const chunks = [];
|
|
961
964
|
const writer = {
|
|
@@ -1072,31 +1075,14 @@ var getReactResponse = async (req, opts) => {
|
|
|
1072
1075
|
location: req.location,
|
|
1073
1076
|
...Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}
|
|
1074
1077
|
};
|
|
1075
|
-
const
|
|
1076
|
-
Fragment,
|
|
1077
|
-
null,
|
|
1078
|
-
createElement(
|
|
1079
|
-
"div",
|
|
1080
|
-
{ id: "app" },
|
|
1081
|
-
createElement(App, { ...props, location: req.location, context })
|
|
1082
|
-
),
|
|
1083
|
-
createElement("script", {
|
|
1084
|
-
id: "hadars",
|
|
1085
|
-
type: "application/json",
|
|
1086
|
-
dangerouslySetInnerHTML: {
|
|
1087
|
-
__html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c")
|
|
1088
|
-
}
|
|
1089
|
-
})
|
|
1090
|
-
);
|
|
1078
|
+
const appProps = { ...props, location: req.location, context };
|
|
1091
1079
|
return {
|
|
1092
|
-
|
|
1080
|
+
App,
|
|
1081
|
+
appProps,
|
|
1082
|
+
clientProps,
|
|
1093
1083
|
unsuspend,
|
|
1094
1084
|
status: context.head.status,
|
|
1095
|
-
headHtml: getHeadHtml(context.head)
|
|
1096
|
-
renderPayload: {
|
|
1097
|
-
appProps: { ...props, location: req.location, context },
|
|
1098
|
-
clientProps
|
|
1099
|
-
}
|
|
1085
|
+
headHtml: getHeadHtml(context.head)
|
|
1100
1086
|
};
|
|
1101
1087
|
};
|
|
1102
1088
|
|
|
@@ -1830,7 +1816,7 @@ var RenderWorkerPool = class {
|
|
|
1830
1816
|
await Promise.all(this.workers.map((w) => w.terminate()));
|
|
1831
1817
|
}
|
|
1832
1818
|
};
|
|
1833
|
-
async function buildSsrResponse(
|
|
1819
|
+
async function buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspendForRender) {
|
|
1834
1820
|
const responseStream = new ReadableStream({
|
|
1835
1821
|
async start(controller) {
|
|
1836
1822
|
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
@@ -1838,12 +1824,15 @@ async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml,
|
|
|
1838
1824
|
let bodyHtml;
|
|
1839
1825
|
try {
|
|
1840
1826
|
globalThis.__hadarsUnsuspend = unsuspendForRender;
|
|
1841
|
-
bodyHtml = await renderToString(
|
|
1827
|
+
bodyHtml = await renderToString(createElement(App, appProps));
|
|
1842
1828
|
} finally {
|
|
1843
1829
|
globalThis.__hadarsUnsuspend = null;
|
|
1844
1830
|
}
|
|
1845
1831
|
bodyHtml = processSegmentCache(bodyHtml);
|
|
1846
|
-
|
|
1832
|
+
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c");
|
|
1833
|
+
controller.enqueue(encoder.encode(
|
|
1834
|
+
`<div id="app">${bodyHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>` + postContent
|
|
1835
|
+
));
|
|
1847
1836
|
controller.close();
|
|
1848
1837
|
}
|
|
1849
1838
|
});
|
|
@@ -2213,7 +2202,7 @@ var dev = async (options) => {
|
|
|
2213
2202
|
getAfterRenderProps,
|
|
2214
2203
|
getFinalProps
|
|
2215
2204
|
} = await import(importPath);
|
|
2216
|
-
const {
|
|
2205
|
+
const { App, appProps, clientProps, unsuspend, status, headHtml } = await getReactResponse(request, {
|
|
2217
2206
|
document: {
|
|
2218
2207
|
body: Component,
|
|
2219
2208
|
lang: "en",
|
|
@@ -2222,7 +2211,7 @@ var dev = async (options) => {
|
|
|
2222
2211
|
getFinalProps
|
|
2223
2212
|
}
|
|
2224
2213
|
});
|
|
2225
|
-
return buildSsrResponse(
|
|
2214
|
+
return buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspend);
|
|
2226
2215
|
} catch (err) {
|
|
2227
2216
|
console.error("[hadars] SSR render error:", err);
|
|
2228
2217
|
const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, "<");
|
|
@@ -2367,7 +2356,7 @@ var run = async (options) => {
|
|
|
2367
2356
|
status: wStatus
|
|
2368
2357
|
});
|
|
2369
2358
|
}
|
|
2370
|
-
const {
|
|
2359
|
+
const { App, appProps, clientProps, unsuspend, status, headHtml } = await getReactResponse(request, {
|
|
2371
2360
|
document: {
|
|
2372
2361
|
body: Component,
|
|
2373
2362
|
lang: "en",
|
|
@@ -2376,7 +2365,7 @@ var run = async (options) => {
|
|
|
2376
2365
|
getFinalProps
|
|
2377
2366
|
}
|
|
2378
2367
|
});
|
|
2379
|
-
return buildSsrResponse(
|
|
2368
|
+
return buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspend);
|
|
2380
2369
|
} catch (err) {
|
|
2381
2370
|
console.error("[hadars] SSR render error:", err);
|
|
2382
2371
|
return new Response("Internal Server Error", { status: 500 });
|
package/dist/index.cjs
CHANGED
|
@@ -183,10 +183,7 @@ function initServerDataCache(data) {
|
|
|
183
183
|
function useServerData(key, fn) {
|
|
184
184
|
const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
|
|
185
185
|
if (typeof window !== "undefined") {
|
|
186
|
-
|
|
187
|
-
return clientServerDataCache.get(cacheKey);
|
|
188
|
-
}
|
|
189
|
-
return fn();
|
|
186
|
+
return clientServerDataCache.get(cacheKey);
|
|
190
187
|
}
|
|
191
188
|
const unsuspend = globalThis.__hadarsUnsuspend;
|
|
192
189
|
if (!unsuspend)
|
package/dist/index.d.ts
CHANGED
|
@@ -205,6 +205,9 @@ declare function initServerDataCache(data: Record<string, unknown>): void;
|
|
|
205
205
|
* awaited, the cache entry is then cleared so that the next render re-calls
|
|
206
206
|
* `fn()` — at that point the Suspense hook returns synchronously.
|
|
207
207
|
*
|
|
208
|
+
* `fn` is **server-only**: it is never called in the browser. The resolved value
|
|
209
|
+
* is serialised into `__serverData` and returned from cache during hydration.
|
|
210
|
+
*
|
|
208
211
|
* @example
|
|
209
212
|
* const user = useServerData('current_user', () => db.getUser(id));
|
|
210
213
|
* const post = useServerData(['post', postId], () => db.getPost(postId));
|
package/dist/index.js
CHANGED
|
@@ -140,10 +140,7 @@ function initServerDataCache(data) {
|
|
|
140
140
|
function useServerData(key, fn) {
|
|
141
141
|
const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
|
|
142
142
|
if (typeof window !== "undefined") {
|
|
143
|
-
|
|
144
|
-
return clientServerDataCache.get(cacheKey);
|
|
145
|
-
}
|
|
146
|
-
return fn();
|
|
143
|
+
return clientServerDataCache.get(cacheKey);
|
|
147
144
|
}
|
|
148
145
|
const unsuspend = globalThis.__hadarsUnsuspend;
|
|
149
146
|
if (!unsuspend)
|
|
@@ -148,10 +148,11 @@ function s() {
|
|
|
148
148
|
}
|
|
149
149
|
return g[GLOBAL_KEY];
|
|
150
150
|
}
|
|
151
|
-
function resetRenderState() {
|
|
151
|
+
function resetRenderState(idPrefix = "") {
|
|
152
152
|
const st = s();
|
|
153
153
|
st.currentTreeContext = { ...EMPTY };
|
|
154
154
|
st.localIdCounter = 0;
|
|
155
|
+
st.idPrefix = idPrefix;
|
|
155
156
|
}
|
|
156
157
|
function pushTreeContext(totalChildren, index) {
|
|
157
158
|
const st = s();
|
|
@@ -215,10 +216,10 @@ function makeId() {
|
|
|
215
216
|
const st = s();
|
|
216
217
|
const treeId = getTreeId();
|
|
217
218
|
const n = st.localIdCounter++;
|
|
218
|
-
let id = "
|
|
219
|
+
let id = "_R_" + st.idPrefix + treeId;
|
|
219
220
|
if (n > 0)
|
|
220
221
|
id += "H" + n.toString(32);
|
|
221
|
-
return id + "
|
|
222
|
+
return id + "_";
|
|
222
223
|
}
|
|
223
224
|
|
|
224
225
|
// src/slim-react/hooks.ts
|
|
@@ -312,8 +313,8 @@ function createContext(defaultValue) {
|
|
|
312
313
|
}
|
|
313
314
|
|
|
314
315
|
// src/slim-react/dispatcher.ts
|
|
315
|
-
var
|
|
316
|
-
var _internals =
|
|
316
|
+
var ReactNS = __toESM(require("react"), 1);
|
|
317
|
+
var _internals = ReactNS.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
|
317
318
|
var slimDispatcher = {
|
|
318
319
|
useId: makeId,
|
|
319
320
|
readContext: (ctx) => getContextValue(ctx),
|
|
@@ -540,7 +541,9 @@ function renderAttributes(props, isSvg) {
|
|
|
540
541
|
continue;
|
|
541
542
|
}
|
|
542
543
|
if (key === "style" && typeof value === "object") {
|
|
543
|
-
|
|
544
|
+
const styleStr = styleObjectToString(value);
|
|
545
|
+
if (styleStr)
|
|
546
|
+
attrs += ` style="${escapeAttr(styleStr)}"`;
|
|
544
547
|
continue;
|
|
545
548
|
}
|
|
546
549
|
attrs += ` ${attrName}="${escapeAttr(String(value))}"`;
|
|
@@ -897,8 +900,8 @@ async function renderSuspense(props, writer, isSvg = false) {
|
|
|
897
900
|
const snap = snapshotContext();
|
|
898
901
|
while (attempts < MAX_SUSPENSE_RETRIES) {
|
|
899
902
|
restoreContext(snap);
|
|
903
|
+
let buffer = new BufferWriter();
|
|
900
904
|
try {
|
|
901
|
-
const buffer = new BufferWriter();
|
|
902
905
|
const r = renderNode(children, buffer, isSvg);
|
|
903
906
|
if (r && typeof r.then === "function") {
|
|
904
907
|
const m = captureMap();
|
|
@@ -932,12 +935,13 @@ async function renderSuspense(props, writer, isSvg = false) {
|
|
|
932
935
|
}
|
|
933
936
|
writer.write("<!--/$-->");
|
|
934
937
|
}
|
|
935
|
-
function renderToStream(element) {
|
|
938
|
+
function renderToStream(element, options) {
|
|
936
939
|
const encoder = new TextEncoder();
|
|
940
|
+
const idPrefix = options?.identifierPrefix ?? "";
|
|
937
941
|
const contextMap = /* @__PURE__ */ new Map();
|
|
938
942
|
return new ReadableStream({
|
|
939
943
|
async start(controller) {
|
|
940
|
-
resetRenderState();
|
|
944
|
+
resetRenderState(idPrefix);
|
|
941
945
|
const prev = swapContextMap(contextMap);
|
|
942
946
|
const writer = {
|
|
943
947
|
lastWasText: false,
|
|
@@ -966,12 +970,13 @@ function renderToStream(element) {
|
|
|
966
970
|
}
|
|
967
971
|
});
|
|
968
972
|
}
|
|
969
|
-
async function renderToString(element) {
|
|
973
|
+
async function renderToString(element, options) {
|
|
974
|
+
const idPrefix = options?.identifierPrefix ?? "";
|
|
970
975
|
const contextMap = /* @__PURE__ */ new Map();
|
|
971
976
|
const prev = swapContextMap(contextMap);
|
|
972
977
|
try {
|
|
973
978
|
for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
|
|
974
|
-
resetRenderState();
|
|
979
|
+
resetRenderState(idPrefix);
|
|
975
980
|
swapContextMap(contextMap);
|
|
976
981
|
const chunks = [];
|
|
977
982
|
const writer = {
|
|
@@ -80,19 +80,27 @@ declare function createContext<T>(defaultValue: T): Context<T>;
|
|
|
80
80
|
* until the async data is ready, then continues – exactly as requested.
|
|
81
81
|
*/
|
|
82
82
|
|
|
83
|
+
interface RenderOptions {
|
|
84
|
+
/**
|
|
85
|
+
* Must match the `identifierPrefix` option passed to `hydrateRoot` on the
|
|
86
|
+
* client so that `useId()` generates identical IDs on server and client.
|
|
87
|
+
* Defaults to `""` (React's default).
|
|
88
|
+
*/
|
|
89
|
+
identifierPrefix?: string;
|
|
90
|
+
}
|
|
83
91
|
/**
|
|
84
92
|
* Render a component tree to a `ReadableStream<Uint8Array>`.
|
|
85
93
|
*
|
|
86
94
|
* The stream pauses at `<Suspense>` boundaries until the suspended
|
|
87
95
|
* promise resolves, then continues writing HTML.
|
|
88
96
|
*/
|
|
89
|
-
declare function renderToStream(element: SlimNode): ReadableStream<Uint8Array>;
|
|
97
|
+
declare function renderToStream(element: SlimNode, options?: RenderOptions): ReadableStream<Uint8Array>;
|
|
90
98
|
/**
|
|
91
99
|
* Convenience: render to a complete HTML string.
|
|
92
100
|
* Retries the full tree when a component throws a Promise (Suspense protocol),
|
|
93
101
|
* so useServerData and similar hooks work without requiring explicit <Suspense>.
|
|
94
102
|
*/
|
|
95
|
-
declare function renderToString(element: SlimNode): Promise<string>;
|
|
103
|
+
declare function renderToString(element: SlimNode, options?: RenderOptions): Promise<string>;
|
|
96
104
|
|
|
97
105
|
/**
|
|
98
106
|
* slim-react – a lightweight, SSR-only React-compatible runtime.
|
|
@@ -179,4 +187,4 @@ declare const React: {
|
|
|
179
187
|
version: string;
|
|
180
188
|
};
|
|
181
189
|
|
|
182
|
-
export { Children, Component, Context, PureComponent, SlimElement, SlimNode, Suspense, cloneElement, createContext, createElement, React as default, forwardRef, isValidElement, lazy, memo, renderToStream as renderToReadableStream, renderToStream, renderToString, startTransition, use, useActionState, useCallback, useContext, useDebugValue, useDeferredValue, useEffect, useFormStatus, useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, useMemo, useOptimistic, useReducer, useRef, useState, useSyncExternalStore, useTransition, version };
|
|
190
|
+
export { Children, Component, Context, PureComponent, RenderOptions, SlimElement, SlimNode, Suspense, cloneElement, createContext, createElement, React as default, forwardRef, isValidElement, lazy, memo, renderToStream as renderToReadableStream, renderToStream, renderToString, startTransition, use, useActionState, useCallback, useContext, useDebugValue, useDeferredValue, useEffect, useFormStatus, useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, useMemo, useOptimistic, useReducer, useRef, useState, useSyncExternalStore, useTransition, version };
|
package/dist/slim-react/index.js
CHANGED
|
@@ -47,10 +47,11 @@ function s() {
|
|
|
47
47
|
}
|
|
48
48
|
return g[GLOBAL_KEY];
|
|
49
49
|
}
|
|
50
|
-
function resetRenderState() {
|
|
50
|
+
function resetRenderState(idPrefix = "") {
|
|
51
51
|
const st = s();
|
|
52
52
|
st.currentTreeContext = { ...EMPTY };
|
|
53
53
|
st.localIdCounter = 0;
|
|
54
|
+
st.idPrefix = idPrefix;
|
|
54
55
|
}
|
|
55
56
|
function pushTreeContext(totalChildren, index) {
|
|
56
57
|
const st = s();
|
|
@@ -114,10 +115,10 @@ function makeId() {
|
|
|
114
115
|
const st = s();
|
|
115
116
|
const treeId = getTreeId();
|
|
116
117
|
const n = st.localIdCounter++;
|
|
117
|
-
let id = "
|
|
118
|
+
let id = "_R_" + st.idPrefix + treeId;
|
|
118
119
|
if (n > 0)
|
|
119
120
|
id += "H" + n.toString(32);
|
|
120
|
-
return id + "
|
|
121
|
+
return id + "_";
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
// src/slim-react/hooks.ts
|
|
@@ -211,8 +212,8 @@ function createContext(defaultValue) {
|
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
// src/slim-react/dispatcher.ts
|
|
214
|
-
import
|
|
215
|
-
var _internals =
|
|
215
|
+
import * as ReactNS from "react";
|
|
216
|
+
var _internals = ReactNS.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
|
216
217
|
var slimDispatcher = {
|
|
217
218
|
useId: makeId,
|
|
218
219
|
readContext: (ctx) => getContextValue(ctx),
|
|
@@ -439,7 +440,9 @@ function renderAttributes(props, isSvg) {
|
|
|
439
440
|
continue;
|
|
440
441
|
}
|
|
441
442
|
if (key === "style" && typeof value === "object") {
|
|
442
|
-
|
|
443
|
+
const styleStr = styleObjectToString(value);
|
|
444
|
+
if (styleStr)
|
|
445
|
+
attrs += ` style="${escapeAttr(styleStr)}"`;
|
|
443
446
|
continue;
|
|
444
447
|
}
|
|
445
448
|
attrs += ` ${attrName}="${escapeAttr(String(value))}"`;
|
|
@@ -796,8 +799,8 @@ async function renderSuspense(props, writer, isSvg = false) {
|
|
|
796
799
|
const snap = snapshotContext();
|
|
797
800
|
while (attempts < MAX_SUSPENSE_RETRIES) {
|
|
798
801
|
restoreContext(snap);
|
|
802
|
+
let buffer = new BufferWriter();
|
|
799
803
|
try {
|
|
800
|
-
const buffer = new BufferWriter();
|
|
801
804
|
const r = renderNode(children, buffer, isSvg);
|
|
802
805
|
if (r && typeof r.then === "function") {
|
|
803
806
|
const m = captureMap();
|
|
@@ -831,12 +834,13 @@ async function renderSuspense(props, writer, isSvg = false) {
|
|
|
831
834
|
}
|
|
832
835
|
writer.write("<!--/$-->");
|
|
833
836
|
}
|
|
834
|
-
function renderToStream(element) {
|
|
837
|
+
function renderToStream(element, options) {
|
|
835
838
|
const encoder = new TextEncoder();
|
|
839
|
+
const idPrefix = options?.identifierPrefix ?? "";
|
|
836
840
|
const contextMap = /* @__PURE__ */ new Map();
|
|
837
841
|
return new ReadableStream({
|
|
838
842
|
async start(controller) {
|
|
839
|
-
resetRenderState();
|
|
843
|
+
resetRenderState(idPrefix);
|
|
840
844
|
const prev = swapContextMap(contextMap);
|
|
841
845
|
const writer = {
|
|
842
846
|
lastWasText: false,
|
|
@@ -865,12 +869,13 @@ function renderToStream(element) {
|
|
|
865
869
|
}
|
|
866
870
|
});
|
|
867
871
|
}
|
|
868
|
-
async function renderToString(element) {
|
|
872
|
+
async function renderToString(element, options) {
|
|
873
|
+
const idPrefix = options?.identifierPrefix ?? "";
|
|
869
874
|
const contextMap = /* @__PURE__ */ new Map();
|
|
870
875
|
const prev = swapContextMap(contextMap);
|
|
871
876
|
try {
|
|
872
877
|
for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
|
|
873
|
-
resetRenderState();
|
|
878
|
+
resetRenderState(idPrefix);
|
|
874
879
|
swapContextMap(contextMap);
|
|
875
880
|
const chunks = [];
|
|
876
881
|
const writer = {
|
|
@@ -48,7 +48,6 @@ var FRAGMENT_TYPE = Symbol.for("react.fragment");
|
|
|
48
48
|
var SUSPENSE_TYPE = Symbol.for("react.suspense");
|
|
49
49
|
|
|
50
50
|
// src/slim-react/jsx.ts
|
|
51
|
-
var Fragment = FRAGMENT_TYPE;
|
|
52
51
|
function createElement(type, props, ...children) {
|
|
53
52
|
const normalizedProps = { ...props || {} };
|
|
54
53
|
if (children.length === 1) {
|
|
@@ -105,10 +104,11 @@ function s() {
|
|
|
105
104
|
}
|
|
106
105
|
return g[GLOBAL_KEY];
|
|
107
106
|
}
|
|
108
|
-
function resetRenderState() {
|
|
107
|
+
function resetRenderState(idPrefix = "") {
|
|
109
108
|
const st = s();
|
|
110
109
|
st.currentTreeContext = { ...EMPTY };
|
|
111
110
|
st.localIdCounter = 0;
|
|
111
|
+
st.idPrefix = idPrefix;
|
|
112
112
|
}
|
|
113
113
|
function pushTreeContext(totalChildren, index) {
|
|
114
114
|
const st = s();
|
|
@@ -172,10 +172,10 @@ function makeId() {
|
|
|
172
172
|
const st = s();
|
|
173
173
|
const treeId = getTreeId();
|
|
174
174
|
const n = st.localIdCounter++;
|
|
175
|
-
let id = "
|
|
175
|
+
let id = "_R_" + st.idPrefix + treeId;
|
|
176
176
|
if (n > 0)
|
|
177
177
|
id += "H" + n.toString(32);
|
|
178
|
-
return id + "
|
|
178
|
+
return id + "_";
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
// src/slim-react/hooks.ts
|
|
@@ -237,8 +237,8 @@ function use(usable) {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
// src/slim-react/dispatcher.ts
|
|
240
|
-
import
|
|
241
|
-
var _internals =
|
|
240
|
+
import * as ReactNS from "react";
|
|
241
|
+
var _internals = ReactNS.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
|
242
242
|
var slimDispatcher = {
|
|
243
243
|
useId: makeId,
|
|
244
244
|
readContext: (ctx) => getContextValue(ctx),
|
|
@@ -465,7 +465,9 @@ function renderAttributes(props, isSvg) {
|
|
|
465
465
|
continue;
|
|
466
466
|
}
|
|
467
467
|
if (key === "style" && typeof value === "object") {
|
|
468
|
-
|
|
468
|
+
const styleStr = styleObjectToString(value);
|
|
469
|
+
if (styleStr)
|
|
470
|
+
attrs += ` style="${escapeAttr(styleStr)}"`;
|
|
469
471
|
continue;
|
|
470
472
|
}
|
|
471
473
|
attrs += ` ${attrName}="${escapeAttr(String(value))}"`;
|
|
@@ -822,8 +824,8 @@ async function renderSuspense(props, writer, isSvg = false) {
|
|
|
822
824
|
const snap = snapshotContext();
|
|
823
825
|
while (attempts < MAX_SUSPENSE_RETRIES) {
|
|
824
826
|
restoreContext(snap);
|
|
827
|
+
let buffer = new BufferWriter();
|
|
825
828
|
try {
|
|
826
|
-
const buffer = new BufferWriter();
|
|
827
829
|
const r = renderNode(children, buffer, isSvg);
|
|
828
830
|
if (r && typeof r.then === "function") {
|
|
829
831
|
const m = captureMap();
|
|
@@ -857,12 +859,13 @@ async function renderSuspense(props, writer, isSvg = false) {
|
|
|
857
859
|
}
|
|
858
860
|
writer.write("<!--/$-->");
|
|
859
861
|
}
|
|
860
|
-
async function renderToString(element) {
|
|
862
|
+
async function renderToString(element, options) {
|
|
863
|
+
const idPrefix = options?.identifierPrefix ?? "";
|
|
861
864
|
const contextMap = /* @__PURE__ */ new Map();
|
|
862
865
|
const prev = swapContextMap(contextMap);
|
|
863
866
|
try {
|
|
864
867
|
for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
|
|
865
|
-
resetRenderState();
|
|
868
|
+
resetRenderState(idPrefix);
|
|
866
869
|
swapContextMap(contextMap);
|
|
867
870
|
const chunks = [];
|
|
868
871
|
const writer = {
|
|
@@ -1003,26 +1006,16 @@ parentPort.on("message", async (msg) => {
|
|
|
1003
1006
|
return;
|
|
1004
1007
|
const { finalAppProps, clientProps, unsuspend, headHtml, status } = await runFullLifecycle(request);
|
|
1005
1008
|
const Component = _ssrMod.default;
|
|
1006
|
-
const page = createElement(
|
|
1007
|
-
Fragment,
|
|
1008
|
-
null,
|
|
1009
|
-
createElement("div", { id: "app" }, createElement(Component, finalAppProps)),
|
|
1010
|
-
createElement("script", {
|
|
1011
|
-
id: "hadars",
|
|
1012
|
-
type: "application/json",
|
|
1013
|
-
dangerouslySetInnerHTML: {
|
|
1014
|
-
__html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c")
|
|
1015
|
-
}
|
|
1016
|
-
})
|
|
1017
|
-
);
|
|
1018
1009
|
globalThis.__hadarsUnsuspend = unsuspend;
|
|
1019
|
-
let
|
|
1010
|
+
let appHtml;
|
|
1020
1011
|
try {
|
|
1021
|
-
|
|
1012
|
+
appHtml = await renderToString(createElement(Component, finalAppProps));
|
|
1022
1013
|
} finally {
|
|
1023
1014
|
globalThis.__hadarsUnsuspend = null;
|
|
1024
1015
|
}
|
|
1025
|
-
|
|
1016
|
+
appHtml = processSegmentCache(appHtml);
|
|
1017
|
+
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c");
|
|
1018
|
+
const html = `<div id="app">${appHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>`;
|
|
1026
1019
|
parentPort.postMessage({ id, html, headHtml, status });
|
|
1027
1020
|
} catch (err) {
|
|
1028
1021
|
parentPort.postMessage({ id, error: err?.message ?? String(err) });
|
package/dist/utils/Head.tsx
CHANGED
|
@@ -204,6 +204,9 @@ export function initServerDataCache(data: Record<string, unknown>) {
|
|
|
204
204
|
* awaited, the cache entry is then cleared so that the next render re-calls
|
|
205
205
|
* `fn()` — at that point the Suspense hook returns synchronously.
|
|
206
206
|
*
|
|
207
|
+
* `fn` is **server-only**: it is never called in the browser. The resolved value
|
|
208
|
+
* is serialised into `__serverData` and returned from cache during hydration.
|
|
209
|
+
*
|
|
207
210
|
* @example
|
|
208
211
|
* const user = useServerData('current_user', () => db.getUser(id));
|
|
209
212
|
* const post = useServerData(['post', postId], () => db.getPost(postId));
|
|
@@ -213,14 +216,9 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
213
216
|
const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
|
|
214
217
|
|
|
215
218
|
if (typeof window !== 'undefined') {
|
|
216
|
-
// Client: if the server serialised a value for this key, return it directly
|
|
217
|
-
// (
|
|
218
|
-
|
|
219
|
-
return clientServerDataCache.get(cacheKey) as T | undefined;
|
|
220
|
-
}
|
|
221
|
-
// Key not serialised → Suspense hook case (e.g. useSuspenseQuery).
|
|
222
|
-
// Call fn() directly so the hook runs with its own hydrated cache.
|
|
223
|
-
return fn() as T | undefined;
|
|
219
|
+
// Client: if the server serialised a value for this key, return it directly.
|
|
220
|
+
// fn() is a server-only operation and must never run in the browser.
|
|
221
|
+
return clientServerDataCache.get(cacheKey) as T | undefined;
|
|
224
222
|
}
|
|
225
223
|
|
|
226
224
|
// Server: communicate via globalThis.__hadarsUnsuspend which is set by the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hadars",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
"node": ">=18.0.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"react": "^19.
|
|
56
|
-
"react-dom": "^19.
|
|
55
|
+
"react": "^19.2.0",
|
|
56
|
+
"react-dom": "^19.2.0",
|
|
57
57
|
"typescript": "^5"
|
|
58
58
|
},
|
|
59
59
|
"peerDependenciesMeta": {
|
|
@@ -73,6 +73,8 @@
|
|
|
73
73
|
"@types/react": "^19.2.14",
|
|
74
74
|
"@types/react-dom": "^19.2.3",
|
|
75
75
|
"esbuild": "^0.19.12",
|
|
76
|
+
"react": "^19.2.4",
|
|
77
|
+
"react-dom": "^19.2.4",
|
|
76
78
|
"tsup": "^6.7.0",
|
|
77
79
|
"typescript": "^5.9.3"
|
|
78
80
|
},
|
package/src/build.ts
CHANGED
|
@@ -73,7 +73,7 @@ async function processHtmlTemplate(templatePath: string): Promise<string> {
|
|
|
73
73
|
const HEAD_MARKER = '<meta name="HADARS_HEAD">';
|
|
74
74
|
const BODY_MARKER = '<meta name="HADARS_BODY">';
|
|
75
75
|
|
|
76
|
-
import { renderToString as slimRenderToString } from './slim-react/index';
|
|
76
|
+
import { renderToString as slimRenderToString, createElement } from './slim-react/index';
|
|
77
77
|
|
|
78
78
|
// Round-robin thread pool for SSR rendering — used on Bun/Deno where
|
|
79
79
|
// node:cluster is not available but node:worker_threads is.
|
|
@@ -194,7 +194,9 @@ class RenderWorkerPool {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
async function buildSsrResponse(
|
|
197
|
-
|
|
197
|
+
App: any,
|
|
198
|
+
appProps: Record<string, unknown>,
|
|
199
|
+
clientProps: Record<string, unknown>,
|
|
198
200
|
headHtml: string,
|
|
199
201
|
status: number,
|
|
200
202
|
getPrecontentHtml: (headHtml: string) => Promise<[string, string]>,
|
|
@@ -210,12 +212,15 @@ async function buildSsrResponse(
|
|
|
210
212
|
let bodyHtml: string;
|
|
211
213
|
try {
|
|
212
214
|
(globalThis as any).__hadarsUnsuspend = unsuspendForRender;
|
|
213
|
-
bodyHtml = await slimRenderToString(
|
|
215
|
+
bodyHtml = await slimRenderToString(createElement(App, appProps));
|
|
214
216
|
} finally {
|
|
215
217
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
216
218
|
}
|
|
217
219
|
bodyHtml = processSegmentCache(bodyHtml);
|
|
218
|
-
|
|
220
|
+
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c');
|
|
221
|
+
controller.enqueue(encoder.encode(
|
|
222
|
+
`<div id="app">${bodyHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>` + postContent
|
|
223
|
+
));
|
|
219
224
|
controller.close();
|
|
220
225
|
},
|
|
221
226
|
});
|
|
@@ -685,7 +690,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
685
690
|
getFinalProps,
|
|
686
691
|
} = (await import(importPath)) as HadarsEntryModule<any>;
|
|
687
692
|
|
|
688
|
-
const {
|
|
693
|
+
const { App, appProps, clientProps, unsuspend, status, headHtml } = await getReactResponse(request, {
|
|
689
694
|
document: {
|
|
690
695
|
body: Component as React.FC<HadarsProps<object>>,
|
|
691
696
|
lang: 'en',
|
|
@@ -695,7 +700,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
695
700
|
},
|
|
696
701
|
});
|
|
697
702
|
|
|
698
|
-
return buildSsrResponse(
|
|
703
|
+
return buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspend);
|
|
699
704
|
} catch (err: any) {
|
|
700
705
|
console.error('[hadars] SSR render error:', err);
|
|
701
706
|
const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, '<');
|
|
@@ -875,7 +880,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
875
880
|
});
|
|
876
881
|
}
|
|
877
882
|
|
|
878
|
-
const {
|
|
883
|
+
const { App, appProps, clientProps, unsuspend, status, headHtml } = await getReactResponse(request, {
|
|
879
884
|
document: {
|
|
880
885
|
body: Component as React.FC<HadarsProps<object>>,
|
|
881
886
|
lang: 'en',
|
|
@@ -885,7 +890,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
885
890
|
},
|
|
886
891
|
});
|
|
887
892
|
|
|
888
|
-
return buildSsrResponse(
|
|
893
|
+
return buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspend);
|
|
889
894
|
} catch (err: any) {
|
|
890
895
|
console.error('[hadars] SSR render error:', err);
|
|
891
896
|
return new Response('Internal Server Error', { status: 500 });
|
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
* call so that `React.useId()` routes through slim-react's tree-aware
|
|
10
10
|
* `makeId()`. All other hooks already have working SSR stubs in hooks.ts;
|
|
11
11
|
* they are forwarded here so libraries that call them via `React.*` also work.
|
|
12
|
+
*
|
|
13
|
+
* In the Rspack SSR bundle `react` is aliased to `slim-react`, which does NOT
|
|
14
|
+
* export `__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE`.
|
|
15
|
+
* Accessing that property via a namespace import (`import * as`) always returns
|
|
16
|
+
* `undefined` safely — `import default from "react"` would crash via interop
|
|
17
|
+
* because Rspack compiles it as `require("react").default`, and slim-react has
|
|
18
|
+
* no `default` export, so `.default` itself is `undefined` and the subsequent
|
|
19
|
+
* property access throws.
|
|
20
|
+
*
|
|
21
|
+
* With the namespace import, `_internals` is `undefined` in the SSR bundle and
|
|
22
|
+
* the install/restore functions become no-ops, which is correct: the SSR bundle
|
|
23
|
+
* already routes all `React.*` hook calls to slim-react's own implementations.
|
|
12
24
|
*/
|
|
13
25
|
|
|
14
26
|
import { makeId, getContextValue } from "./renderContext";
|
|
@@ -19,11 +31,14 @@ import {
|
|
|
19
31
|
useOptimistic, useActionState, use,
|
|
20
32
|
} from "./hooks";
|
|
21
33
|
|
|
22
|
-
import
|
|
34
|
+
// Use namespace import so that when `react` is aliased to slim-react in the
|
|
35
|
+
// Rspack SSR bundle, `ReactNS` is always an object (never undefined), and
|
|
36
|
+
// accessing a missing property returns `undefined` rather than throwing.
|
|
37
|
+
import * as ReactNS from "react";
|
|
23
38
|
|
|
24
39
|
// React 19 exposes its shared internals under this key.
|
|
25
40
|
const _internals: { H: object | null } | undefined =
|
|
26
|
-
(
|
|
41
|
+
(ReactNS as any).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
|
27
42
|
|
|
28
43
|
// The dispatcher object we install. We keep a stable reference so the same
|
|
29
44
|
// object is reused across every component call.
|
package/src/slim-react/index.ts
CHANGED
|
@@ -75,7 +75,7 @@ export function useContext<T>(context: Context<T>): T {
|
|
|
75
75
|
|
|
76
76
|
// ---- Rendering ----
|
|
77
77
|
import { renderToStream, renderToString, renderToReadableStream } from "./render";
|
|
78
|
-
export { renderToStream, renderToString, renderToReadableStream };
|
|
78
|
+
export { renderToStream, renderToString, renderToReadableStream, type RenderOptions } from "./render";
|
|
79
79
|
|
|
80
80
|
// ---- Suspense (as a JSX tag) ----
|
|
81
81
|
import { SUSPENSE_TYPE } from "./types";
|
package/src/slim-react/render.ts
CHANGED
|
@@ -300,7 +300,8 @@ function renderAttributes(props: Record<string, any>, isSvg: boolean): string {
|
|
|
300
300
|
continue;
|
|
301
301
|
}
|
|
302
302
|
if (key === "style" && typeof value === "object") {
|
|
303
|
-
|
|
303
|
+
const styleStr = styleObjectToString(value);
|
|
304
|
+
if (styleStr) attrs += ` style="${escapeAttr(styleStr)}"`;
|
|
304
305
|
continue;
|
|
305
306
|
}
|
|
306
307
|
attrs += ` ${attrName}="${escapeAttr(String(value))}"`;
|
|
@@ -812,8 +813,8 @@ async function renderSuspense(
|
|
|
812
813
|
while (attempts < MAX_SUSPENSE_RETRIES) {
|
|
813
814
|
// Restore context to the state it was in when we entered <Suspense>.
|
|
814
815
|
restoreContext(snap);
|
|
816
|
+
let buffer = new BufferWriter();
|
|
815
817
|
try {
|
|
816
|
-
const buffer = new BufferWriter();
|
|
817
818
|
const r = renderNode(children, buffer, isSvg);
|
|
818
819
|
if (r && typeof (r as any).then === "function") {
|
|
819
820
|
const m = captureMap(); await r; swapContextMap(m);
|
|
@@ -850,19 +851,32 @@ async function renderSuspense(
|
|
|
850
851
|
// Public API
|
|
851
852
|
// ---------------------------------------------------------------------------
|
|
852
853
|
|
|
854
|
+
export interface RenderOptions {
|
|
855
|
+
/**
|
|
856
|
+
* Must match the `identifierPrefix` option passed to `hydrateRoot` on the
|
|
857
|
+
* client so that `useId()` generates identical IDs on server and client.
|
|
858
|
+
* Defaults to `""` (React's default).
|
|
859
|
+
*/
|
|
860
|
+
identifierPrefix?: string;
|
|
861
|
+
}
|
|
862
|
+
|
|
853
863
|
/**
|
|
854
864
|
* Render a component tree to a `ReadableStream<Uint8Array>`.
|
|
855
865
|
*
|
|
856
866
|
* The stream pauses at `<Suspense>` boundaries until the suspended
|
|
857
867
|
* promise resolves, then continues writing HTML.
|
|
858
868
|
*/
|
|
859
|
-
export function renderToStream(
|
|
869
|
+
export function renderToStream(
|
|
870
|
+
element: SlimNode,
|
|
871
|
+
options?: RenderOptions,
|
|
872
|
+
): ReadableStream<Uint8Array> {
|
|
860
873
|
const encoder = new TextEncoder();
|
|
874
|
+
const idPrefix = options?.identifierPrefix ?? "";
|
|
861
875
|
|
|
862
876
|
const contextMap = new Map<object, unknown>();
|
|
863
877
|
return new ReadableStream({
|
|
864
878
|
async start(controller) {
|
|
865
|
-
resetRenderState();
|
|
879
|
+
resetRenderState(idPrefix);
|
|
866
880
|
const prev = swapContextMap(contextMap);
|
|
867
881
|
|
|
868
882
|
const writer: Writer = {
|
|
@@ -897,12 +911,16 @@ export function renderToStream(element: SlimNode): ReadableStream<Uint8Array> {
|
|
|
897
911
|
* Retries the full tree when a component throws a Promise (Suspense protocol),
|
|
898
912
|
* so useServerData and similar hooks work without requiring explicit <Suspense>.
|
|
899
913
|
*/
|
|
900
|
-
export async function renderToString(
|
|
914
|
+
export async function renderToString(
|
|
915
|
+
element: SlimNode,
|
|
916
|
+
options?: RenderOptions,
|
|
917
|
+
): Promise<string> {
|
|
918
|
+
const idPrefix = options?.identifierPrefix ?? "";
|
|
901
919
|
const contextMap = new Map<object, unknown>();
|
|
902
920
|
const prev = swapContextMap(contextMap);
|
|
903
921
|
try {
|
|
904
922
|
for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
|
|
905
|
-
resetRenderState();
|
|
923
|
+
resetRenderState(idPrefix);
|
|
906
924
|
swapContextMap(contextMap); // re-activate our map on each retry
|
|
907
925
|
const chunks: string[] = [];
|
|
908
926
|
const writer: Writer = {
|
|
@@ -92,10 +92,11 @@ function s(): RenderState {
|
|
|
92
92
|
return g[GLOBAL_KEY] as RenderState;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
export function resetRenderState() {
|
|
95
|
+
export function resetRenderState(idPrefix = "") {
|
|
96
96
|
const st = s();
|
|
97
97
|
st.currentTreeContext = { ...EMPTY };
|
|
98
98
|
st.localIdCounter = 0;
|
|
99
|
+
st.idPrefix = idPrefix;
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
export function setIdPrefix(prefix: string) {
|
|
@@ -192,20 +193,19 @@ function getTreeId(): string {
|
|
|
192
193
|
/**
|
|
193
194
|
* Generate a `useId`-compatible ID for the current call site.
|
|
194
195
|
*
|
|
195
|
-
* Format:
|
|
196
|
+
* Format: `_R_<idPrefix><treeId>_` (React 19.2+)
|
|
196
197
|
* with an optional `H<n>` suffix for the n-th useId call in the same
|
|
197
198
|
* component (matching React 19's `localIdCounter` behaviour).
|
|
198
199
|
*
|
|
199
|
-
* React 19.
|
|
200
|
-
* This matches React 19.
|
|
201
|
-
*
|
|
202
|
-
* real React runtime during `hydrateRoot`.
|
|
200
|
+
* React 19.2 uses `_R_<id>_` (underscore-delimited).
|
|
201
|
+
* This matches React 19.2's output from both renderToString (Fizz) and
|
|
202
|
+
* hydrateRoot, so SSR-generated IDs agree with client React during hydration.
|
|
203
203
|
*/
|
|
204
204
|
export function makeId(): string {
|
|
205
205
|
const st = s();
|
|
206
206
|
const treeId = getTreeId();
|
|
207
207
|
const n = st.localIdCounter++;
|
|
208
|
-
let id = "
|
|
208
|
+
let id = "_R_" + st.idPrefix + treeId;
|
|
209
209
|
if (n > 0) id += "H" + n.toString(32);
|
|
210
|
-
return id + "
|
|
210
|
+
return id + "_";
|
|
211
211
|
}
|
package/src/ssr-render-worker.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { workerData, parentPort } from 'node:worker_threads';
|
|
14
14
|
import { pathToFileURL } from 'node:url';
|
|
15
15
|
import { processSegmentCache } from './utils/segmentCache';
|
|
16
|
-
import { renderToString, createElement
|
|
16
|
+
import { renderToString, createElement } from './slim-react/index';
|
|
17
17
|
|
|
18
18
|
const { ssrBundlePath } = workerData as { ssrBundlePath: string };
|
|
19
19
|
|
|
@@ -145,26 +145,21 @@ parentPort!.on('message', async (msg: any) => {
|
|
|
145
145
|
|
|
146
146
|
const Component = _ssrMod.default;
|
|
147
147
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
type: 'application/json',
|
|
153
|
-
dangerouslySetInnerHTML: {
|
|
154
|
-
__html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c'),
|
|
155
|
-
},
|
|
156
|
-
}),
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
// Re-use the same cache so useServerData returns immediately (no re-fetch).
|
|
148
|
+
// Render the Component as the direct root — matching hydrateRoot(div#app, <Component>)
|
|
149
|
+
// on the client. Wrapping in Fragment(div#app(...), script) would add an extra
|
|
150
|
+
// pushTreeContext(2,0) from the Fragment's child array, shifting all tree-position
|
|
151
|
+
// useId values by 2 bits and causing a hydration mismatch.
|
|
160
152
|
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
161
|
-
let
|
|
153
|
+
let appHtml: string;
|
|
162
154
|
try {
|
|
163
|
-
|
|
155
|
+
appHtml = await renderToString(createElement(Component, finalAppProps));
|
|
164
156
|
} finally {
|
|
165
157
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
166
158
|
}
|
|
167
|
-
|
|
159
|
+
appHtml = processSegmentCache(appHtml);
|
|
160
|
+
|
|
161
|
+
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c');
|
|
162
|
+
const html = `<div id="app">${appHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>`;
|
|
168
163
|
|
|
169
164
|
parentPort!.postMessage({ id, html, headHtml, status });
|
|
170
165
|
|
package/src/utils/Head.tsx
CHANGED
|
@@ -204,6 +204,9 @@ export function initServerDataCache(data: Record<string, unknown>) {
|
|
|
204
204
|
* awaited, the cache entry is then cleared so that the next render re-calls
|
|
205
205
|
* `fn()` — at that point the Suspense hook returns synchronously.
|
|
206
206
|
*
|
|
207
|
+
* `fn` is **server-only**: it is never called in the browser. The resolved value
|
|
208
|
+
* is serialised into `__serverData` and returned from cache during hydration.
|
|
209
|
+
*
|
|
207
210
|
* @example
|
|
208
211
|
* const user = useServerData('current_user', () => db.getUser(id));
|
|
209
212
|
* const post = useServerData(['post', postId], () => db.getPost(postId));
|
|
@@ -213,14 +216,9 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
213
216
|
const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
|
|
214
217
|
|
|
215
218
|
if (typeof window !== 'undefined') {
|
|
216
|
-
// Client: if the server serialised a value for this key, return it directly
|
|
217
|
-
// (
|
|
218
|
-
|
|
219
|
-
return clientServerDataCache.get(cacheKey) as T | undefined;
|
|
220
|
-
}
|
|
221
|
-
// Key not serialised → Suspense hook case (e.g. useSuspenseQuery).
|
|
222
|
-
// Call fn() directly so the hook runs with its own hydrated cache.
|
|
223
|
-
return fn() as T | undefined;
|
|
219
|
+
// Client: if the server serialised a value for this key, return it directly.
|
|
220
|
+
// fn() is a server-only operation and must never run in the browser.
|
|
221
|
+
return clientServerDataCache.get(cacheKey) as T | undefined;
|
|
224
222
|
}
|
|
225
223
|
|
|
226
224
|
// Server: communicate via globalThis.__hadarsUnsuspend which is set by the
|
package/src/utils/response.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
2
|
import type { AppHead, HadarsRequest, HadarsEntryBase, HadarsEntryModule, HadarsProps, AppContext } from "../types/hadars";
|
|
3
|
-
import { renderToString, createElement
|
|
3
|
+
import { renderToString, createElement } from '../slim-react/index';
|
|
4
4
|
|
|
5
5
|
interface ReactResponseOptions {
|
|
6
6
|
document: {
|
|
@@ -55,14 +55,12 @@ export const getReactResponse = async (
|
|
|
55
55
|
req: HadarsRequest,
|
|
56
56
|
opts: ReactResponseOptions,
|
|
57
57
|
): Promise<{
|
|
58
|
-
|
|
58
|
+
App: React.FC<any>,
|
|
59
|
+
appProps: Record<string, unknown>,
|
|
60
|
+
clientProps: Record<string, unknown>,
|
|
59
61
|
unsuspend: { cache: Map<string, any> },
|
|
60
62
|
status: number,
|
|
61
63
|
headHtml: string,
|
|
62
|
-
renderPayload: {
|
|
63
|
-
appProps: Record<string, unknown>;
|
|
64
|
-
clientProps: Record<string, unknown>;
|
|
65
|
-
};
|
|
66
64
|
}> => {
|
|
67
65
|
const App = opts.document.body;
|
|
68
66
|
const { getInitProps, getAfterRenderProps, getFinalProps } = opts.document;
|
|
@@ -105,27 +103,14 @@ export const getReactResponse = async (
|
|
|
105
103
|
...(Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}),
|
|
106
104
|
};
|
|
107
105
|
|
|
108
|
-
const
|
|
109
|
-
createElement('div', { id: 'app' },
|
|
110
|
-
createElement(App as any, { ...props, location: req.location, context } as any),
|
|
111
|
-
),
|
|
112
|
-
createElement('script', {
|
|
113
|
-
id: 'hadars',
|
|
114
|
-
type: 'application/json',
|
|
115
|
-
dangerouslySetInnerHTML: {
|
|
116
|
-
__html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c'),
|
|
117
|
-
},
|
|
118
|
-
}),
|
|
119
|
-
);
|
|
106
|
+
const appProps = { ...props, location: req.location, context } as unknown as Record<string, unknown>;
|
|
120
107
|
|
|
121
108
|
return {
|
|
122
|
-
|
|
109
|
+
App: App as React.FC<any>,
|
|
110
|
+
appProps,
|
|
111
|
+
clientProps: clientProps as Record<string, unknown>,
|
|
123
112
|
unsuspend,
|
|
124
113
|
status: context.head.status,
|
|
125
114
|
headHtml: getHeadHtml(context.head),
|
|
126
|
-
renderPayload: {
|
|
127
|
-
appProps: { ...props, location: req.location, context } as Record<string, unknown>,
|
|
128
|
-
clientProps: clientProps as Record<string, unknown>,
|
|
129
|
-
},
|
|
130
115
|
};
|
|
131
116
|
};
|