hadars 0.1.17 → 0.1.19

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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Render-time context for tree-position-based `useId`.
3
+ *
4
+ * State lives on `globalThis` rather than module-level variables so that
5
+ * multiple slim-react instances (the render worker's direct import and the
6
+ * SSR bundle's bundled copy) share the same context without any coordination.
7
+ * Safe because each worker processes one render at a time; `resetRenderState`
8
+ * is always called at the top of every `renderToString` / `renderToStream`.
9
+ */
10
+
11
+ export interface TreeContext {
12
+ id: number;
13
+ overflow: string;
14
+ bits: number;
15
+ }
16
+
17
+ interface RenderState {
18
+ currentTreeContext: TreeContext;
19
+ localIdCounter: number;
20
+ idPrefix: string;
21
+ }
22
+
23
+ const GLOBAL_KEY = "__slimReactRenderState";
24
+ const EMPTY: TreeContext = { id: 0, overflow: "", bits: 0 };
25
+
26
+ function s(): RenderState {
27
+ const g = globalThis as any;
28
+ if (!g[GLOBAL_KEY]) {
29
+ g[GLOBAL_KEY] = { currentTreeContext: { ...EMPTY }, localIdCounter: 0, idPrefix: "" };
30
+ }
31
+ return g[GLOBAL_KEY] as RenderState;
32
+ }
33
+
34
+ export function resetRenderState() {
35
+ const st = s();
36
+ st.currentTreeContext = { ...EMPTY };
37
+ st.localIdCounter = 0;
38
+ }
39
+
40
+ export function setIdPrefix(prefix: string) {
41
+ s().idPrefix = prefix;
42
+ }
43
+
44
+ export function pushTreeContext(totalChildren: number, index: number): TreeContext {
45
+ const st = s();
46
+ const saved: TreeContext = { ...st.currentTreeContext };
47
+ const pendingBits = 32 - Math.clz32(totalChildren);
48
+ const slot = index + 1;
49
+ const totalBits = st.currentTreeContext.bits + pendingBits;
50
+
51
+ if (totalBits <= 30) {
52
+ st.currentTreeContext = {
53
+ id: (st.currentTreeContext.id << pendingBits) | slot,
54
+ overflow: st.currentTreeContext.overflow,
55
+ bits: totalBits,
56
+ };
57
+ } else {
58
+ let newOverflow = st.currentTreeContext.overflow;
59
+ if (st.currentTreeContext.bits > 0) newOverflow += st.currentTreeContext.id.toString(32);
60
+ st.currentTreeContext = { id: (1 << pendingBits) | slot, overflow: newOverflow, bits: pendingBits };
61
+ }
62
+ return saved;
63
+ }
64
+
65
+ export function popTreeContext(saved: TreeContext) {
66
+ s().currentTreeContext = saved;
67
+ }
68
+
69
+ export function pushComponentScope(): number {
70
+ const st = s();
71
+ const saved = st.localIdCounter;
72
+ st.localIdCounter = 0;
73
+ return saved;
74
+ }
75
+
76
+ export function popComponentScope(saved: number) {
77
+ s().localIdCounter = saved;
78
+ }
79
+
80
+ export function snapshotContext(): { tree: TreeContext; localId: number } {
81
+ const st = s();
82
+ return { tree: { ...st.currentTreeContext }, localId: st.localIdCounter };
83
+ }
84
+
85
+ export function restoreContext(snap: { tree: TreeContext; localId: number }) {
86
+ const st = s();
87
+ st.currentTreeContext = { ...snap.tree };
88
+ st.localIdCounter = snap.localId;
89
+ }
90
+
91
+ function getTreeId(): string {
92
+ const { id, overflow, bits } = s().currentTreeContext;
93
+ return bits > 0 ? overflow + id.toString(32) : overflow;
94
+ }
95
+
96
+ export function makeId(): string {
97
+ const st = s();
98
+ const treeId = getTreeId();
99
+ const n = st.localIdCounter++;
100
+ let id = ":" + st.idPrefix + "R";
101
+ if (treeId.length > 0) id += treeId;
102
+ id += ":";
103
+ if (n > 0) id += "H" + n.toString(32) + ":";
104
+ return id;
105
+ }
@@ -0,0 +1,33 @@
1
+ // ---- Symbols ----
2
+ // Use the same symbols as React so elements produced here are wire-compatible
3
+ // with elements produced by the real React JSX runtime (e.g. when a library
4
+ // uses React.createElement directly). This means the SSR bundle can be aliased
5
+ // to slim-react without any element shape mismatch.
6
+ //
7
+ // React 19 introduced "react.transitional.element" as the canonical $$typeof for
8
+ // elements created by createElement / the jsx-runtime. We keep the old
9
+ // "react.element" as slim-react's own emission symbol (unchanged wire format for
10
+ // SSR HTML — it makes no difference) and accept both in the renderer.
11
+ export const SLIM_ELEMENT = Symbol.for("react.element");
12
+ export const REACT19_ELEMENT = Symbol.for("react.transitional.element");
13
+ export const FRAGMENT_TYPE = Symbol.for("react.fragment");
14
+ export const SUSPENSE_TYPE = Symbol.for("react.suspense");
15
+
16
+ // ---- Types ----
17
+ export type ComponentFunction = (props: any) => SlimNode;
18
+
19
+ export type SlimElement = {
20
+ $$typeof: typeof SLIM_ELEMENT;
21
+ type: string | ComponentFunction | symbol;
22
+ props: Record<string, any>;
23
+ key: string | number | null;
24
+ };
25
+
26
+ export type SlimNode =
27
+ | SlimElement
28
+ | string
29
+ | number
30
+ | boolean
31
+ | null
32
+ | undefined
33
+ | SlimNode[];
@@ -1,53 +1,27 @@
1
1
  /**
2
2
  * SSR render worker — runs in a node:worker_threads thread.
3
3
  *
4
- * Handles one message type sent by RenderWorkerPool in build.ts:
4
+ * Uses slim-react (bundled with hadars) for rendering instead of react-dom/server.
5
+ * The SSR bundle is compiled with `react` aliased to slim-react, so both the
6
+ * worker and the bundle share the same slim-react instance (and its globalThis
7
+ * render state) without any extra coordination.
5
8
  *
6
- * { type: 'renderFull', id, request: SerializableRequest }
7
- * runs full lifecycle (getInitProps render loop getAfterRenderProps → getFinalProps)
8
- * → renderToString(ReactPage)
9
- * → postMessage({ id, html, headHtml, status })
10
- *
11
- * The SSR bundle path is passed once via workerData at thread creation time so
12
- * the SSR module is only imported once per worker lifetime.
9
+ * Message: { type: 'renderFull', id, request: SerializableRequest }
10
+ * Reply: { id, html, headHtml, status } | { id, error }
13
11
  */
14
12
 
15
13
  import { workerData, parentPort } from 'node:worker_threads';
16
- import { createRequire } from 'node:module';
17
- import pathMod from 'node:path';
18
14
  import { pathToFileURL } from 'node:url';
15
+ import { processSegmentCache } from './utils/segmentCache';
16
+ import { renderToString, createElement, Fragment } from './slim-react/index';
19
17
 
20
18
  const { ssrBundlePath } = workerData as { ssrBundlePath: string };
21
19
 
22
- // Lazy-loaded singletons resolved from the *project's* node_modules so the
23
- // same React instance is shared with the SSR bundle (prevents invalid hook calls).
24
- let _React: any = null;
25
- let _renderToStaticMarkup: ((element: any) => string) | null = null;
26
- let _renderToString: ((element: any) => string) | null = null;
27
- // Full SSR module — includes default (App component) + lifecycle exports.
28
20
  let _ssrMod: any = null;
29
21
 
30
22
  async function init() {
31
- if (_React && _ssrMod) return;
32
-
33
- const req = createRequire(pathMod.resolve(process.cwd(), '__hadars_fake__.js'));
34
-
35
- if (!_React) {
36
- const reactPath = pathToFileURL(req.resolve('react')).href;
37
- const reactMod = await import(reactPath);
38
- _React = reactMod.default ?? reactMod;
39
- }
40
-
41
- if (!_renderToString || !_renderToStaticMarkup) {
42
- const serverPath = pathToFileURL(req.resolve('react-dom/server')).href;
43
- const serverMod = await import(serverPath);
44
- _renderToString = serverMod.renderToString;
45
- _renderToStaticMarkup = serverMod.renderToStaticMarkup;
46
- }
47
-
48
- if (!_ssrMod) {
49
- _ssrMod = await import(pathToFileURL(ssrBundlePath).href);
50
- }
23
+ if (_ssrMod) return;
24
+ _ssrMod = await import(pathToFileURL(ssrBundlePath).href);
51
25
  }
52
26
 
53
27
  export type SerializableRequest = {
@@ -65,61 +39,57 @@ function deserializeRequest(s: SerializableRequest): any {
65
39
  const init: RequestInit = { method: s.method, headers: new Headers(s.headers) };
66
40
  if (s.body) init.body = s.body.buffer as ArrayBuffer;
67
41
  const req = new Request(s.url, init);
68
- Object.assign(req, {
69
- pathname: s.pathname,
70
- search: s.search,
71
- location: s.location,
72
- cookies: s.cookies,
73
- });
42
+ Object.assign(req, { pathname: s.pathname, search: s.search, location: s.location, cookies: s.cookies });
74
43
  return req;
75
44
  }
76
45
 
77
- function buildHeadHtml(head: any): string {
78
- const R = _React;
79
- const metaEntries = Object.entries(head.meta ?? {});
80
- const linkEntries = Object.entries(head.link ?? {});
81
- const styleEntries = Object.entries(head.style ?? {});
82
- const scriptEntries = Object.entries(head.script ?? {});
83
- return _renderToStaticMarkup!(
84
- R.createElement(R.Fragment, null,
85
- R.createElement('title', null, head.title),
86
- ...metaEntries.map(([id, opts]) => R.createElement('meta', { key: id, id, ...(opts as any) })),
87
- ...linkEntries.map(([id, opts]) => R.createElement('link', { key: id, id, ...(opts as any) })),
88
- ...styleEntries.map(([id, opts]) => R.createElement('style', { key: id, id, ...(opts as any) })),
89
- ...scriptEntries.map(([id, opts]) => R.createElement('script', { key: id, id, ...(opts as any) })),
90
- )
91
- );
46
+ // ── Head HTML serialisation ────────────────────────────────────────────────
47
+
48
+ const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
49
+ const escAttr = (s: string) => s.replace(/[&<>"]/g, c => ESC[c] ?? c);
50
+ const escText = (s: string) => s.replace(/[&<>]/g, c => ESC[c] ?? c);
51
+
52
+ const HEAD_ATTR: Record<string, string> = {
53
+ className: 'class', htmlFor: 'for', httpEquiv: 'http-equiv',
54
+ charSet: 'charset', crossOrigin: 'crossorigin',
55
+ };
56
+
57
+ function renderHeadTag(tag: string, id: string, opts: Record<string, unknown>, selfClose = false): string {
58
+ let a = ` id="${escAttr(id)}"`;
59
+ let inner = '';
60
+ for (const [k, v] of Object.entries(opts)) {
61
+ if (k === 'key' || k === 'children') continue;
62
+ if (k === 'dangerouslySetInnerHTML') { inner = (v as any).__html ?? ''; continue; }
63
+ const attr = HEAD_ATTR[k] ?? k;
64
+ if (v === true) a += ` ${attr}`;
65
+ else if (v !== false && v != null) a += ` ${attr}="${escAttr(String(v))}"`;
66
+ }
67
+ return selfClose ? `<${tag}${a}>` : `<${tag}${a}>${inner}</${tag}>`;
92
68
  }
93
69
 
94
- function buildReactPage(appProps: any, clientProps: any) {
95
- const R = _React;
96
- const Component = _ssrMod.default;
97
- return R.createElement(
98
- R.Fragment, null,
99
- R.createElement('div', { id: 'app' },
100
- R.createElement(Component, appProps),
101
- ),
102
- R.createElement('script', {
103
- id: 'hadars',
104
- type: 'application/json',
105
- dangerouslySetInnerHTML: {
106
- __html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c'),
107
- },
108
- }),
109
- );
70
+ function buildHeadHtml(head: any): string {
71
+ let html = `<title>${escText(head.title ?? '')}</title>`;
72
+ for (const [id, opts] of Object.entries(head.meta ?? {}))
73
+ html += renderHeadTag('meta', id, opts as Record<string, unknown>, true);
74
+ for (const [id, opts] of Object.entries(head.link ?? {}))
75
+ html += renderHeadTag('link', id, opts as Record<string, unknown>, true);
76
+ for (const [id, opts] of Object.entries(head.style ?? {}))
77
+ html += renderHeadTag('style', id, opts as Record<string, unknown>);
78
+ for (const [id, opts] of Object.entries(head.script ?? {}))
79
+ html += renderHeadTag('script', id, opts as Record<string, unknown>);
80
+ return html;
110
81
  }
111
82
 
83
+ // ── Full lifecycle ─────────────────────────────────────────────────────────
84
+
112
85
  async function runFullLifecycle(serialReq: SerializableRequest) {
113
- const R = _React;
114
86
  const Component = _ssrMod.default;
115
87
  const { getInitProps, getAfterRenderProps, getFinalProps } = _ssrMod;
116
88
 
117
89
  const parsedReq = deserializeRequest(serialReq);
118
90
 
119
- const unsuspend: any = { cache: new Map(), hasPending: false };
120
91
  const context: any = {
121
92
  head: { title: 'Hadars App', meta: {}, link: {}, style: {}, script: {}, status: 200 },
122
- _unsuspend: unsuspend,
123
93
  };
124
94
 
125
95
  let props: any = {
@@ -128,82 +98,77 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
128
98
  context,
129
99
  };
130
100
 
131
- // useServerData render loop same logic as getReactResponse on main thread.
132
- let html = '';
133
- let iters = 0;
134
- do {
135
- unsuspend.hasPending = false;
136
- try {
137
- (globalThis as any).__hadarsUnsuspend = unsuspend;
138
- html = _renderToStaticMarkup!(R.createElement(Component, props));
139
- } finally {
140
- (globalThis as any).__hadarsUnsuspend = null;
141
- }
142
- if (unsuspend.hasPending) {
143
- const pending = [...unsuspend.cache.values()]
144
- .filter((e: any) => e.status === 'pending')
145
- .map((e: any) => e.promise);
146
- await Promise.all(pending);
147
- }
148
- } while (unsuspend.hasPending && ++iters < 25);
149
- if (unsuspend.hasPending) {
150
- console.warn('[hadars] SSR render loop hit the 25-iteration cap — some useServerData values may not be resolved. Check for data dependencies that are never fulfilled.');
151
- }
152
-
153
- props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
101
+ // Create per-request cache for useServerData, active for all renders.
102
+ const unsuspend = { cache: new Map<string, any>() };
103
+ (globalThis as any).__hadarsUnsuspend = unsuspend;
154
104
 
155
- // Re-render to capture head changes from getAfterRenderProps.
156
105
  try {
157
- (globalThis as any).__hadarsUnsuspend = unsuspend;
158
- _renderToStaticMarkup!(R.createElement(Component, { ...props, location: serialReq.location, context }));
106
+ let html = await renderToString(createElement(Component, props));
107
+
108
+ if (getAfterRenderProps) {
109
+ props = await getAfterRenderProps(props, html);
110
+ await renderToString(
111
+ createElement(Component, { ...props, location: serialReq.location, context }),
112
+ );
113
+ }
159
114
  } finally {
160
115
  (globalThis as any).__hadarsUnsuspend = null;
161
116
  }
162
117
 
163
- // Collect resolved useServerData values for client hydration.
118
+ const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
119
+
120
+ // Collect fulfilled useServerData values for client-side hydration.
164
121
  const serverData: Record<string, unknown> = {};
165
- for (const [k, v] of unsuspend.cache) {
166
- if ((v as any).status === 'fulfilled') serverData[k] = (v as any).value;
167
- if ((v as any).status === 'suspense-cached') serverData[k] = (v as any).value;
122
+ for (const [key, entry] of unsuspend.cache) {
123
+ if (entry.status === 'fulfilled') serverData[key] = entry.value;
168
124
  }
169
-
170
- const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
171
125
  const clientProps = {
172
126
  ...restProps,
173
127
  location: serialReq.location,
174
128
  ...(Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}),
175
129
  };
176
130
 
177
- const headHtml = buildHeadHtml(context.head);
178
- const status: number = context.head.status ?? 200;
179
131
  const finalAppProps = { ...props, location: serialReq.location, context };
180
-
181
- return { finalAppProps, clientProps, headHtml, status, unsuspend };
132
+ return { finalAppProps, clientProps, unsuspend, headHtml: buildHeadHtml(context.head), status: context.head.status ?? 200 };
182
133
  }
183
134
 
135
+ // ── Message handler ────────────────────────────────────────────────────────
136
+
184
137
  parentPort!.on('message', async (msg: any) => {
185
138
  const { id, type, request } = msg;
186
139
  try {
187
140
  await init();
188
-
189
141
  if (type !== 'renderFull') return;
190
142
 
191
- const { finalAppProps, clientProps, headHtml, status, unsuspend } =
143
+ const { finalAppProps, clientProps, unsuspend, headHtml, status } =
192
144
  await runFullLifecycle(request as SerializableRequest);
193
145
 
194
- const ReactPage = buildReactPage(finalAppProps, clientProps);
146
+ const Component = _ssrMod.default;
195
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).
160
+ (globalThis as any).__hadarsUnsuspend = unsuspend;
196
161
  let html: string;
197
162
  try {
198
- (globalThis as any).__hadarsUnsuspend = unsuspend;
199
- html = _renderToString!(ReactPage);
163
+ html = await renderToString(page);
200
164
  } finally {
201
165
  (globalThis as any).__hadarsUnsuspend = null;
202
166
  }
167
+ html = processSegmentCache(html);
168
+
203
169
  parentPort!.postMessage({ id, html, headHtml, status });
204
170
 
205
171
  } catch (err: any) {
206
- (globalThis as any).__hadarsUnsuspend = null;
207
172
  parentPort!.postMessage({ id, error: err?.message ?? String(err) });
208
173
  }
209
174
  });
@@ -273,8 +273,7 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
273
273
  () => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
274
274
  );
275
275
  unsuspend.cache.set(cacheKey, { status: 'pending', promise: suspensePromise });
276
- unsuspend.hasPending = true;
277
- return undefined;
276
+ throw suspensePromise; // slim-react will await and retry
278
277
  }
279
278
  throw thrown;
280
279
  }
@@ -294,12 +293,10 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
294
293
  reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
295
294
  );
296
295
  unsuspend.cache.set(cacheKey, { status: 'pending', promise });
297
- unsuspend.hasPending = true;
298
- return undefined;
296
+ throw promise; // slim-react will await and retry
299
297
  }
300
298
  if (existing.status === 'pending') {
301
- unsuspend.hasPending = true;
302
- return undefined;
299
+ throw existing.promise; // slim-react will await and retry
303
300
  }
304
301
  if (existing.status === 'rejected') throw existing.reason;
305
302
  return existing.value as T;
@@ -1,23 +1,6 @@
1
1
  import type React from "react";
2
- import { createRequire } from "node:module";
3
- import pathMod from "node:path";
4
- import { pathToFileURL } from "node:url";
5
- import type { AppHead, AppUnsuspend, HadarsRequest, HadarsEntryBase, HadarsEntryModule, HadarsProps, AppContext } from "../types/hadars";
6
-
7
- // Resolve react-dom/server from the *project's* node_modules (process.cwd()) so
8
- // the same React instance is used here as in the SSR bundle. Without this,
9
- // when hadars is installed as a file: symlink the renderer ends up on a
10
- // different React than the component, breaking hook calls.
11
- let _renderToStaticMarkup: ((element: any) => string) | null = null;
12
- async function getStaticMarkupRenderer(): Promise<(element: any) => string> {
13
- if (!_renderToStaticMarkup) {
14
- const req = createRequire(pathMod.resolve(process.cwd(), '__hadars_fake__.js'));
15
- const resolved = req.resolve('react-dom/server');
16
- const mod = await import(pathToFileURL(resolved).href);
17
- _renderToStaticMarkup = mod.renderToStaticMarkup;
18
- }
19
- return _renderToStaticMarkup!;
20
- }
2
+ import type { AppHead, HadarsRequest, HadarsEntryBase, HadarsEntryModule, HadarsProps, AppContext } from "../types/hadars";
3
+ import { renderToString, createElement, Fragment } from '../slim-react/index';
21
4
 
22
5
  interface ReactResponseOptions {
23
6
  document: {
@@ -30,13 +13,12 @@ interface ReactResponseOptions {
30
13
  }
31
14
  }
32
15
 
33
- // ── Head HTML serialisation (no React render needed) ─────────────────────────
16
+ // ── Head HTML serialisation ────────────────────────────────────────────────
34
17
 
35
18
  const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
36
19
  const escAttr = (s: string) => s.replace(/[&<>"]/g, c => ESC[c] ?? c);
37
20
  const escText = (s: string) => s.replace(/[&<>]/g, c => ESC[c] ?? c);
38
21
 
39
- // React prop → HTML attribute name for the subset used in head tags.
40
22
  const ATTR: Record<string, string> = {
41
23
  className: 'class', htmlFor: 'for', httpEquiv: 'http-equiv',
42
24
  charSet: 'charset', crossOrigin: 'crossorigin', noModule: 'nomodule',
@@ -69,12 +51,12 @@ const getHeadHtml = (seoData: AppHead): string => {
69
51
  return html;
70
52
  };
71
53
 
72
-
73
54
  export const getReactResponse = async (
74
55
  req: HadarsRequest,
75
56
  opts: ReactResponseOptions,
76
57
  ): Promise<{
77
- ReactPage: React.ReactElement,
58
+ ReactPage: any,
59
+ unsuspend: { cache: Map<string, any> },
78
60
  status: number,
79
61
  headHtml: string,
80
62
  renderPayload: {
@@ -82,113 +64,68 @@ export const getReactResponse = async (
82
64
  clientProps: Record<string, unknown>;
83
65
  };
84
66
  }> => {
85
- const App = opts.document.body
67
+ const App = opts.document.body;
86
68
  const { getInitProps, getAfterRenderProps, getFinalProps } = opts.document;
87
69
 
88
- const renderToStaticMarkup = await getStaticMarkupRenderer();
89
-
90
- // Per-request unsuspend context — populated by useServerData() hooks during render.
91
- // Lifecycle passes always run on the main thread so the cache is directly accessible.
92
- // Kept as a plain AppUnsuspend (no methods) so it is serializable via structuredClone
93
- // for postMessage to worker threads.
94
- const unsuspend: AppUnsuspend = {
95
- cache: new Map(),
96
- hasPending: false,
97
- };
98
- const processUnsuspend = async () => {
99
- const pending = [...unsuspend.cache.values()]
100
- .filter((e): e is { status: 'pending'; promise: Promise<unknown> } => e.status === 'pending')
101
- .map(e => e.promise);
102
- await Promise.all(pending);
103
- };
104
-
105
70
  const context: AppContext = {
106
- head: {
107
- title: "Hadars App",
108
- meta: {},
109
- link: {},
110
- style: {},
111
- script: {},
112
- status: 200,
113
- },
114
- _unsuspend: unsuspend,
115
- }
71
+ head: { title: 'Hadars App', meta: {}, link: {}, style: {}, script: {}, status: 200 },
72
+ };
116
73
 
117
74
  let props: HadarsEntryBase = {
118
75
  ...(getInitProps ? await getInitProps(req) : {}),
119
76
  location: req.location,
120
77
  context,
121
- } as HadarsEntryBase
122
-
123
- // ── First lifecycle pass: useServerData render loop ───────────────────────
124
- // Render the component repeatedly until all useServerData() promises have
125
- // resolved. Each iteration discovers new pending promises; awaiting them
126
- // before the next pass ensures every hook returns its value by the final
127
- // iteration. Capped at 25 iterations as a safety guard against infinite loops.
128
- let html = '';
129
- let iters = 0;
130
- do {
131
- unsuspend.hasPending = false;
132
- try {
133
- (globalThis as any).__hadarsUnsuspend = unsuspend;
134
- html = renderToStaticMarkup(<App {...(props as any)} />);
135
- } finally {
136
- (globalThis as any).__hadarsUnsuspend = null;
78
+ } as HadarsEntryBase;
79
+
80
+ // Create per-request cache for useServerData, active for all renders.
81
+ const unsuspend = { cache: new Map<string, any>() };
82
+ (globalThis as any).__hadarsUnsuspend = unsuspend;
83
+ try {
84
+ let html = await renderToString(createElement(App as any, props as any));
85
+ if (getAfterRenderProps) {
86
+ props = await getAfterRenderProps(props, html);
87
+ await renderToString(
88
+ createElement(App as any, { ...props, location: req.location, context } as any),
89
+ );
137
90
  }
138
- if (unsuspend.hasPending) await processUnsuspend();
139
- } while (unsuspend.hasPending && ++iters < 25);
140
- if (unsuspend.hasPending) {
141
- console.warn('[hadars] SSR render loop hit the 25-iteration cap — some useServerData values may not be resolved. Check for data dependencies that are never fulfilled.');
91
+ } finally {
92
+ (globalThis as any).__hadarsUnsuspend = null;
142
93
  }
143
94
 
144
- if (getAfterRenderProps) {
145
- props = await getAfterRenderProps(props, html);
146
- // Re-render only when getAfterRenderProps is present — it may mutate
147
- // props that affect head tags, so we need another pass to capture them.
148
- try {
149
- (globalThis as any).__hadarsUnsuspend = unsuspend;
150
- renderToStaticMarkup(<App {...({ ...props, location: req.location, context })} />);
151
- } finally {
152
- (globalThis as any).__hadarsUnsuspend = null;
153
- }
154
- }
95
+ const { context: _, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
155
96
 
156
- // Serialize resolved useServerData() values for client hydration.
157
- // The client bootstrap reads __serverData and pre-populates the hook cache
158
- // before hydrateRoot so that useServerData() returns the same values CSR.
97
+ // Collect fulfilled useServerData values for client-side hydration.
159
98
  const serverData: Record<string, unknown> = {};
160
- for (const [k, v] of unsuspend.cache) {
161
- if (v.status === 'fulfilled') serverData[k] = v.value;
99
+ for (const [key, entry] of unsuspend.cache) {
100
+ if (entry.status === 'fulfilled') serverData[key] = entry.value;
162
101
  }
163
-
164
- const { context: _, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
165
102
  const clientProps = {
166
103
  ...restProps,
167
104
  location: req.location,
168
105
  ...(Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}),
169
- }
106
+ };
170
107
 
171
- const ReactPage = (
172
- <>
173
- <div id="app">
174
- <App {...({
175
- ...props,
176
- location: req.location,
177
- context,
178
- })} />
179
- </div>
180
- <script id="hadars" type="application/json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c') }}></script>
181
- </>
182
- )
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
+ );
183
120
 
184
121
  return {
185
122
  ReactPage,
123
+ unsuspend,
186
124
  status: context.head.status,
187
125
  headHtml: getHeadHtml(context.head),
188
126
  renderPayload: {
189
127
  appProps: { ...props, location: req.location, context } as Record<string, unknown>,
190
128
  clientProps: clientProps as Record<string, unknown>,
191
129
  },
192
- }
193
-
194
- }
130
+ };
131
+ };