hadars 0.1.29 → 0.1.30

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 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 = "\xAB" + st.idPrefix + "R" + treeId;
268
+ let id = "_R_" + st.idPrefix + treeId;
269
269
  if (n > 0)
270
270
  id += "H" + n.toString(32);
271
- return id + "\xBB";
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 ReactPkg from "react";
334
- var _internals = ReactPkg.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
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),
@@ -915,8 +915,8 @@ async function renderSuspense(props, writer, isSvg = false) {
915
915
  const snap = snapshotContext();
916
916
  while (attempts < MAX_SUSPENSE_RETRIES) {
917
917
  restoreContext(snap);
918
+ let buffer = new BufferWriter();
918
919
  try {
919
- const buffer = new BufferWriter();
920
920
  const r = renderNode(children, buffer, isSvg);
921
921
  if (r && typeof r.then === "function") {
922
922
  const m = captureMap();
@@ -950,12 +950,13 @@ async function renderSuspense(props, writer, isSvg = false) {
950
950
  }
951
951
  writer.write("<!--/$-->");
952
952
  }
953
- async function renderToString(element) {
953
+ async function renderToString(element, options) {
954
+ const idPrefix = options?.identifierPrefix ?? "";
954
955
  const contextMap = /* @__PURE__ */ new Map();
955
956
  const prev = swapContextMap(contextMap);
956
957
  try {
957
958
  for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
958
- resetRenderState();
959
+ resetRenderState(idPrefix);
959
960
  swapContextMap(contextMap);
960
961
  const chunks = [];
961
962
  const writer = {
@@ -1072,31 +1073,14 @@ var getReactResponse = async (req, opts) => {
1072
1073
  location: req.location,
1073
1074
  ...Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}
1074
1075
  };
1075
- const ReactPage = createElement(
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
- );
1076
+ const appProps = { ...props, location: req.location, context };
1091
1077
  return {
1092
- ReactPage,
1078
+ App,
1079
+ appProps,
1080
+ clientProps,
1093
1081
  unsuspend,
1094
1082
  status: context.head.status,
1095
- headHtml: getHeadHtml(context.head),
1096
- renderPayload: {
1097
- appProps: { ...props, location: req.location, context },
1098
- clientProps
1099
- }
1083
+ headHtml: getHeadHtml(context.head)
1100
1084
  };
1101
1085
  };
1102
1086
 
@@ -1830,7 +1814,7 @@ var RenderWorkerPool = class {
1830
1814
  await Promise.all(this.workers.map((w) => w.terminate()));
1831
1815
  }
1832
1816
  };
1833
- async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspendForRender) {
1817
+ async function buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspendForRender) {
1834
1818
  const responseStream = new ReadableStream({
1835
1819
  async start(controller) {
1836
1820
  const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
@@ -1838,12 +1822,15 @@ async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml,
1838
1822
  let bodyHtml;
1839
1823
  try {
1840
1824
  globalThis.__hadarsUnsuspend = unsuspendForRender;
1841
- bodyHtml = await renderToString(ReactPage);
1825
+ bodyHtml = await renderToString(createElement(App, appProps));
1842
1826
  } finally {
1843
1827
  globalThis.__hadarsUnsuspend = null;
1844
1828
  }
1845
1829
  bodyHtml = processSegmentCache(bodyHtml);
1846
- controller.enqueue(encoder.encode(bodyHtml + postContent));
1830
+ const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c");
1831
+ controller.enqueue(encoder.encode(
1832
+ `<div id="app">${bodyHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>` + postContent
1833
+ ));
1847
1834
  controller.close();
1848
1835
  }
1849
1836
  });
@@ -2213,7 +2200,7 @@ var dev = async (options) => {
2213
2200
  getAfterRenderProps,
2214
2201
  getFinalProps
2215
2202
  } = await import(importPath);
2216
- const { ReactPage, unsuspend, status, headHtml } = await getReactResponse(request, {
2203
+ const { App, appProps, clientProps, unsuspend, status, headHtml } = await getReactResponse(request, {
2217
2204
  document: {
2218
2205
  body: Component,
2219
2206
  lang: "en",
@@ -2222,7 +2209,7 @@ var dev = async (options) => {
2222
2209
  getFinalProps
2223
2210
  }
2224
2211
  });
2225
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
2212
+ return buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspend);
2226
2213
  } catch (err) {
2227
2214
  console.error("[hadars] SSR render error:", err);
2228
2215
  const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, "&lt;");
@@ -2367,7 +2354,7 @@ var run = async (options) => {
2367
2354
  status: wStatus
2368
2355
  });
2369
2356
  }
2370
- const { ReactPage, unsuspend, status, headHtml } = await getReactResponse(request, {
2357
+ const { App, appProps, clientProps, unsuspend, status, headHtml } = await getReactResponse(request, {
2371
2358
  document: {
2372
2359
  body: Component,
2373
2360
  lang: "en",
@@ -2376,7 +2363,7 @@ var run = async (options) => {
2376
2363
  getFinalProps
2377
2364
  }
2378
2365
  });
2379
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
2366
+ return buildSsrResponse(App, appProps, clientProps, headHtml, status, getPrecontentHtml, unsuspend);
2380
2367
  } catch (err) {
2381
2368
  console.error("[hadars] SSR render error:", err);
2382
2369
  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
- if (clientServerDataCache.has(cacheKey)) {
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
- if (clientServerDataCache.has(cacheKey)) {
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 = "\xAB" + st.idPrefix + "R" + treeId;
219
+ let id = "_R_" + st.idPrefix + treeId;
219
220
  if (n > 0)
220
221
  id += "H" + n.toString(32);
221
- return id + "\xBB";
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 import_react = __toESM(require("react"), 1);
316
- var _internals = import_react.default.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
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),
@@ -897,8 +898,8 @@ async function renderSuspense(props, writer, isSvg = false) {
897
898
  const snap = snapshotContext();
898
899
  while (attempts < MAX_SUSPENSE_RETRIES) {
899
900
  restoreContext(snap);
901
+ let buffer = new BufferWriter();
900
902
  try {
901
- const buffer = new BufferWriter();
902
903
  const r = renderNode(children, buffer, isSvg);
903
904
  if (r && typeof r.then === "function") {
904
905
  const m = captureMap();
@@ -932,12 +933,13 @@ async function renderSuspense(props, writer, isSvg = false) {
932
933
  }
933
934
  writer.write("<!--/$-->");
934
935
  }
935
- function renderToStream(element) {
936
+ function renderToStream(element, options) {
936
937
  const encoder = new TextEncoder();
938
+ const idPrefix = options?.identifierPrefix ?? "";
937
939
  const contextMap = /* @__PURE__ */ new Map();
938
940
  return new ReadableStream({
939
941
  async start(controller) {
940
- resetRenderState();
942
+ resetRenderState(idPrefix);
941
943
  const prev = swapContextMap(contextMap);
942
944
  const writer = {
943
945
  lastWasText: false,
@@ -966,12 +968,13 @@ function renderToStream(element) {
966
968
  }
967
969
  });
968
970
  }
969
- async function renderToString(element) {
971
+ async function renderToString(element, options) {
972
+ const idPrefix = options?.identifierPrefix ?? "";
970
973
  const contextMap = /* @__PURE__ */ new Map();
971
974
  const prev = swapContextMap(contextMap);
972
975
  try {
973
976
  for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
974
- resetRenderState();
977
+ resetRenderState(idPrefix);
975
978
  swapContextMap(contextMap);
976
979
  const chunks = [];
977
980
  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 };
@@ -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 = "\xAB" + st.idPrefix + "R" + treeId;
118
+ let id = "_R_" + st.idPrefix + treeId;
118
119
  if (n > 0)
119
120
  id += "H" + n.toString(32);
120
- return id + "\xBB";
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 ReactPkg from "react";
215
- var _internals = ReactPkg.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
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),
@@ -796,8 +797,8 @@ async function renderSuspense(props, writer, isSvg = false) {
796
797
  const snap = snapshotContext();
797
798
  while (attempts < MAX_SUSPENSE_RETRIES) {
798
799
  restoreContext(snap);
800
+ let buffer = new BufferWriter();
799
801
  try {
800
- const buffer = new BufferWriter();
801
802
  const r = renderNode(children, buffer, isSvg);
802
803
  if (r && typeof r.then === "function") {
803
804
  const m = captureMap();
@@ -831,12 +832,13 @@ async function renderSuspense(props, writer, isSvg = false) {
831
832
  }
832
833
  writer.write("<!--/$-->");
833
834
  }
834
- function renderToStream(element) {
835
+ function renderToStream(element, options) {
835
836
  const encoder = new TextEncoder();
837
+ const idPrefix = options?.identifierPrefix ?? "";
836
838
  const contextMap = /* @__PURE__ */ new Map();
837
839
  return new ReadableStream({
838
840
  async start(controller) {
839
- resetRenderState();
841
+ resetRenderState(idPrefix);
840
842
  const prev = swapContextMap(contextMap);
841
843
  const writer = {
842
844
  lastWasText: false,
@@ -865,12 +867,13 @@ function renderToStream(element) {
865
867
  }
866
868
  });
867
869
  }
868
- async function renderToString(element) {
870
+ async function renderToString(element, options) {
871
+ const idPrefix = options?.identifierPrefix ?? "";
869
872
  const contextMap = /* @__PURE__ */ new Map();
870
873
  const prev = swapContextMap(contextMap);
871
874
  try {
872
875
  for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
873
- resetRenderState();
876
+ resetRenderState(idPrefix);
874
877
  swapContextMap(contextMap);
875
878
  const chunks = [];
876
879
  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 = "\xAB" + st.idPrefix + "R" + treeId;
175
+ let id = "_R_" + st.idPrefix + treeId;
176
176
  if (n > 0)
177
177
  id += "H" + n.toString(32);
178
- return id + "\xBB";
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 ReactPkg from "react";
241
- var _internals = ReactPkg.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
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),
@@ -822,8 +822,8 @@ async function renderSuspense(props, writer, isSvg = false) {
822
822
  const snap = snapshotContext();
823
823
  while (attempts < MAX_SUSPENSE_RETRIES) {
824
824
  restoreContext(snap);
825
+ let buffer = new BufferWriter();
825
826
  try {
826
- const buffer = new BufferWriter();
827
827
  const r = renderNode(children, buffer, isSvg);
828
828
  if (r && typeof r.then === "function") {
829
829
  const m = captureMap();
@@ -857,12 +857,13 @@ async function renderSuspense(props, writer, isSvg = false) {
857
857
  }
858
858
  writer.write("<!--/$-->");
859
859
  }
860
- async function renderToString(element) {
860
+ async function renderToString(element, options) {
861
+ const idPrefix = options?.identifierPrefix ?? "";
861
862
  const contextMap = /* @__PURE__ */ new Map();
862
863
  const prev = swapContextMap(contextMap);
863
864
  try {
864
865
  for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
865
- resetRenderState();
866
+ resetRenderState(idPrefix);
866
867
  swapContextMap(contextMap);
867
868
  const chunks = [];
868
869
  const writer = {
@@ -1003,26 +1004,16 @@ parentPort.on("message", async (msg) => {
1003
1004
  return;
1004
1005
  const { finalAppProps, clientProps, unsuspend, headHtml, status } = await runFullLifecycle(request);
1005
1006
  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
1007
  globalThis.__hadarsUnsuspend = unsuspend;
1019
- let html;
1008
+ let appHtml;
1020
1009
  try {
1021
- html = await renderToString(page);
1010
+ appHtml = await renderToString(createElement(Component, finalAppProps));
1022
1011
  } finally {
1023
1012
  globalThis.__hadarsUnsuspend = null;
1024
1013
  }
1025
- html = processSegmentCache(html);
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>`;
1026
1017
  parentPort.postMessage({ id, html, headHtml, status });
1027
1018
  } catch (err) {
1028
1019
  parentPort.postMessage({ id, error: err?.message ?? String(err) });
@@ -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
- // (normal async case no re-fetch in the browser).
218
- if (clientServerDataCache.has(cacheKey)) {
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.29",
3
+ "version": "0.1.30",
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.1.1",
56
- "react-dom": "^19.1.1",
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
- ReactPage: any,
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(ReactPage);
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
- controller.enqueue(encoder.encode(bodyHtml + postContent));
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 { ReactPage, unsuspend, status, headHtml } = await getReactResponse(request, {
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(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
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, '&lt;');
@@ -875,7 +880,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
875
880
  });
876
881
  }
877
882
 
878
- const { ReactPage, unsuspend, status, headHtml } = await getReactResponse(request, {
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(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
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 ReactPkg from "react";
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
- (ReactPkg as any).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
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.
@@ -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";
@@ -812,8 +812,8 @@ async function renderSuspense(
812
812
  while (attempts < MAX_SUSPENSE_RETRIES) {
813
813
  // Restore context to the state it was in when we entered <Suspense>.
814
814
  restoreContext(snap);
815
+ let buffer = new BufferWriter();
815
816
  try {
816
- const buffer = new BufferWriter();
817
817
  const r = renderNode(children, buffer, isSvg);
818
818
  if (r && typeof (r as any).then === "function") {
819
819
  const m = captureMap(); await r; swapContextMap(m);
@@ -850,19 +850,32 @@ async function renderSuspense(
850
850
  // Public API
851
851
  // ---------------------------------------------------------------------------
852
852
 
853
+ export interface RenderOptions {
854
+ /**
855
+ * Must match the `identifierPrefix` option passed to `hydrateRoot` on the
856
+ * client so that `useId()` generates identical IDs on server and client.
857
+ * Defaults to `""` (React's default).
858
+ */
859
+ identifierPrefix?: string;
860
+ }
861
+
853
862
  /**
854
863
  * Render a component tree to a `ReadableStream<Uint8Array>`.
855
864
  *
856
865
  * The stream pauses at `<Suspense>` boundaries until the suspended
857
866
  * promise resolves, then continues writing HTML.
858
867
  */
859
- export function renderToStream(element: SlimNode): ReadableStream<Uint8Array> {
868
+ export function renderToStream(
869
+ element: SlimNode,
870
+ options?: RenderOptions,
871
+ ): ReadableStream<Uint8Array> {
860
872
  const encoder = new TextEncoder();
873
+ const idPrefix = options?.identifierPrefix ?? "";
861
874
 
862
875
  const contextMap = new Map<object, unknown>();
863
876
  return new ReadableStream({
864
877
  async start(controller) {
865
- resetRenderState();
878
+ resetRenderState(idPrefix);
866
879
  const prev = swapContextMap(contextMap);
867
880
 
868
881
  const writer: Writer = {
@@ -897,12 +910,16 @@ export function renderToStream(element: SlimNode): ReadableStream<Uint8Array> {
897
910
  * Retries the full tree when a component throws a Promise (Suspense protocol),
898
911
  * so useServerData and similar hooks work without requiring explicit <Suspense>.
899
912
  */
900
- export async function renderToString(element: SlimNode): Promise<string> {
913
+ export async function renderToString(
914
+ element: SlimNode,
915
+ options?: RenderOptions,
916
+ ): Promise<string> {
917
+ const idPrefix = options?.identifierPrefix ?? "";
901
918
  const contextMap = new Map<object, unknown>();
902
919
  const prev = swapContextMap(contextMap);
903
920
  try {
904
921
  for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
905
- resetRenderState();
922
+ resetRenderState(idPrefix);
906
923
  swapContextMap(contextMap); // re-activate our map on each retry
907
924
  const chunks: string[] = [];
908
925
  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: `«<idPrefix>R<treeId>»` (React 19.1+)
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.1 switched from `_R_<id>_` to `«R<id>»` (U+00AB / U+00BB).
200
- * This matches React 19.1's `mountId` output on the Fizz SSR renderer and
201
- * the client hydration path, so the IDs produced here will agree with the
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 = "\u00ab" + st.idPrefix + "R" + treeId;
208
+ let id = "_R_" + st.idPrefix + treeId;
209
209
  if (n > 0) id += "H" + n.toString(32);
210
- return id + "\u00bb";
210
+ return id + "_";
211
211
  }
@@ -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, Fragment } from './slim-react/index';
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
- const page = createElement(Fragment, null,
149
- createElement('div', { id: 'app' }, createElement(Component, finalAppProps)),
150
- createElement('script', {
151
- id: 'hadars',
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 html: string;
153
+ let appHtml: string;
162
154
  try {
163
- html = await renderToString(page);
155
+ appHtml = await renderToString(createElement(Component, finalAppProps));
164
156
  } finally {
165
157
  (globalThis as any).__hadarsUnsuspend = null;
166
158
  }
167
- html = processSegmentCache(html);
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
 
@@ -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
- // (normal async case no re-fetch in the browser).
218
- if (clientServerDataCache.has(cacheKey)) {
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
@@ -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, Fragment } from '../slim-react/index';
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
- ReactPage: any,
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 ReactPage = createElement(Fragment, null,
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
- ReactPage,
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
  };