hadars 0.1.28 → 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.
@@ -28,6 +28,7 @@ import {
28
28
  popTreeContext,
29
29
  pushComponentScope,
30
30
  popComponentScope,
31
+ componentCalledUseId,
31
32
  snapshotContext,
32
33
  restoreContext,
33
34
  pushContextValue,
@@ -35,7 +36,9 @@ import {
35
36
  getContextValue,
36
37
  swapContextMap,
37
38
  captureMap,
39
+ type TreeContext,
38
40
  } from "./renderContext";
41
+ import { installDispatcher, restoreDispatcher } from "./dispatcher";
39
42
 
40
43
  // ---------------------------------------------------------------------------
41
44
  // HTML helpers
@@ -630,6 +633,7 @@ function renderComponent(
630
633
  }
631
634
 
632
635
  let result: SlimNode;
636
+ const prevDispatcher = installDispatcher();
633
637
  try {
634
638
  if (type.prototype && typeof type.prototype.render === "function") {
635
639
  const instance = new (type as any)(props);
@@ -643,12 +647,25 @@ function renderComponent(
643
647
  result = type(props);
644
648
  }
645
649
  } catch (e) {
650
+ restoreDispatcher(prevDispatcher);
646
651
  popComponentScope(savedScope);
647
652
  if (isProvider) popContextValue(ctx, prevCtxValue);
648
653
  throw e;
649
654
  }
655
+ restoreDispatcher(prevDispatcher);
656
+
657
+ // React 19 finishFunctionComponent: if the component called useId, push a
658
+ // tree-context slot for the component's OUTPUT children — matching React 19's
659
+ // `pushTreeContext(keyPath, 1, 0)` call inside finishFunctionComponent.
660
+ // This ensures that useId IDs produced by child components of a useId-calling
661
+ // component are tree-positioned identically to React's own renderer.
662
+ let savedIdTree: TreeContext | undefined;
663
+ if (!(result instanceof Promise) && componentCalledUseId()) {
664
+ savedIdTree = pushTreeContext(1, 0);
665
+ }
650
666
 
651
667
  const finish = () => {
668
+ if (savedIdTree !== undefined) popTreeContext(savedIdTree);
652
669
  popComponentScope(savedScope);
653
670
  if (isProvider) popContextValue(ctx, prevCtxValue);
654
671
  };
@@ -658,15 +675,25 @@ function renderComponent(
658
675
  const m = captureMap();
659
676
  return result.then((resolved) => {
660
677
  swapContextMap(m);
678
+ // Check useId after the async body has finished executing.
679
+ let asyncSavedIdTree: TreeContext | undefined;
680
+ if (componentCalledUseId()) {
681
+ asyncSavedIdTree = pushTreeContext(1, 0);
682
+ }
683
+ const asyncFinish = () => {
684
+ if (asyncSavedIdTree !== undefined) popTreeContext(asyncSavedIdTree);
685
+ popComponentScope(savedScope);
686
+ if (isProvider) popContextValue(ctx, prevCtxValue);
687
+ };
661
688
  const r = renderNode(resolved, writer, isSvg);
662
689
  if (r && typeof (r as any).then === "function") {
663
690
  const m2 = captureMap();
664
691
  return (r as Promise<void>).then(
665
- () => { swapContextMap(m2); finish(); },
666
- (e) => { swapContextMap(m2); finish(); throw e; },
692
+ () => { swapContextMap(m2); asyncFinish(); },
693
+ (e) => { swapContextMap(m2); asyncFinish(); throw e; },
667
694
  );
668
695
  }
669
- finish();
696
+ asyncFinish();
670
697
  }, (e) => { swapContextMap(m); finish(); throw e; });
671
698
  }
672
699
 
@@ -785,8 +812,8 @@ async function renderSuspense(
785
812
  while (attempts < MAX_SUSPENSE_RETRIES) {
786
813
  // Restore context to the state it was in when we entered <Suspense>.
787
814
  restoreContext(snap);
815
+ let buffer = new BufferWriter();
788
816
  try {
789
- const buffer = new BufferWriter();
790
817
  const r = renderNode(children, buffer, isSvg);
791
818
  if (r && typeof (r as any).then === "function") {
792
819
  const m = captureMap(); await r; swapContextMap(m);
@@ -823,19 +850,32 @@ async function renderSuspense(
823
850
  // Public API
824
851
  // ---------------------------------------------------------------------------
825
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
+
826
862
  /**
827
863
  * Render a component tree to a `ReadableStream<Uint8Array>`.
828
864
  *
829
865
  * The stream pauses at `<Suspense>` boundaries until the suspended
830
866
  * promise resolves, then continues writing HTML.
831
867
  */
832
- export function renderToStream(element: SlimNode): ReadableStream<Uint8Array> {
868
+ export function renderToStream(
869
+ element: SlimNode,
870
+ options?: RenderOptions,
871
+ ): ReadableStream<Uint8Array> {
833
872
  const encoder = new TextEncoder();
873
+ const idPrefix = options?.identifierPrefix ?? "";
834
874
 
835
875
  const contextMap = new Map<object, unknown>();
836
876
  return new ReadableStream({
837
877
  async start(controller) {
838
- resetRenderState();
878
+ resetRenderState(idPrefix);
839
879
  const prev = swapContextMap(contextMap);
840
880
 
841
881
  const writer: Writer = {
@@ -870,12 +910,16 @@ export function renderToStream(element: SlimNode): ReadableStream<Uint8Array> {
870
910
  * Retries the full tree when a component throws a Promise (Suspense protocol),
871
911
  * so useServerData and similar hooks work without requiring explicit <Suspense>.
872
912
  */
873
- 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 ?? "";
874
918
  const contextMap = new Map<object, unknown>();
875
919
  const prev = swapContextMap(contextMap);
876
920
  try {
877
921
  for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
878
- resetRenderState();
922
+ resetRenderState(idPrefix);
879
923
  swapContextMap(contextMap); // re-activate our map on each retry
880
924
  const chunks: string[] = [];
881
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) {
@@ -160,6 +161,11 @@ export function popComponentScope(saved: number) {
160
161
  s().localIdCounter = saved;
161
162
  }
162
163
 
164
+ /** True if the current component has called useId at least once. */
165
+ export function componentCalledUseId(): boolean {
166
+ return s().localIdCounter > 0;
167
+ }
168
+
163
169
  export function snapshotContext(): { tree: TreeContext; localId: number } {
164
170
  const st = s();
165
171
  return { tree: { ...st.currentTreeContext }, localId: st.localIdCounter };
@@ -187,19 +193,19 @@ function getTreeId(): string {
187
193
  /**
188
194
  * Generate a `useId`-compatible ID for the current call site.
189
195
  *
190
- * Format: `_<idPrefix>R_<treeId>_`
191
- * with an optional `H<n>` suffix when the same component calls useId
192
- * more than once (matching React 19's `localIdCounter` behaviour).
196
+ * Format: `_R_<idPrefix><treeId>_` (React 19.2+)
197
+ * with an optional `H<n>` suffix for the n-th useId call in the same
198
+ * component (matching React 19's `localIdCounter` behaviour).
193
199
  *
194
- * This matches React 19's `mountId` output on both the Fizz SSR renderer
195
- * and the client hydration path, so the IDs produced here will agree with
196
- * the 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.
197
203
  */
198
204
  export function makeId(): string {
199
205
  const st = s();
200
206
  const treeId = getTreeId();
201
207
  const n = st.localIdCounter++;
202
- let id = "_" + st.idPrefix + "R_" + treeId;
208
+ let id = "_R_" + st.idPrefix + treeId;
203
209
  if (n > 0) id += "H" + n.toString(32);
204
210
  return id + "_";
205
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
  };