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 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),
@@ -558,7 +558,9 @@ function renderAttributes(props, isSvg) {
558
558
  continue;
559
559
  }
560
560
  if (key === "style" && typeof value === "object") {
561
- attrs += ` style="${escapeAttr(styleObjectToString(value))}"`;
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 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
- );
1078
+ const appProps = { ...props, location: req.location, context };
1091
1079
  return {
1092
- ReactPage,
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(ReactPage, headHtml, status, getPrecontentHtml, unsuspendForRender) {
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(ReactPage);
1827
+ bodyHtml = await renderToString(createElement(App, appProps));
1842
1828
  } finally {
1843
1829
  globalThis.__hadarsUnsuspend = null;
1844
1830
  }
1845
1831
  bodyHtml = processSegmentCache(bodyHtml);
1846
- controller.enqueue(encoder.encode(bodyHtml + postContent));
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 { ReactPage, unsuspend, status, headHtml } = await getReactResponse(request, {
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(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
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, "&lt;");
@@ -2367,7 +2356,7 @@ var run = async (options) => {
2367
2356
  status: wStatus
2368
2357
  });
2369
2358
  }
2370
- const { ReactPage, unsuspend, status, headHtml } = await getReactResponse(request, {
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(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
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
- 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),
@@ -540,7 +541,9 @@ function renderAttributes(props, isSvg) {
540
541
  continue;
541
542
  }
542
543
  if (key === "style" && typeof value === "object") {
543
- attrs += ` style="${escapeAttr(styleObjectToString(value))}"`;
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 };
@@ -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),
@@ -439,7 +440,9 @@ function renderAttributes(props, isSvg) {
439
440
  continue;
440
441
  }
441
442
  if (key === "style" && typeof value === "object") {
442
- attrs += ` style="${escapeAttr(styleObjectToString(value))}"`;
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 = "\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),
@@ -465,7 +465,9 @@ function renderAttributes(props, isSvg) {
465
465
  continue;
466
466
  }
467
467
  if (key === "style" && typeof value === "object") {
468
- attrs += ` style="${escapeAttr(styleObjectToString(value))}"`;
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 html;
1010
+ let appHtml;
1020
1011
  try {
1021
- html = await renderToString(page);
1012
+ appHtml = await renderToString(createElement(Component, finalAppProps));
1022
1013
  } finally {
1023
1014
  globalThis.__hadarsUnsuspend = null;
1024
1015
  }
1025
- html = processSegmentCache(html);
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) });
@@ -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.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.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";
@@ -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
- attrs += ` style="${escapeAttr(styleObjectToString(value))}"`;
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(element: SlimNode): ReadableStream<Uint8Array> {
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(element: SlimNode): Promise<string> {
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: `«<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
  };