hadars 0.1.31 → 0.1.33

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/README.md CHANGED
@@ -16,6 +16,10 @@ hadars is an alternative to Next.js for apps that just need SSR.
16
16
 
17
17
  Bring your own router (or none), keep your components as plain React, and get SSR, HMR, and a production build from a single config file.
18
18
 
19
+ ## Benchmarks
20
+
21
+ Benchmarks against an equivalent Next.js app show significantly faster server throughput (requests/second) and meaningfully better page load metrics (TTFB, FCP, DOMContentLoaded). Build times are also much lower due to rspack.
22
+
19
23
  ## Quick start
20
24
 
21
25
  Scaffold a new project in seconds:
@@ -114,7 +118,6 @@ const UserCard = ({ userId }: { userId: string }) => {
114
118
  - **`key`** - string or string array; must be stable and unique within the page
115
119
  - **Server** - calls `fn()`, awaits the result across render iterations, returns `undefined` until resolved
116
120
  - **Client** - reads the pre-resolved value from the hydration cache serialised by the server; `fn()` is never called in the browser
117
- - **Suspense libraries** - also works when `fn()` throws a thenable (e.g. Relay `useLazyLoadQuery` with `suspense: true`); the thrown promise is awaited and the next render re-calls `fn()` synchronously
118
121
 
119
122
  ## Data lifecycle hooks
120
123
 
@@ -182,21 +185,14 @@ export default config;
182
185
 
183
186
  ## slim-react
184
187
 
185
- hadars ships its own lightweight React-compatible SSR renderer called **slim-react** (`src/slim-react/`). It replaces `react-dom/server` entirely on the server side - no `renderToStaticMarkup`, no `renderToPipeableStream`, no react-dom dependency at all.
186
-
187
- For server builds, rspack aliases `react` and `react/jsx-runtime` to slim-react, so your components and any libraries they import render through it automatically without any code changes.
188
-
189
- **What it does:**
190
-
191
- - Renders the full component tree to an HTML string with native `async/await` support - async components and hooks that return Promises are awaited directly without streaming workarounds
192
- - Implements the React Suspense protocol: when a component throws a Promise (e.g. from `useServerData` or a Suspense-enabled data library), slim-react awaits it and retries the tree automatically
193
- - Emits React-compatible hydration markers - `<!--$-->…<!--/$-->` for resolved Suspense boundaries, `<!-- -->` separators between adjacent text nodes - so `hydrateRoot` on the client works without mismatches
194
- - Supports `React.memo`, `React.forwardRef`, `React.lazy`, `Context.Provider`, `Context.Consumer`, and the React 18/19 element wire formats
195
- - Covers the full hook surface needed for SSR: `useState`, `useReducer`, `useContext`, `useRef`, `useMemo`, `useCallback`, `useId`, `useSyncExternalStore`, `use`, and more - all as lightweight SSR stubs
188
+ hadars ships its own lightweight React-compatible SSR renderer called **slim-react** (`src/slim-react/`). It replaces `react-dom/server` on the server side entirely.
196
189
 
197
- **Why not react-dom/server?**
190
+ For server builds, rspack aliases `react` and `react/jsx-runtime` to slim-react, so your components and any libraries they import render through it automatically without code changes.
198
191
 
199
- `react-dom/server` cannot `await` arbitrary Promises thrown during render - it only handles Suspense via streaming and requires components to use `React.lazy` or Relay-style Suspense resources. slim-react's retry loop makes `useServerData` (and any hook that throws a Promise) work without wrapping every async component in a `<Suspense>` boundary.
192
+ - Renders the full component tree to an HTML string with native `async/await` async components are awaited directly
193
+ - Implements the React Suspense protocol: thrown Promises (e.g. from `useSuspenseQuery`) are awaited and the component retried automatically
194
+ - Compatible with `hydrateRoot` — output matches what React expects on the client
195
+ - Supports `React.memo`, `React.forwardRef`, `React.lazy`, `Context.Provider`, `Context.Consumer`, and the React 19 element format
200
196
 
201
197
  ## License
202
198
 
package/dist/cli.js CHANGED
@@ -387,17 +387,23 @@ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
387
387
  "track",
388
388
  "wbr"
389
389
  ]);
390
+ var HTML_ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#x27;" };
390
391
  function escapeHtml(str) {
391
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/'/g, "&#x27;");
392
+ return str.replace(/[&<>']/g, (c) => HTML_ESC[c]);
392
393
  }
394
+ var ATTR_ESC = { "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" };
393
395
  function escapeAttr(str) {
394
- return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
396
+ return str.replace(/[&"<>]/g, (c) => ATTR_ESC[c]);
395
397
  }
396
398
  function styleObjectToString(style) {
397
- return Object.entries(style).map(([key, value]) => {
399
+ let result = "";
400
+ for (const key in style) {
401
+ if (result)
402
+ result += ";";
398
403
  const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
399
- return `${cssKey}:${value}`;
400
- }).join(";");
404
+ result += cssKey + ":" + style[key];
405
+ }
406
+ return result;
401
407
  }
402
408
  var SVG_ATTR_MAP = {
403
409
  // Presentation / geometry
@@ -532,7 +538,8 @@ var SVG_ATTR_MAP = {
532
538
  };
533
539
  function renderAttributes(props, isSvg) {
534
540
  let attrs = "";
535
- for (const [key, value] of Object.entries(props)) {
541
+ for (const key in props) {
542
+ const value = props[key];
536
543
  if (key === "children" || key === "key" || key === "ref" || key === "dangerouslySetInnerHTML" || key === "suppressHydrationWarning" || key === "suppressContentEditableWarning")
537
544
  continue;
538
545
  if (key.startsWith("on") && key.length > 2 && key[2] === key[2].toUpperCase())
@@ -996,6 +1003,45 @@ async function renderToString(element, options) {
996
1003
  }
997
1004
  }
998
1005
 
1006
+ // src/utils/segmentCache.ts
1007
+ function getStore() {
1008
+ const g = globalThis;
1009
+ if (!g.__hadarsSegmentStore) {
1010
+ g.__hadarsSegmentStore = /* @__PURE__ */ new Map();
1011
+ }
1012
+ return g.__hadarsSegmentStore;
1013
+ }
1014
+ function setSegment(key, html, ttl) {
1015
+ getStore().set(key, {
1016
+ html,
1017
+ expiresAt: ttl != null ? Date.now() + ttl : null
1018
+ });
1019
+ }
1020
+ function processSegmentCache(html) {
1021
+ let prev;
1022
+ do {
1023
+ prev = html;
1024
+ html = html.replace(
1025
+ /<hadars-c([^>]*)>([\s\S]*?)<\/hadars-c>/g,
1026
+ (match, attrs, content) => {
1027
+ const cacheM = /data-cache="([^"]+)"/.exec(attrs);
1028
+ const keyM = /data-key="([^"]+)"/.exec(attrs);
1029
+ const ttlM = /data-ttl="(\d+)"/.exec(attrs);
1030
+ if (!cacheM || !keyM)
1031
+ return match;
1032
+ if (cacheM[1] === "miss") {
1033
+ setSegment(keyM[1], content, ttlM ? Number(ttlM[1]) : void 0);
1034
+ return content;
1035
+ }
1036
+ if (cacheM[1] === "hit")
1037
+ return content;
1038
+ return match;
1039
+ }
1040
+ );
1041
+ } while (html !== prev);
1042
+ return html;
1043
+ }
1044
+
999
1045
  // src/utils/response.tsx
1000
1046
  var ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
1001
1047
  var escAttr = (s2) => s2.replace(/[&<>"]/g, (c) => ESC[c] ?? c);
@@ -1053,11 +1099,12 @@ var getReactResponse = async (req, opts) => {
1053
1099
  };
1054
1100
  const unsuspend = { cache: /* @__PURE__ */ new Map() };
1055
1101
  globalThis.__hadarsUnsuspend = unsuspend;
1102
+ let bodyHtml;
1056
1103
  try {
1057
- let html = await renderToString(createElement(App, props));
1104
+ bodyHtml = await renderToString(createElement(App, props));
1058
1105
  if (getAfterRenderProps) {
1059
- props = await getAfterRenderProps(props, html);
1060
- await renderToString(
1106
+ props = await getAfterRenderProps(props, bodyHtml);
1107
+ bodyHtml = await renderToString(
1061
1108
  createElement(App, { ...props, location: req.location, context })
1062
1109
  );
1063
1110
  }
@@ -1075,12 +1122,9 @@ var getReactResponse = async (req, opts) => {
1075
1122
  location: req.location,
1076
1123
  ...Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}
1077
1124
  };
1078
- const appProps = { ...props, location: req.location, context };
1079
1125
  return {
1080
- App,
1081
- appProps,
1126
+ bodyHtml: processSegmentCache(bodyHtml),
1082
1127
  clientProps,
1083
- unsuspend,
1084
1128
  status: context.head.status,
1085
1129
  headHtml: getHeadHtml(context.head)
1086
1130
  };
@@ -1631,47 +1675,6 @@ import { existsSync as existsSync2 } from "node:fs";
1631
1675
  import os from "node:os";
1632
1676
  import { spawn } from "node:child_process";
1633
1677
  import cluster from "node:cluster";
1634
-
1635
- // src/utils/segmentCache.ts
1636
- function getStore() {
1637
- const g = globalThis;
1638
- if (!g.__hadarsSegmentStore) {
1639
- g.__hadarsSegmentStore = /* @__PURE__ */ new Map();
1640
- }
1641
- return g.__hadarsSegmentStore;
1642
- }
1643
- function setSegment(key, html, ttl) {
1644
- getStore().set(key, {
1645
- html,
1646
- expiresAt: ttl != null ? Date.now() + ttl : null
1647
- });
1648
- }
1649
- function processSegmentCache(html) {
1650
- let prev;
1651
- do {
1652
- prev = html;
1653
- html = html.replace(
1654
- /<hadars-c([^>]*)>([\s\S]*?)<\/hadars-c>/g,
1655
- (match, attrs, content) => {
1656
- const cacheM = /data-cache="([^"]+)"/.exec(attrs);
1657
- const keyM = /data-key="([^"]+)"/.exec(attrs);
1658
- const ttlM = /data-ttl="(\d+)"/.exec(attrs);
1659
- if (!cacheM || !keyM)
1660
- return match;
1661
- if (cacheM[1] === "miss") {
1662
- setSegment(keyM[1], content, ttlM ? Number(ttlM[1]) : void 0);
1663
- return content;
1664
- }
1665
- if (cacheM[1] === "hit")
1666
- return content;
1667
- return match;
1668
- }
1669
- );
1670
- } while (html !== prev);
1671
- return html;
1672
- }
1673
-
1674
- // src/build.ts
1675
1678
  var encoder = new TextEncoder();
1676
1679
  async function processHtmlTemplate(templatePath) {
1677
1680
  const html = await fs.readFile(templatePath, "utf-8");
@@ -1816,19 +1819,11 @@ var RenderWorkerPool = class {
1816
1819
  await Promise.all(this.workers.map((w) => w.terminate()));
1817
1820
  }
1818
1821
  };
1819
- async function buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspendForRender) {
1822
+ async function buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml) {
1820
1823
  const responseStream = new ReadableStream({
1821
1824
  async start(controller) {
1822
1825
  const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
1823
1826
  controller.enqueue(encoder.encode(precontentHtml));
1824
- let bodyHtml;
1825
- try {
1826
- globalThis.__hadarsUnsuspend = unsuspendForRender;
1827
- bodyHtml = await renderToString(createElement(App, appProps));
1828
- } finally {
1829
- globalThis.__hadarsUnsuspend = null;
1830
- }
1831
- bodyHtml = processSegmentCache(bodyHtml);
1832
1827
  const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c");
1833
1828
  controller.enqueue(encoder.encode(
1834
1829
  `<div id="app">${bodyHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>` + postContent
@@ -2202,7 +2197,7 @@ var dev = async (options) => {
2202
2197
  getAfterRenderProps,
2203
2198
  getFinalProps
2204
2199
  } = await import(importPath);
2205
- const { App, appProps, clientProps, unsuspend, status, headHtml } = await getReactResponse(request, {
2200
+ const { bodyHtml, clientProps, status, headHtml } = await getReactResponse(request, {
2206
2201
  document: {
2207
2202
  body: Component,
2208
2203
  lang: "en",
@@ -2211,7 +2206,7 @@ var dev = async (options) => {
2211
2206
  getFinalProps
2212
2207
  }
2213
2208
  });
2214
- return buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspend);
2209
+ return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
2215
2210
  } catch (err) {
2216
2211
  console.error("[hadars] SSR render error:", err);
2217
2212
  const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, "&lt;");
@@ -2356,7 +2351,7 @@ var run = async (options) => {
2356
2351
  status: wStatus
2357
2352
  });
2358
2353
  }
2359
- const { App, appProps, clientProps, unsuspend, status, headHtml } = await getReactResponse(request, {
2354
+ const { bodyHtml, clientProps, status, headHtml } = await getReactResponse(request, {
2360
2355
  document: {
2361
2356
  body: Component,
2362
2357
  lang: "en",
@@ -2365,7 +2360,7 @@ var run = async (options) => {
2365
2360
  getFinalProps
2366
2361
  }
2367
2362
  });
2368
- return buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspend);
2363
+ return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
2369
2364
  } catch (err) {
2370
2365
  console.error("[hadars] SSR render error:", err);
2371
2366
  return new Response("Internal Server Error", { status: 500 });
package/dist/index.cjs CHANGED
@@ -189,37 +189,8 @@ function useServerData(key, fn) {
189
189
  if (!unsuspend)
190
190
  return void 0;
191
191
  const existing = unsuspend.cache.get(cacheKey);
192
- if (existing?.status === "suspense-resolved") {
193
- try {
194
- const value = fn();
195
- unsuspend.cache.set(cacheKey, { status: "suspense-cached", value });
196
- return value;
197
- } catch {
198
- return void 0;
199
- }
200
- }
201
- if (existing?.status === "suspense-cached") {
202
- return existing.value;
203
- }
204
192
  if (!existing) {
205
- let result;
206
- try {
207
- result = fn();
208
- } catch (thrown) {
209
- if (thrown !== null && typeof thrown === "object" && typeof thrown.then === "function") {
210
- const suspensePromise = Promise.resolve(thrown).then(
211
- () => {
212
- unsuspend.cache.set(cacheKey, { status: "suspense-resolved" });
213
- },
214
- () => {
215
- unsuspend.cache.set(cacheKey, { status: "suspense-resolved" });
216
- }
217
- );
218
- unsuspend.cache.set(cacheKey, { status: "pending", promise: suspensePromise });
219
- throw suspensePromise;
220
- }
221
- throw thrown;
222
- }
193
+ const result = fn();
223
194
  const isThenable = result !== null && typeof result === "object" && typeof result.then === "function";
224
195
  if (!isThenable) {
225
196
  const value = result;
package/dist/index.d.ts CHANGED
@@ -28,11 +28,6 @@ type UnsuspendEntry = {
28
28
  } | {
29
29
  status: 'fulfilled';
30
30
  value: unknown;
31
- } | {
32
- status: 'suspense-resolved';
33
- } | {
34
- status: 'suspense-cached';
35
- value: unknown;
36
31
  } | {
37
32
  status: 'rejected';
38
33
  reason: unknown;
@@ -40,7 +35,6 @@ type UnsuspendEntry = {
40
35
  /** @internal Populated by the framework's render loop — use useServerData() instead. */
41
36
  interface AppUnsuspend {
42
37
  cache: Map<string, UnsuspendEntry>;
43
- hasPending: boolean;
44
38
  }
45
39
  interface AppContext {
46
40
  path?: string;
@@ -197,16 +191,11 @@ declare function initServerDataCache(data: Record<string, unknown>): void;
197
191
  * across all SSR render passes and client hydration — it must be stable and
198
192
  * unique within the page.
199
193
  *
200
- * `fn` may return a `Promise<T>` (normal async usage), return `T` synchronously,
201
- * or throw a thenable (React Suspense protocol). All three cases are handled:
202
- * - Async `Promise<T>`: awaited across render iterations, result cached.
203
- * - Synchronous return: value stored immediately, returned on the same pass.
204
- * - Thrown thenable (e.g. `useQuery({ suspense: true })`): the thrown promise is
205
- * awaited, the cache entry is then cleared so that the next render re-calls
206
- * `fn()` — at that point the Suspense hook returns synchronously.
194
+ * `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
195
+ * The resolved value is serialised into `__serverData` and returned from cache
196
+ * during hydration.
207
197
  *
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.
198
+ * `fn` is **server-only**: it is never called in the browser.
210
199
  *
211
200
  * @example
212
201
  * const user = useServerData('current_user', () => db.getUser(id));
package/dist/index.js CHANGED
@@ -146,37 +146,8 @@ function useServerData(key, fn) {
146
146
  if (!unsuspend)
147
147
  return void 0;
148
148
  const existing = unsuspend.cache.get(cacheKey);
149
- if (existing?.status === "suspense-resolved") {
150
- try {
151
- const value = fn();
152
- unsuspend.cache.set(cacheKey, { status: "suspense-cached", value });
153
- return value;
154
- } catch {
155
- return void 0;
156
- }
157
- }
158
- if (existing?.status === "suspense-cached") {
159
- return existing.value;
160
- }
161
149
  if (!existing) {
162
- let result;
163
- try {
164
- result = fn();
165
- } catch (thrown) {
166
- if (thrown !== null && typeof thrown === "object" && typeof thrown.then === "function") {
167
- const suspensePromise = Promise.resolve(thrown).then(
168
- () => {
169
- unsuspend.cache.set(cacheKey, { status: "suspense-resolved" });
170
- },
171
- () => {
172
- unsuspend.cache.set(cacheKey, { status: "suspense-resolved" });
173
- }
174
- );
175
- unsuspend.cache.set(cacheKey, { status: "pending", promise: suspensePromise });
176
- throw suspensePromise;
177
- }
178
- throw thrown;
179
- }
150
+ const result = fn();
180
151
  const isThenable = result !== null && typeof result === "object" && typeof result.then === "function";
181
152
  if (!isThenable) {
182
153
  const value = result;
@@ -370,17 +370,23 @@ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
370
370
  "track",
371
371
  "wbr"
372
372
  ]);
373
+ var HTML_ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#x27;" };
373
374
  function escapeHtml(str) {
374
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/'/g, "&#x27;");
375
+ return str.replace(/[&<>']/g, (c) => HTML_ESC[c]);
375
376
  }
377
+ var ATTR_ESC = { "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" };
376
378
  function escapeAttr(str) {
377
- return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
379
+ return str.replace(/[&"<>]/g, (c) => ATTR_ESC[c]);
378
380
  }
379
381
  function styleObjectToString(style) {
380
- return Object.entries(style).map(([key, value]) => {
382
+ let result = "";
383
+ for (const key in style) {
384
+ if (result)
385
+ result += ";";
381
386
  const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
382
- return `${cssKey}:${value}`;
383
- }).join(";");
387
+ result += cssKey + ":" + style[key];
388
+ }
389
+ return result;
384
390
  }
385
391
  var SVG_ATTR_MAP = {
386
392
  // Presentation / geometry
@@ -515,7 +521,8 @@ var SVG_ATTR_MAP = {
515
521
  };
516
522
  function renderAttributes(props, isSvg) {
517
523
  let attrs = "";
518
- for (const [key, value] of Object.entries(props)) {
524
+ for (const key in props) {
525
+ const value = props[key];
519
526
  if (key === "children" || key === "key" || key === "ref" || key === "dangerouslySetInnerHTML" || key === "suppressHydrationWarning" || key === "suppressContentEditableWarning")
520
527
  continue;
521
528
  if (key.startsWith("on") && key.length > 2 && key[2] === key[2].toUpperCase())
@@ -269,17 +269,23 @@ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
269
269
  "track",
270
270
  "wbr"
271
271
  ]);
272
+ var HTML_ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#x27;" };
272
273
  function escapeHtml(str) {
273
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/'/g, "&#x27;");
274
+ return str.replace(/[&<>']/g, (c) => HTML_ESC[c]);
274
275
  }
276
+ var ATTR_ESC = { "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" };
275
277
  function escapeAttr(str) {
276
- return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
278
+ return str.replace(/[&"<>]/g, (c) => ATTR_ESC[c]);
277
279
  }
278
280
  function styleObjectToString(style) {
279
- return Object.entries(style).map(([key, value]) => {
281
+ let result = "";
282
+ for (const key in style) {
283
+ if (result)
284
+ result += ";";
280
285
  const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
281
- return `${cssKey}:${value}`;
282
- }).join(";");
286
+ result += cssKey + ":" + style[key];
287
+ }
288
+ return result;
283
289
  }
284
290
  var SVG_ATTR_MAP = {
285
291
  // Presentation / geometry
@@ -414,7 +420,8 @@ var SVG_ATTR_MAP = {
414
420
  };
415
421
  function renderAttributes(props, isSvg) {
416
422
  let attrs = "";
417
- for (const [key, value] of Object.entries(props)) {
423
+ for (const key in props) {
424
+ const value = props[key];
418
425
  if (key === "children" || key === "key" || key === "ref" || key === "dangerouslySetInnerHTML" || key === "suppressHydrationWarning" || key === "suppressContentEditableWarning")
419
426
  continue;
420
427
  if (key.startsWith("on") && key.length > 2 && key[2] === key[2].toUpperCase())
@@ -294,17 +294,23 @@ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
294
294
  "track",
295
295
  "wbr"
296
296
  ]);
297
+ var HTML_ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#x27;" };
297
298
  function escapeHtml(str) {
298
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/'/g, "&#x27;");
299
+ return str.replace(/[&<>']/g, (c) => HTML_ESC[c]);
299
300
  }
301
+ var ATTR_ESC = { "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" };
300
302
  function escapeAttr(str) {
301
- return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
303
+ return str.replace(/[&"<>]/g, (c) => ATTR_ESC[c]);
302
304
  }
303
305
  function styleObjectToString(style) {
304
- return Object.entries(style).map(([key, value]) => {
306
+ let result = "";
307
+ for (const key in style) {
308
+ if (result)
309
+ result += ";";
305
310
  const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
306
- return `${cssKey}:${value}`;
307
- }).join(";");
311
+ result += cssKey + ":" + style[key];
312
+ }
313
+ return result;
308
314
  }
309
315
  var SVG_ATTR_MAP = {
310
316
  // Presentation / geometry
@@ -439,7 +445,8 @@ var SVG_ATTR_MAP = {
439
445
  };
440
446
  function renderAttributes(props, isSvg) {
441
447
  let attrs = "";
442
- for (const [key, value] of Object.entries(props)) {
448
+ for (const key in props) {
449
+ const value = props[key];
443
450
  if (key === "children" || key === "key" || key === "ref" || key === "dangerouslySetInnerHTML" || key === "suppressHydrationWarning" || key === "suppressContentEditableWarning")
444
451
  continue;
445
452
  if (key.startsWith("on") && key.length > 2 && key[2] === key[2].toUpperCase())
@@ -973,16 +980,18 @@ async function runFullLifecycle(serialReq) {
973
980
  };
974
981
  const unsuspend = { cache: /* @__PURE__ */ new Map() };
975
982
  globalThis.__hadarsUnsuspend = unsuspend;
983
+ let prelimHtml;
976
984
  try {
977
- let html = await renderToString(createElement(Component, props));
985
+ prelimHtml = await renderToString(createElement(Component, props));
978
986
  if (getAfterRenderProps) {
979
- props = await getAfterRenderProps(props, html);
987
+ props = await getAfterRenderProps(props, prelimHtml);
980
988
  await renderToString(
981
989
  createElement(Component, { ...props, location: serialReq.location, context })
982
990
  );
983
991
  }
984
- } finally {
992
+ } catch (e) {
985
993
  globalThis.__hadarsUnsuspend = null;
994
+ throw e;
986
995
  }
987
996
  const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
988
997
  const serverData = {};
@@ -996,7 +1005,16 @@ async function runFullLifecycle(serialReq) {
996
1005
  ...Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}
997
1006
  };
998
1007
  const finalAppProps = { ...props, location: serialReq.location, context };
999
- return { finalAppProps, clientProps, unsuspend, headHtml: buildHeadHtml(context.head), status: context.head.status ?? 200 };
1008
+ let appHtml;
1009
+ try {
1010
+ appHtml = await renderToString(createElement(Component, finalAppProps));
1011
+ } finally {
1012
+ globalThis.__hadarsUnsuspend = null;
1013
+ }
1014
+ appHtml = processSegmentCache(appHtml);
1015
+ const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c");
1016
+ const html = `<div id="app">${appHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>`;
1017
+ return { html, headHtml: buildHeadHtml(context.head), status: context.head.status ?? 200 };
1000
1018
  }
1001
1019
  parentPort.on("message", async (msg) => {
1002
1020
  const { id, type, request } = msg;
@@ -1004,18 +1022,7 @@ parentPort.on("message", async (msg) => {
1004
1022
  await init();
1005
1023
  if (type !== "renderFull")
1006
1024
  return;
1007
- const { finalAppProps, clientProps, unsuspend, headHtml, status } = await runFullLifecycle(request);
1008
- const Component = _ssrMod.default;
1009
- globalThis.__hadarsUnsuspend = unsuspend;
1010
- let appHtml;
1011
- try {
1012
- appHtml = await renderToString(createElement(Component, finalAppProps));
1013
- } finally {
1014
- globalThis.__hadarsUnsuspend = null;
1015
- }
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>`;
1025
+ const { html, headHtml, status } = await runFullLifecycle(request);
1019
1026
  parentPort.postMessage({ id, html, headHtml, status });
1020
1027
  } catch (err) {
1021
1028
  parentPort.postMessage({ id, error: err?.message ?? String(err) });
@@ -196,16 +196,11 @@ export function initServerDataCache(data: Record<string, unknown>) {
196
196
  * across all SSR render passes and client hydration — it must be stable and
197
197
  * unique within the page.
198
198
  *
199
- * `fn` may return a `Promise<T>` (normal async usage), return `T` synchronously,
200
- * or throw a thenable (React Suspense protocol). All three cases are handled:
201
- * - Async `Promise<T>`: awaited across render iterations, result cached.
202
- * - Synchronous return: value stored immediately, returned on the same pass.
203
- * - Thrown thenable (e.g. `useQuery({ suspense: true })`): the thrown promise is
204
- * awaited, the cache entry is then cleared so that the next render re-calls
205
- * `fn()` — at that point the Suspense hook returns synchronously.
199
+ * `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
200
+ * The resolved value is serialised into `__serverData` and returned from cache
201
+ * during hydration.
206
202
  *
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.
203
+ * `fn` is **server-only**: it is never called in the browser.
209
204
  *
210
205
  * @example
211
206
  * const user = useServerData('current_user', () => db.getUser(id));
@@ -230,51 +225,11 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
230
225
 
231
226
  const existing = unsuspend.cache.get(cacheKey);
232
227
 
233
- // Suspense promise has resolved — re-call fn() so the hook returns its value
234
- // synchronously from its own internal cache. Cache the result as
235
- // 'suspense-cached' so later renders (e.g. the final renderToString in
236
- // buildSsrResponse, which runs after getFinalProps may have cleared the
237
- // user's QueryClient) can return the value without calling fn() again.
238
- // NOT stored as 'fulfilled' so it is never included in serverData sent to
239
- // the client — the Suspense library owns its own hydration.
240
- if (existing?.status === 'suspense-resolved') {
241
- try {
242
- const value = fn() as T;
243
- unsuspend.cache.set(cacheKey, { status: 'suspense-cached', value });
244
- return value;
245
- } catch {
246
- return undefined;
247
- }
248
- }
249
-
250
- // Return the cached Suspense value on all subsequent renders.
251
- if (existing?.status === 'suspense-cached') {
252
- return existing.value as T;
253
- }
254
-
255
228
  if (!existing) {
256
229
  // First encounter — call fn(), which may:
257
- // (a) return a Promise<T> — normal async usage (serialised for the client)
230
+ // (a) return a Promise<T> — async usage (serialised for the client)
258
231
  // (b) return T synchronously — e.g. a sync data source
259
- // (c) throw a thenable — Suspense protocol (e.g. useSuspenseQuery)
260
- let result: Promise<T> | T;
261
- try {
262
- result = fn();
263
- } catch (thrown) {
264
- // (c) Suspense protocol: fn() threw a thenable. Await it, then mark the
265
- // entry as 'suspense-resolved' so the next render re-calls fn() to get
266
- // the synchronously available value. Not stored as 'fulfilled' → not
267
- // serialised to the client (the Suspense library handles its own hydration).
268
- if (thrown !== null && typeof thrown === 'object' && typeof (thrown as any).then === 'function') {
269
- const suspensePromise = Promise.resolve(thrown as Promise<unknown>).then(
270
- () => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
271
- () => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
272
- );
273
- unsuspend.cache.set(cacheKey, { status: 'pending', promise: suspensePromise });
274
- throw suspensePromise; // slim-react will await and retry
275
- }
276
- throw thrown;
277
- }
232
+ const result = fn();
278
233
 
279
234
  const isThenable = result !== null && typeof result === 'object' && typeof (result as any).then === 'function';
280
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
package/src/build.ts CHANGED
@@ -16,7 +16,6 @@ import os from 'node:os';
16
16
  import { spawn } from 'node:child_process';
17
17
  import cluster from 'node:cluster';
18
18
  import type { HadarsEntryModule, HadarsOptions, HadarsProps } from "./types/hadars";
19
- import { processSegmentCache } from "./utils/segmentCache";
20
19
  const encoder = new TextEncoder();
21
20
 
22
21
  /**
@@ -73,7 +72,7 @@ async function processHtmlTemplate(templatePath: string): Promise<string> {
73
72
  const HEAD_MARKER = '<meta name="HADARS_HEAD">';
74
73
  const BODY_MARKER = '<meta name="HADARS_BODY">';
75
74
 
76
- import { renderToString as slimRenderToString, createElement } from './slim-react/index';
75
+
77
76
 
78
77
  // Round-robin thread pool for SSR rendering — used on Bun/Deno where
79
78
  // node:cluster is not available but node:worker_threads is.
@@ -194,29 +193,19 @@ class RenderWorkerPool {
194
193
  }
195
194
 
196
195
  async function buildSsrResponse(
197
- App: any,
198
- appProps: Record<string, unknown>,
196
+ bodyHtml: string,
199
197
  clientProps: Record<string, unknown>,
200
198
  headHtml: string,
201
199
  status: number,
202
200
  getPrecontentHtml: (headHtml: string) => Promise<[string, string]>,
203
- unsuspendForRender: any,
204
201
  ): Promise<Response> {
205
202
  const responseStream = new ReadableStream({
206
203
  async start(controller) {
207
204
  const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
208
205
  // Flush the shell (precontentHtml) immediately so the browser can
209
- // start loading CSS/fonts before renderToString blocks the thread.
206
+ // start loading CSS/fonts before the body is assembled.
210
207
  controller.enqueue(encoder.encode(precontentHtml));
211
208
 
212
- let bodyHtml: string;
213
- try {
214
- (globalThis as any).__hadarsUnsuspend = unsuspendForRender;
215
- bodyHtml = await slimRenderToString(createElement(App, appProps));
216
- } finally {
217
- (globalThis as any).__hadarsUnsuspend = null;
218
- }
219
- bodyHtml = processSegmentCache(bodyHtml);
220
209
  const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c');
221
210
  controller.enqueue(encoder.encode(
222
211
  `<div id="app">${bodyHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>` + postContent
@@ -690,7 +679,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
690
679
  getFinalProps,
691
680
  } = (await import(importPath)) as HadarsEntryModule<any>;
692
681
 
693
- const { App, appProps, clientProps, unsuspend, status, headHtml } = await getReactResponse(request, {
682
+ const { bodyHtml, clientProps, status, headHtml } = await getReactResponse(request, {
694
683
  document: {
695
684
  body: Component as React.FC<HadarsProps<object>>,
696
685
  lang: 'en',
@@ -700,7 +689,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
700
689
  },
701
690
  });
702
691
 
703
- return buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspend);
692
+ return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
704
693
  } catch (err: any) {
705
694
  console.error('[hadars] SSR render error:', err);
706
695
  const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, '&lt;');
@@ -880,7 +869,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
880
869
  });
881
870
  }
882
871
 
883
- const { App, appProps, clientProps, unsuspend, status, headHtml } = await getReactResponse(request, {
872
+ const { bodyHtml, clientProps, status, headHtml } = await getReactResponse(request, {
884
873
  document: {
885
874
  body: Component as React.FC<HadarsProps<object>>,
886
875
  lang: 'en',
@@ -890,7 +879,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
890
879
  },
891
880
  });
892
881
 
893
- return buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspend);
882
+ return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
894
883
  } catch (err: any) {
895
884
  console.error('[hadars] SSR render error:', err);
896
885
  return new Response('Internal Server Error', { status: 500 });
@@ -61,30 +61,25 @@ const VOID_ELEMENTS = new Set([
61
61
  "wbr",
62
62
  ]);
63
63
 
64
+ const HTML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#x27;' };
64
65
  function escapeHtml(str: string): string {
65
- return str
66
- .replace(/&/g, "&amp;")
67
- .replace(/</g, "&lt;")
68
- .replace(/>/g, "&gt;")
69
- .replace(/'/g, "&#x27;");
66
+ return str.replace(/[&<>']/g, c => HTML_ESC[c]!);
70
67
  }
71
68
 
69
+ const ATTR_ESC: Record<string, string> = { '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' };
72
70
  function escapeAttr(str: string): string {
73
- return str
74
- .replace(/&/g, "&amp;")
75
- .replace(/"/g, "&quot;")
76
- .replace(/</g, "&lt;")
77
- .replace(/>/g, "&gt;");
71
+ return str.replace(/[&"<>]/g, c => ATTR_ESC[c]!);
78
72
  }
79
73
 
80
74
  function styleObjectToString(style: Record<string, any>): string {
81
- return Object.entries(style)
82
- .map(([key, value]) => {
83
- // camelCase kebab-case
84
- const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
85
- return `${cssKey}:${value}`;
86
- })
87
- .join(";");
75
+ let result = '';
76
+ for (const key in style) {
77
+ if (result) result += ';';
78
+ // camelCase kebab-case
79
+ const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
80
+ result += cssKey + ':' + style[key];
81
+ }
82
+ return result;
88
83
  }
89
84
 
90
85
  // ---------------------------------------------------------------------------
@@ -247,7 +242,8 @@ const SVG_ELEMENTS = new Set([
247
242
 
248
243
  function renderAttributes(props: Record<string, any>, isSvg: boolean): string {
249
244
  let attrs = "";
250
- for (const [key, value] of Object.entries(props)) {
245
+ for (const key in props) {
246
+ const value = props[key];
251
247
  // Skip internal / non-attribute props
252
248
  if (
253
249
  key === "children" ||
@@ -102,17 +102,19 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
102
102
  const unsuspend = { cache: new Map<string, any>() };
103
103
  (globalThis as any).__hadarsUnsuspend = unsuspend;
104
104
 
105
+ let prelimHtml: string;
105
106
  try {
106
- let html = await renderToString(createElement(Component, props));
107
+ prelimHtml = await renderToString(createElement(Component, props));
107
108
 
108
109
  if (getAfterRenderProps) {
109
- props = await getAfterRenderProps(props, html);
110
+ props = await getAfterRenderProps(props, prelimHtml);
110
111
  await renderToString(
111
112
  createElement(Component, { ...props, location: serialReq.location, context }),
112
113
  );
113
114
  }
114
- } finally {
115
+ } catch (e) {
115
116
  (globalThis as any).__hadarsUnsuspend = null;
117
+ throw e;
116
118
  }
117
119
 
118
120
  const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
@@ -129,7 +131,21 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
129
131
  };
130
132
 
131
133
  const finalAppProps = { ...props, location: serialReq.location, context };
132
- return { finalAppProps, clientProps, unsuspend, headHtml: buildHeadHtml(context.head), status: context.head.status ?? 200 };
134
+
135
+ // Final render — __hadarsUnsuspend is still set; cache is fully populated so
136
+ // useServerData calls return cached values without any async work.
137
+ let appHtml: string;
138
+ try {
139
+ appHtml = await renderToString(createElement(Component, finalAppProps));
140
+ } finally {
141
+ (globalThis as any).__hadarsUnsuspend = null;
142
+ }
143
+ appHtml = processSegmentCache(appHtml);
144
+
145
+ const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c');
146
+ const html = `<div id="app">${appHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>`;
147
+
148
+ return { html, headHtml: buildHeadHtml(context.head), status: context.head.status ?? 200 };
133
149
  }
134
150
 
135
151
  // ── Message handler ────────────────────────────────────────────────────────
@@ -140,27 +156,7 @@ parentPort!.on('message', async (msg: any) => {
140
156
  await init();
141
157
  if (type !== 'renderFull') return;
142
158
 
143
- const { finalAppProps, clientProps, unsuspend, headHtml, status } =
144
- await runFullLifecycle(request as SerializableRequest);
145
-
146
- const Component = _ssrMod.default;
147
-
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.
152
- (globalThis as any).__hadarsUnsuspend = unsuspend;
153
- let appHtml: string;
154
- try {
155
- appHtml = await renderToString(createElement(Component, finalAppProps));
156
- } finally {
157
- (globalThis as any).__hadarsUnsuspend = null;
158
- }
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>`;
163
-
159
+ const { html, headHtml, status } = await runFullLifecycle(request as SerializableRequest);
164
160
  parentPort!.postMessage({ id, html, headHtml, status });
165
161
 
166
162
  } catch (err: any) {
@@ -26,14 +26,11 @@ export interface AppHead {
26
26
  export type UnsuspendEntry =
27
27
  | { status: 'pending'; promise: Promise<unknown> }
28
28
  | { status: 'fulfilled'; value: unknown }
29
- | { status: 'suspense-resolved' }
30
- | { status: 'suspense-cached'; value: unknown }
31
29
  | { status: 'rejected'; reason: unknown };
32
30
 
33
31
  /** @internal Populated by the framework's render loop — use useServerData() instead. */
34
32
  export interface AppUnsuspend {
35
33
  cache: Map<string, UnsuspendEntry>;
36
- hasPending: boolean;
37
34
  }
38
35
 
39
36
  export interface AppContext {
@@ -196,16 +196,11 @@ export function initServerDataCache(data: Record<string, unknown>) {
196
196
  * across all SSR render passes and client hydration — it must be stable and
197
197
  * unique within the page.
198
198
  *
199
- * `fn` may return a `Promise<T>` (normal async usage), return `T` synchronously,
200
- * or throw a thenable (React Suspense protocol). All three cases are handled:
201
- * - Async `Promise<T>`: awaited across render iterations, result cached.
202
- * - Synchronous return: value stored immediately, returned on the same pass.
203
- * - Thrown thenable (e.g. `useQuery({ suspense: true })`): the thrown promise is
204
- * awaited, the cache entry is then cleared so that the next render re-calls
205
- * `fn()` — at that point the Suspense hook returns synchronously.
199
+ * `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
200
+ * The resolved value is serialised into `__serverData` and returned from cache
201
+ * during hydration.
206
202
  *
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.
203
+ * `fn` is **server-only**: it is never called in the browser.
209
204
  *
210
205
  * @example
211
206
  * const user = useServerData('current_user', () => db.getUser(id));
@@ -230,51 +225,11 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
230
225
 
231
226
  const existing = unsuspend.cache.get(cacheKey);
232
227
 
233
- // Suspense promise has resolved — re-call fn() so the hook returns its value
234
- // synchronously from its own internal cache. Cache the result as
235
- // 'suspense-cached' so later renders (e.g. the final renderToString in
236
- // buildSsrResponse, which runs after getFinalProps may have cleared the
237
- // user's QueryClient) can return the value without calling fn() again.
238
- // NOT stored as 'fulfilled' so it is never included in serverData sent to
239
- // the client — the Suspense library owns its own hydration.
240
- if (existing?.status === 'suspense-resolved') {
241
- try {
242
- const value = fn() as T;
243
- unsuspend.cache.set(cacheKey, { status: 'suspense-cached', value });
244
- return value;
245
- } catch {
246
- return undefined;
247
- }
248
- }
249
-
250
- // Return the cached Suspense value on all subsequent renders.
251
- if (existing?.status === 'suspense-cached') {
252
- return existing.value as T;
253
- }
254
-
255
228
  if (!existing) {
256
229
  // First encounter — call fn(), which may:
257
- // (a) return a Promise<T> — normal async usage (serialised for the client)
230
+ // (a) return a Promise<T> — async usage (serialised for the client)
258
231
  // (b) return T synchronously — e.g. a sync data source
259
- // (c) throw a thenable — Suspense protocol (e.g. useSuspenseQuery)
260
- let result: Promise<T> | T;
261
- try {
262
- result = fn();
263
- } catch (thrown) {
264
- // (c) Suspense protocol: fn() threw a thenable. Await it, then mark the
265
- // entry as 'suspense-resolved' so the next render re-calls fn() to get
266
- // the synchronously available value. Not stored as 'fulfilled' → not
267
- // serialised to the client (the Suspense library handles its own hydration).
268
- if (thrown !== null && typeof thrown === 'object' && typeof (thrown as any).then === 'function') {
269
- const suspensePromise = Promise.resolve(thrown as Promise<unknown>).then(
270
- () => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
271
- () => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
272
- );
273
- unsuspend.cache.set(cacheKey, { status: 'pending', promise: suspensePromise });
274
- throw suspensePromise; // slim-react will await and retry
275
- }
276
- throw thrown;
277
- }
232
+ const result = fn();
278
233
 
279
234
  const isThenable = result !== null && typeof result === 'object' && typeof (result as any).then === 'function';
280
235
 
@@ -1,6 +1,7 @@
1
1
  import type React from "react";
2
2
  import type { AppHead, HadarsRequest, HadarsEntryBase, HadarsEntryModule, HadarsProps, AppContext } from "../types/hadars";
3
3
  import { renderToString, createElement } from '../slim-react/index';
4
+ import { processSegmentCache } from './segmentCache';
4
5
 
5
6
  interface ReactResponseOptions {
6
7
  document: {
@@ -55,10 +56,8 @@ export const getReactResponse = async (
55
56
  req: HadarsRequest,
56
57
  opts: ReactResponseOptions,
57
58
  ): Promise<{
58
- App: React.FC<any>,
59
- appProps: Record<string, unknown>,
59
+ bodyHtml: string,
60
60
  clientProps: Record<string, unknown>,
61
- unsuspend: { cache: Map<string, any> },
62
61
  status: number,
63
62
  headHtml: string,
64
63
  }> => {
@@ -78,11 +77,12 @@ export const getReactResponse = async (
78
77
  // Create per-request cache for useServerData, active for all renders.
79
78
  const unsuspend = { cache: new Map<string, any>() };
80
79
  (globalThis as any).__hadarsUnsuspend = unsuspend;
80
+ let bodyHtml: string;
81
81
  try {
82
- let html = await renderToString(createElement(App as any, props as any));
82
+ bodyHtml = await renderToString(createElement(App as any, props as any));
83
83
  if (getAfterRenderProps) {
84
- props = await getAfterRenderProps(props, html);
85
- await renderToString(
84
+ props = await getAfterRenderProps(props, bodyHtml);
85
+ bodyHtml = await renderToString(
86
86
  createElement(App as any, { ...props, location: req.location, context } as any),
87
87
  );
88
88
  }
@@ -103,13 +103,9 @@ export const getReactResponse = async (
103
103
  ...(Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}),
104
104
  };
105
105
 
106
- const appProps = { ...props, location: req.location, context } as unknown as Record<string, unknown>;
107
-
108
106
  return {
109
- App: App as React.FC<any>,
110
- appProps,
107
+ bodyHtml: processSegmentCache(bodyHtml),
111
108
  clientProps: clientProps as Record<string, unknown>,
112
- unsuspend,
113
109
  status: context.head.status,
114
110
  headHtml: getHeadHtml(context.head),
115
111
  };