hadars 0.3.2 → 0.4.0

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.
@@ -134,7 +134,12 @@ function getCtx(): InnerContext | null {
134
134
  // ── useServerData ─────────────────────────────────────────────────────────────
135
135
  //
136
136
  // Client-side cache pre-populated from the server's resolved data before
137
- // hydration. Keyed by the same React useId() values that the server used.
137
+ // hydration. During the initial SSR load, keyed by the server's useId() values
138
+ // (which match React's hydrateRoot IDs). During client-side navigation the
139
+ // server keys are _R_..._ (slim-react format) but the client is NOT in
140
+ // hydration mode so useId() returns _r_..._ — a completely different format.
141
+ // We bridge this by storing navigation results as an ordered array and consuming
142
+ // them sequentially on the retry, then caching by the client's own useId() key.
138
143
  const clientServerDataCache = new Map<string, unknown>();
139
144
 
140
145
  // Tracks in-flight data-only requests keyed by pathname+search so that all
@@ -144,39 +149,21 @@ const pendingDataFetch = new Map<string, Promise<void>>();
144
149
  // when a key is genuinely absent from the server response for this path.
145
150
  const fetchedPaths = new Set<string>();
146
151
 
147
- // Keys that were seeded from SSR data and not yet claimed by any useServerData
148
- // call on the client. Used to detect server↔client key mismatches.
149
- let ssrInitialKeys: Set<string> | null = null;
150
- let unclaimedKeyCheckScheduled = false;
151
-
152
- function scheduleUnclaimedKeyCheck() {
153
- if (unclaimedKeyCheckScheduled) return;
154
- unclaimedKeyCheckScheduled = true;
155
- // Wait for the current synchronous hydration pass to finish, then check.
156
- setTimeout(() => {
157
- unclaimedKeyCheckScheduled = false;
158
- if (ssrInitialKeys && ssrInitialKeys.size > 0) {
159
- console.warn(
160
- `[hadars] useServerData: ${ssrInitialKeys.size} server-resolved key(s) were ` +
161
- `never claimed during client hydration: ${[...ssrInitialKeys].map(k => JSON.stringify(k)).join(', ')}. ` +
162
- `This usually means the key passed to useServerData was different on the server ` +
163
- `than on the client (e.g. it contains Date.now(), Math.random(), or another ` +
164
- `value that changes between renders). Keys must be stable and deterministic.`,
165
- );
166
- }
167
- ssrInitialKeys = null;
168
- }, 0);
169
- }
152
+ // Ordered values from the most recent navigation fetch.
153
+ // React retries suspended components in tree order (same order as SSR), so
154
+ // consuming these positionally is safe and avoids the server/client key mismatch.
155
+ let _navValues: unknown[] = [];
156
+ let _navIdx = 0;
170
157
 
171
158
  /** Call this before hydrating to seed the client cache from the server's data.
172
159
  * Invoked automatically by the hadars client bootstrap.
173
160
  * Always clears the existing cache before populating — call with `{}` to just clear. */
174
161
  export function initServerDataCache(data: Record<string, unknown>) {
175
162
  clientServerDataCache.clear();
176
- ssrInitialKeys = new Set<string>();
163
+ _navValues = [];
164
+ _navIdx = 0;
177
165
  for (const [k, v] of Object.entries(data)) {
178
166
  clientServerDataCache.set(k, v);
179
- ssrInitialKeys.add(k);
180
167
  }
181
168
  }
182
169
 
@@ -188,9 +175,8 @@ export function initServerDataCache(data: Record<string, unknown>) {
188
175
  * On the client the pre-resolved value is read from the hydration cache
189
176
  * serialised into the page by the server, so no fetch is issued in the browser.
190
177
  *
191
- * The `key` (string or array of strings) uniquely identifies the cached value
192
- * across all SSR render passes and client hydration it must be stable and
193
- * unique within the page.
178
+ * The cache key is derived automatically from the call-site's position in the
179
+ * component tree via `useId()`no manual key is required.
194
180
  *
195
181
  * `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
196
182
  * The resolved value is serialised into `__serverData` and returned from cache
@@ -202,29 +188,42 @@ export function initServerDataCache(data: Record<string, unknown>) {
202
188
  * React Suspense until the server returns the JSON map of resolved values.
203
189
  *
204
190
  * @example
205
- * const user = useServerData('current_user', () => db.getUser(id));
206
- * const post = useServerData(['post', postId], () => db.getPost(postId));
191
+ * const user = useServerData(() => db.getUser(id));
192
+ * const post = useServerData(() => db.getPost(postId));
207
193
  * if (!user) return null; // undefined while pending on the first SSR pass
194
+ *
195
+ * // cache: false — evicts the entry on unmount so the next mount fetches fresh data
196
+ * const stats = useServerData(() => getServerStats(), { cache: false });
208
197
  */
209
- export function useServerData<T>(key: string | string[], fn: () => Promise<T> | T): T | undefined {
210
- const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
198
+ export function useServerData<T>(fn: () => Promise<T> | T, options?: { cache?: boolean }): T | undefined {
199
+ const cacheKey = React.useId();
200
+
201
+ // When cache: false, evict the entry on unmount so the next mount fetches
202
+ // fresh data from the server. The eviction is deferred via setTimeout so
203
+ // that React Strict Mode's synchronous fake-unmount/remount cycle can cancel
204
+ // it before it fires — a real unmount has no follow-up effect to cancel it.
205
+ const evictTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
206
+ React.useEffect(() => {
207
+ if (options?.cache !== false) return;
208
+ // Cancel any timer left over from a Strict Mode fake unmount.
209
+ if (evictTimerRef.current !== null) {
210
+ clearTimeout(evictTimerRef.current);
211
+ evictTimerRef.current = null;
212
+ }
213
+ return () => {
214
+ evictTimerRef.current = setTimeout(() => {
215
+ clientServerDataCache.delete(cacheKey);
216
+ evictTimerRef.current = null;
217
+ }, 0);
218
+ };
219
+ }, []);
211
220
 
212
221
  if (typeof window !== 'undefined') {
213
- // Cache hit — return the server-resolved value directly (covers both initial
214
- // SSR hydration and values fetched during client-side navigation).
222
+ // Cache hit — return the server-resolved value directly.
215
223
  if (clientServerDataCache.has(cacheKey)) {
216
- // Mark this SSR key as claimed so the unclaimed-key check doesn't warn about it.
217
- ssrInitialKeys?.delete(cacheKey);
218
224
  return clientServerDataCache.get(cacheKey) as T;
219
225
  }
220
226
 
221
- // Cache miss during the initial hydration pass (SSR data is present but
222
- // this key wasn't in it) — schedule a deferred check for orphaned SSR keys
223
- // which would signal a server↔client key mismatch.
224
- if (ssrInitialKeys !== null && ssrInitialKeys.size > 0) {
225
- scheduleUnclaimedKeyCheck();
226
- }
227
-
228
227
  // Cache miss — this component is mounting during client-side navigation
229
228
  // (the server hasn't sent data for this path yet). Fire a data-only
230
229
  // request to the server at the current URL and suspend via React Suspense
@@ -232,10 +231,23 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
232
231
  // share one Promise so only one network request is made per navigation.
233
232
  const pathKey = window.location.pathname + window.location.search;
234
233
 
235
- // If we already fetched this path and the key is still missing, the server
236
- // doesn't produce a value for it return undefined rather than looping.
234
+ // After a navigation fetch has completed, consume values positionally.
235
+ // The server returns keys in slim-react (_R_..._) format, but the client
236
+ // is not in hydration mode so useId() returns _r_..._ — they never match.
237
+ // We store the ordered values from the fetch and hand them out in tree
238
+ // order (which is identical on server and client for a given route).
237
239
  if (fetchedPaths.has(pathKey)) {
238
- return undefined;
240
+ if (_navIdx < _navValues.length) {
241
+ const value = _navValues[_navIdx++] as T;
242
+ clientServerDataCache.set(cacheKey, value);
243
+ return value;
244
+ }
245
+ // Positional data exhausted — cache:false eviction or remount with new
246
+ // useId() keys. Remove the path so a fresh fetch fires below.
247
+ fetchedPaths.delete(pathKey);
248
+ _navValues = [];
249
+ _navIdx = 0;
250
+ // fall through to trigger a new fetch
239
251
  }
240
252
 
241
253
  if (!pendingDataFetch.has(pathKey)) {
@@ -261,9 +273,14 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
261
273
  if (res.ok) json = await res.json();
262
274
  }
263
275
 
264
- for (const [k, v] of Object.entries(json?.serverData ?? {})) {
265
- clientServerDataCache.set(k, v);
266
- }
276
+ // Store as ordered array consumed positionally on retry to
277
+ // avoid the server (_R_..._) vs client (_r_..._) key mismatch.
278
+ _navValues = Object.values(json?.serverData ?? {});
279
+ _navIdx = 0;
280
+ // Only keep the freshly-fetched path in fetchedPaths — clear
281
+ // others so stale positional data from a previous page cannot
282
+ // be served if the user navigates back to it.
283
+ fetchedPaths.clear();
267
284
  } finally {
268
285
  fetchedPaths.add(pathKey);
269
286
  pendingDataFetch.delete(pathKey);
@@ -281,22 +298,8 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
281
298
  const unsuspend: AppUnsuspend | undefined = (globalThis as any).__hadarsUnsuspend;
282
299
  if (!unsuspend) return undefined;
283
300
 
284
- // ── unstable-key detection ───────────────────────────────────────────────
285
- // Track the last key thrown as a pending promise and whether it was accessed
286
- // as a cache hit in the current pass. If a new pending entry appears while
287
- // the previous pending key resolved but was never requested, the key is
288
- // changing between passes (e.g. Date.now() or Math.random() in the key).
289
- const _u = unsuspend as any;
290
- if (!_u.pendingCreated) _u.pendingCreated = 0;
291
- // ────────────────────────────────────────────────────────────────────────
292
-
293
301
  const existing = unsuspend.cache.get(cacheKey);
294
302
 
295
- // Mark the previous pending key as accessed when it appears as a cache hit.
296
- if (existing?.status === 'fulfilled' && _u.lastPendingKey === cacheKey) {
297
- _u.lastPendingKeyAccessed = true;
298
- }
299
-
300
303
  if (!existing) {
301
304
  // First encounter — call fn(), which may:
302
305
  // (a) return a Promise<T> — async usage (serialised for the client)
@@ -313,35 +316,6 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
313
316
  }
314
317
 
315
318
  // (a) Async Promise — standard useServerData usage.
316
-
317
- // Unstable-key detection: the previous pending key resolved but was never
318
- // requested in the current pass — a new key replaced it, which means the
319
- // key is not stable between render passes.
320
- if (_u.lastPendingKey != null && !_u.lastPendingKeyAccessed) {
321
- const prev = unsuspend.cache.get(_u.lastPendingKey);
322
- if (prev?.status === 'fulfilled') {
323
- throw new Error(
324
- `[hadars] useServerData: key ${JSON.stringify(cacheKey)} is not stable between render passes. ` +
325
- `The previous pass resolved ${JSON.stringify(_u.lastPendingKey)} but it was not ` +
326
- `requested in this pass — the key is changing between renders. ` +
327
- `Avoid dynamic values in keys (e.g. Date.now() or Math.random()); ` +
328
- `use stable, deterministic identifiers instead.`,
329
- );
330
- }
331
- }
332
-
333
- _u.pendingCreated++;
334
- if (_u.pendingCreated > 100) {
335
- throw new Error(
336
- `[hadars] useServerData: more than 100 async keys created in a single render. ` +
337
- `This usually means a key is not stable between renders (e.g. it contains ` +
338
- `Date.now() or Math.random()). Currently offending key: ${JSON.stringify(cacheKey)}.`,
339
- );
340
- }
341
-
342
- _u.lastPendingKey = cacheKey;
343
- _u.lastPendingKeyAccessed = false;
344
-
345
319
  const promise = (result as Promise<T>).then(
346
320
  value => { unsuspend.cache.set(cacheKey, { status: 'fulfilled', value }); },
347
321
  reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
@@ -363,26 +337,6 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
363
337
  // is stored in globalThis.__hadarsGraphQL by the framework before each render.
364
338
  // On the client, useServerData handles hydration + client-side navigation.
365
339
 
366
- /**
367
- * Derive a stable cache key string from a query argument.
368
- * For string queries the key is the trimmed query string.
369
- * For document nodes we use the operation name when available (fast, concise)
370
- * and fall back to stringifying the definitions (always stable).
371
- * The key is ONLY used for cache lookup — the original document is passed to
372
- * the executor so it can call print() itself.
373
- */
374
- function toCacheKey(doc: any): string {
375
- if (typeof doc === 'string') return doc.trim();
376
- // Use the operation name for named operations — compact and stable.
377
- for (const def of doc?.definitions ?? []) {
378
- if (def.kind === 'OperationDefinition' && def.name?.value) {
379
- return `op:${def.name.value}`;
380
- }
381
- }
382
- // Anonymous operation — fall back to stringifying definitions.
383
- return JSON.stringify(doc?.definitions ?? doc);
384
- }
385
-
386
340
  /**
387
341
  * Execute a GraphQL query server-side and return the result.
388
342
  *
@@ -421,9 +375,7 @@ export function useGraphQL(
421
375
  query: string | HadarsDocumentNode<unknown, Record<string, unknown>>,
422
376
  variables?: Record<string, unknown>,
423
377
  ): { data?: unknown } | undefined {
424
- const key = ['__gql', toCacheKey(query), JSON.stringify(variables ?? {})];
425
-
426
- return useServerData(key, async () => {
378
+ return useServerData(async () => {
427
379
  const executor: ((q: any, v?: Record<string, unknown>) => Promise<any>) | undefined =
428
380
  (globalThis as any).__hadarsGraphQL;
429
381
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
package/src/build.ts CHANGED
@@ -575,6 +575,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
575
575
  return buildSsrResponse(head, status, getAppBody, finalize, getPrecontentHtml);
576
576
  } catch (err: any) {
577
577
  console.error('[hadars] SSR render error:', err);
578
+ options.onError?.(err, request)?.catch?.(() => {});
578
579
  const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, '&lt;');
579
580
  return new Response(`<!doctype html><pre style="white-space:pre-wrap">${msg}</pre>`, {
580
581
  status: 500,
@@ -783,6 +784,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
783
784
  return buildSsrResponse(head, status, getAppBody, finalize, getPrecontentHtml);
784
785
  } catch (err: any) {
785
786
  console.error('[hadars] SSR render error:', err);
787
+ options.onError?.(err, request)?.catch?.(() => {});
786
788
  return new Response('Internal Server Error', { status: 500 });
787
789
  }
788
790
  };
package/src/cloudflare.ts CHANGED
@@ -123,6 +123,7 @@ export function createCloudflareHandler(
123
123
  });
124
124
  } catch (err: any) {
125
125
  console.error('[hadars] SSR render error:', err);
126
+ options.onError?.(err, request)?.catch?.(() => {});
126
127
  return new Response('Internal Server Error', { status: 500 });
127
128
  }
128
129
  };
package/src/lambda.ts CHANGED
@@ -270,6 +270,7 @@ export function createLambdaHandler(options: HadarsOptions, bundled?: LambdaBund
270
270
  });
271
271
  } catch (err: any) {
272
272
  console.error('[hadars] SSR render error:', err);
273
+ options.onError?.(err, request)?.catch?.(() => {});
273
274
  return new Response('Internal Server Error', { status: 500 });
274
275
  }
275
276
  };
@@ -294,6 +294,23 @@ export interface HadarsOptions {
294
294
  * ]
295
295
  */
296
296
  sources?: HadarsSourceEntry[];
297
+ /**
298
+ * Called whenever an SSR render error is caught, in both `dev()` and `run()` mode
299
+ * as well as the Lambda and Cloudflare adapters.
300
+ *
301
+ * Use this to forward errors to your monitoring service (Sentry, Datadog, etc.)
302
+ * without affecting the response sent to the browser.
303
+ * The handler may be async — hadars fires it and does not await the result,
304
+ * so it never delays the error response.
305
+ *
306
+ * @example
307
+ * import * as Sentry from '@sentry/node';
308
+ * onError: (err, req) => Sentry.captureException(err, { extra: { url: req.url } })
309
+ *
310
+ * @example
311
+ * onError: (err, req) => console.error('[myapp]', req.method, req.url, err)
312
+ */
313
+ onError?: (err: Error, req: Request) => void | Promise<void>;
297
314
  }
298
315
 
299
316
  /**
@@ -134,7 +134,12 @@ function getCtx(): InnerContext | null {
134
134
  // ── useServerData ─────────────────────────────────────────────────────────────
135
135
  //
136
136
  // Client-side cache pre-populated from the server's resolved data before
137
- // hydration. Keyed by the same React useId() values that the server used.
137
+ // hydration. During the initial SSR load, keyed by the server's useId() values
138
+ // (which match React's hydrateRoot IDs). During client-side navigation the
139
+ // server keys are _R_..._ (slim-react format) but the client is NOT in
140
+ // hydration mode so useId() returns _r_..._ — a completely different format.
141
+ // We bridge this by storing navigation results as an ordered array and consuming
142
+ // them sequentially on the retry, then caching by the client's own useId() key.
138
143
  const clientServerDataCache = new Map<string, unknown>();
139
144
 
140
145
  // Tracks in-flight data-only requests keyed by pathname+search so that all
@@ -144,39 +149,21 @@ const pendingDataFetch = new Map<string, Promise<void>>();
144
149
  // when a key is genuinely absent from the server response for this path.
145
150
  const fetchedPaths = new Set<string>();
146
151
 
147
- // Keys that were seeded from SSR data and not yet claimed by any useServerData
148
- // call on the client. Used to detect server↔client key mismatches.
149
- let ssrInitialKeys: Set<string> | null = null;
150
- let unclaimedKeyCheckScheduled = false;
151
-
152
- function scheduleUnclaimedKeyCheck() {
153
- if (unclaimedKeyCheckScheduled) return;
154
- unclaimedKeyCheckScheduled = true;
155
- // Wait for the current synchronous hydration pass to finish, then check.
156
- setTimeout(() => {
157
- unclaimedKeyCheckScheduled = false;
158
- if (ssrInitialKeys && ssrInitialKeys.size > 0) {
159
- console.warn(
160
- `[hadars] useServerData: ${ssrInitialKeys.size} server-resolved key(s) were ` +
161
- `never claimed during client hydration: ${[...ssrInitialKeys].map(k => JSON.stringify(k)).join(', ')}. ` +
162
- `This usually means the key passed to useServerData was different on the server ` +
163
- `than on the client (e.g. it contains Date.now(), Math.random(), or another ` +
164
- `value that changes between renders). Keys must be stable and deterministic.`,
165
- );
166
- }
167
- ssrInitialKeys = null;
168
- }, 0);
169
- }
152
+ // Ordered values from the most recent navigation fetch.
153
+ // React retries suspended components in tree order (same order as SSR), so
154
+ // consuming these positionally is safe and avoids the server/client key mismatch.
155
+ let _navValues: unknown[] = [];
156
+ let _navIdx = 0;
170
157
 
171
158
  /** Call this before hydrating to seed the client cache from the server's data.
172
159
  * Invoked automatically by the hadars client bootstrap.
173
160
  * Always clears the existing cache before populating — call with `{}` to just clear. */
174
161
  export function initServerDataCache(data: Record<string, unknown>) {
175
162
  clientServerDataCache.clear();
176
- ssrInitialKeys = new Set<string>();
163
+ _navValues = [];
164
+ _navIdx = 0;
177
165
  for (const [k, v] of Object.entries(data)) {
178
166
  clientServerDataCache.set(k, v);
179
- ssrInitialKeys.add(k);
180
167
  }
181
168
  }
182
169
 
@@ -188,9 +175,8 @@ export function initServerDataCache(data: Record<string, unknown>) {
188
175
  * On the client the pre-resolved value is read from the hydration cache
189
176
  * serialised into the page by the server, so no fetch is issued in the browser.
190
177
  *
191
- * The `key` (string or array of strings) uniquely identifies the cached value
192
- * across all SSR render passes and client hydration it must be stable and
193
- * unique within the page.
178
+ * The cache key is derived automatically from the call-site's position in the
179
+ * component tree via `useId()`no manual key is required.
194
180
  *
195
181
  * `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
196
182
  * The resolved value is serialised into `__serverData` and returned from cache
@@ -202,29 +188,42 @@ export function initServerDataCache(data: Record<string, unknown>) {
202
188
  * React Suspense until the server returns the JSON map of resolved values.
203
189
  *
204
190
  * @example
205
- * const user = useServerData('current_user', () => db.getUser(id));
206
- * const post = useServerData(['post', postId], () => db.getPost(postId));
191
+ * const user = useServerData(() => db.getUser(id));
192
+ * const post = useServerData(() => db.getPost(postId));
207
193
  * if (!user) return null; // undefined while pending on the first SSR pass
194
+ *
195
+ * // cache: false — evicts the entry on unmount so the next mount fetches fresh data
196
+ * const stats = useServerData(() => getServerStats(), { cache: false });
208
197
  */
209
- export function useServerData<T>(key: string | string[], fn: () => Promise<T> | T): T | undefined {
210
- const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
198
+ export function useServerData<T>(fn: () => Promise<T> | T, options?: { cache?: boolean }): T | undefined {
199
+ const cacheKey = React.useId();
200
+
201
+ // When cache: false, evict the entry on unmount so the next mount fetches
202
+ // fresh data from the server. The eviction is deferred via setTimeout so
203
+ // that React Strict Mode's synchronous fake-unmount/remount cycle can cancel
204
+ // it before it fires — a real unmount has no follow-up effect to cancel it.
205
+ const evictTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
206
+ React.useEffect(() => {
207
+ if (options?.cache !== false) return;
208
+ // Cancel any timer left over from a Strict Mode fake unmount.
209
+ if (evictTimerRef.current !== null) {
210
+ clearTimeout(evictTimerRef.current);
211
+ evictTimerRef.current = null;
212
+ }
213
+ return () => {
214
+ evictTimerRef.current = setTimeout(() => {
215
+ clientServerDataCache.delete(cacheKey);
216
+ evictTimerRef.current = null;
217
+ }, 0);
218
+ };
219
+ }, []);
211
220
 
212
221
  if (typeof window !== 'undefined') {
213
- // Cache hit — return the server-resolved value directly (covers both initial
214
- // SSR hydration and values fetched during client-side navigation).
222
+ // Cache hit — return the server-resolved value directly.
215
223
  if (clientServerDataCache.has(cacheKey)) {
216
- // Mark this SSR key as claimed so the unclaimed-key check doesn't warn about it.
217
- ssrInitialKeys?.delete(cacheKey);
218
224
  return clientServerDataCache.get(cacheKey) as T;
219
225
  }
220
226
 
221
- // Cache miss during the initial hydration pass (SSR data is present but
222
- // this key wasn't in it) — schedule a deferred check for orphaned SSR keys
223
- // which would signal a server↔client key mismatch.
224
- if (ssrInitialKeys !== null && ssrInitialKeys.size > 0) {
225
- scheduleUnclaimedKeyCheck();
226
- }
227
-
228
227
  // Cache miss — this component is mounting during client-side navigation
229
228
  // (the server hasn't sent data for this path yet). Fire a data-only
230
229
  // request to the server at the current URL and suspend via React Suspense
@@ -232,10 +231,23 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
232
231
  // share one Promise so only one network request is made per navigation.
233
232
  const pathKey = window.location.pathname + window.location.search;
234
233
 
235
- // If we already fetched this path and the key is still missing, the server
236
- // doesn't produce a value for it return undefined rather than looping.
234
+ // After a navigation fetch has completed, consume values positionally.
235
+ // The server returns keys in slim-react (_R_..._) format, but the client
236
+ // is not in hydration mode so useId() returns _r_..._ — they never match.
237
+ // We store the ordered values from the fetch and hand them out in tree
238
+ // order (which is identical on server and client for a given route).
237
239
  if (fetchedPaths.has(pathKey)) {
238
- return undefined;
240
+ if (_navIdx < _navValues.length) {
241
+ const value = _navValues[_navIdx++] as T;
242
+ clientServerDataCache.set(cacheKey, value);
243
+ return value;
244
+ }
245
+ // Positional data exhausted — cache:false eviction or remount with new
246
+ // useId() keys. Remove the path so a fresh fetch fires below.
247
+ fetchedPaths.delete(pathKey);
248
+ _navValues = [];
249
+ _navIdx = 0;
250
+ // fall through to trigger a new fetch
239
251
  }
240
252
 
241
253
  if (!pendingDataFetch.has(pathKey)) {
@@ -261,9 +273,14 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
261
273
  if (res.ok) json = await res.json();
262
274
  }
263
275
 
264
- for (const [k, v] of Object.entries(json?.serverData ?? {})) {
265
- clientServerDataCache.set(k, v);
266
- }
276
+ // Store as ordered array consumed positionally on retry to
277
+ // avoid the server (_R_..._) vs client (_r_..._) key mismatch.
278
+ _navValues = Object.values(json?.serverData ?? {});
279
+ _navIdx = 0;
280
+ // Only keep the freshly-fetched path in fetchedPaths — clear
281
+ // others so stale positional data from a previous page cannot
282
+ // be served if the user navigates back to it.
283
+ fetchedPaths.clear();
267
284
  } finally {
268
285
  fetchedPaths.add(pathKey);
269
286
  pendingDataFetch.delete(pathKey);
@@ -281,22 +298,8 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
281
298
  const unsuspend: AppUnsuspend | undefined = (globalThis as any).__hadarsUnsuspend;
282
299
  if (!unsuspend) return undefined;
283
300
 
284
- // ── unstable-key detection ───────────────────────────────────────────────
285
- // Track the last key thrown as a pending promise and whether it was accessed
286
- // as a cache hit in the current pass. If a new pending entry appears while
287
- // the previous pending key resolved but was never requested, the key is
288
- // changing between passes (e.g. Date.now() or Math.random() in the key).
289
- const _u = unsuspend as any;
290
- if (!_u.pendingCreated) _u.pendingCreated = 0;
291
- // ────────────────────────────────────────────────────────────────────────
292
-
293
301
  const existing = unsuspend.cache.get(cacheKey);
294
302
 
295
- // Mark the previous pending key as accessed when it appears as a cache hit.
296
- if (existing?.status === 'fulfilled' && _u.lastPendingKey === cacheKey) {
297
- _u.lastPendingKeyAccessed = true;
298
- }
299
-
300
303
  if (!existing) {
301
304
  // First encounter — call fn(), which may:
302
305
  // (a) return a Promise<T> — async usage (serialised for the client)
@@ -313,35 +316,6 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
313
316
  }
314
317
 
315
318
  // (a) Async Promise — standard useServerData usage.
316
-
317
- // Unstable-key detection: the previous pending key resolved but was never
318
- // requested in the current pass — a new key replaced it, which means the
319
- // key is not stable between render passes.
320
- if (_u.lastPendingKey != null && !_u.lastPendingKeyAccessed) {
321
- const prev = unsuspend.cache.get(_u.lastPendingKey);
322
- if (prev?.status === 'fulfilled') {
323
- throw new Error(
324
- `[hadars] useServerData: key ${JSON.stringify(cacheKey)} is not stable between render passes. ` +
325
- `The previous pass resolved ${JSON.stringify(_u.lastPendingKey)} but it was not ` +
326
- `requested in this pass — the key is changing between renders. ` +
327
- `Avoid dynamic values in keys (e.g. Date.now() or Math.random()); ` +
328
- `use stable, deterministic identifiers instead.`,
329
- );
330
- }
331
- }
332
-
333
- _u.pendingCreated++;
334
- if (_u.pendingCreated > 100) {
335
- throw new Error(
336
- `[hadars] useServerData: more than 100 async keys created in a single render. ` +
337
- `This usually means a key is not stable between renders (e.g. it contains ` +
338
- `Date.now() or Math.random()). Currently offending key: ${JSON.stringify(cacheKey)}.`,
339
- );
340
- }
341
-
342
- _u.lastPendingKey = cacheKey;
343
- _u.lastPendingKeyAccessed = false;
344
-
345
319
  const promise = (result as Promise<T>).then(
346
320
  value => { unsuspend.cache.set(cacheKey, { status: 'fulfilled', value }); },
347
321
  reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
@@ -363,26 +337,6 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
363
337
  // is stored in globalThis.__hadarsGraphQL by the framework before each render.
364
338
  // On the client, useServerData handles hydration + client-side navigation.
365
339
 
366
- /**
367
- * Derive a stable cache key string from a query argument.
368
- * For string queries the key is the trimmed query string.
369
- * For document nodes we use the operation name when available (fast, concise)
370
- * and fall back to stringifying the definitions (always stable).
371
- * The key is ONLY used for cache lookup — the original document is passed to
372
- * the executor so it can call print() itself.
373
- */
374
- function toCacheKey(doc: any): string {
375
- if (typeof doc === 'string') return doc.trim();
376
- // Use the operation name for named operations — compact and stable.
377
- for (const def of doc?.definitions ?? []) {
378
- if (def.kind === 'OperationDefinition' && def.name?.value) {
379
- return `op:${def.name.value}`;
380
- }
381
- }
382
- // Anonymous operation — fall back to stringifying definitions.
383
- return JSON.stringify(doc?.definitions ?? doc);
384
- }
385
-
386
340
  /**
387
341
  * Execute a GraphQL query server-side and return the result.
388
342
  *
@@ -421,9 +375,7 @@ export function useGraphQL(
421
375
  query: string | HadarsDocumentNode<unknown, Record<string, unknown>>,
422
376
  variables?: Record<string, unknown>,
423
377
  ): { data?: unknown } | undefined {
424
- const key = ['__gql', toCacheKey(query), JSON.stringify(variables ?? {})];
425
-
426
- return useServerData(key, async () => {
378
+ return useServerData(async () => {
427
379
  const executor: ((q: any, v?: Record<string, unknown>) => Promise<any>) | undefined =
428
380
  (globalThis as any).__hadarsGraphQL;
429
381
 
@@ -99,11 +99,11 @@ function swcTransform(this: any, swc: any, source: string, isServer: boolean, re
99
99
 
100
100
  const name: string = callee.value;
101
101
 
102
- // ── useServerData(key, fn) — strip fn on client builds ────────────────
102
+ // ── useServerData(fn) — strip fn on client builds ────────────────────
103
103
  if (!isServer && name === 'useServerData') {
104
104
  const args: any[] = node.arguments;
105
- if (!args || args.length < 2) return;
106
- const fnArg = args[1].expression ?? args[1];
105
+ if (!args || args.length < 1) return;
106
+ const fnArg = args[0].expression ?? args[0];
107
107
  // Normalise to 0-based local byte offsets and replace with stub.
108
108
  replacements.push({
109
109
  start: fnArg.span.start - fileOffset,
@@ -278,7 +278,7 @@ function scanExpressionEnd(source: string, pos: number): number {
278
278
  }
279
279
 
280
280
  /**
281
- * Strip the `fn` argument from `useServerData(key, fn)` calls in client builds.
281
+ * Strip the `fn` argument from `useServerData(fn)` calls in client builds.
282
282
  * Uses a character-level scanner to handle arbitrary fn expressions (arrow
283
283
  * functions with nested calls, async functions, object literals, etc.).
284
284
  */
@@ -290,16 +290,8 @@ function stripUseServerDataFns(source: string): string {
290
290
  let match: RegExpExecArray | null;
291
291
  CALL_RE.lastIndex = 0;
292
292
  while ((match = CALL_RE.exec(source)) !== null) {
293
- const callStart = match.index;
294
293
  let i = match.index + match[0].length;
295
- // Skip whitespace before first arg
296
- while (i < source.length && /\s/.test(source[i]!)) i++;
297
- // Skip first argument (key: string or array)
298
- i = scanExpressionEnd(source, i);
299
- // Expect comma separator
300
- if (i >= source.length || source[i] !== ',') continue;
301
- i++; // skip comma
302
- // Skip whitespace before fn
294
+ // Skip whitespace before fn arg
303
295
  while (i < source.length && /\s/.test(source[i]!)) i++;
304
296
  const fnStart = i;
305
297
  // Scan to end of fn argument