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.
package/README.md CHANGED
@@ -21,30 +21,30 @@ Bring your own router (or none), keep your components as plain React, and get SS
21
21
  ## Benchmarks
22
22
 
23
23
  <!-- BENCHMARK_START -->
24
- > Last run: 2026-03-25 · 120s · 100 connections · Bun runtime
25
- > hadars is **8.9x faster** in requests/sec
24
+ > Last run: 2026-04-02 · 120s · 100 connections · Bun runtime
25
+ > hadars is **9.3x faster** in requests/sec
26
26
 
27
27
  **Throughput** (autocannon, 120s)
28
28
 
29
29
  | Metric | hadars | Next.js |
30
30
  |---|---:|---:|
31
- | Requests/sec | **151** | 17 |
32
- | Latency median | **642 ms** | 2747 ms |
33
- | Latency p99 | **959 ms** | 4019 ms |
34
- | Throughput | **43.05** MB/s | 9.5 MB/s |
35
- | Peak RSS | 950.3 MB | **478.5 MB** |
36
- | Avg RSS | 763.3 MB | **426.4 MB** |
37
- | Build time | 0.7 s | 6.0 s |
31
+ | Requests/sec | **158** | 17 |
32
+ | Latency median | **634 ms** | 2754 ms |
33
+ | Latency p99 | **1031 ms** | 4140 ms |
34
+ | Throughput | **44.97** MB/s | 9.67 MB/s |
35
+ | Peak RSS | 1001.1 MB | **477.2 MB** |
36
+ | Avg RSS | 778.8 MB | **426.8 MB** |
37
+ | Build time | 0.6 s | 6.0 s |
38
38
 
39
39
  **Page load** (Playwright · Chromium headless · median)
40
40
 
41
41
  | Metric | hadars | Next.js |
42
42
  |---|---:|---:|
43
- | TTFB | **19 ms** | 42 ms |
44
- | FCP | **96 ms** | 136 ms |
45
- | DOMContentLoaded | **39 ms** | 127 ms |
46
- | Load | **122 ms** | 173 ms |
47
- | Peak RSS | 476.8 MB | **289.5 MB** |
43
+ | TTFB | **17 ms** | 45 ms |
44
+ | FCP | **92 ms** | 140 ms |
45
+ | DOMContentLoaded | **37 ms** | 127 ms |
46
+ | Load | **118 ms** | 172 ms |
47
+ | Peak RSS | 479.5 MB | **295.5 MB** |
48
48
  <!-- BENCHMARK_END -->
49
49
 
50
50
  ## Quick start
@@ -148,16 +148,28 @@ Fetch async data inside a component during SSR. The framework's render loop awai
148
148
  import { useServerData } from 'hadars';
149
149
 
150
150
  const UserCard = ({ userId }: { userId: string }) => {
151
- const user = useServerData(['user', userId], () => db.getUser(userId));
151
+ const user = useServerData(() => db.getUser(userId));
152
152
  if (!user) return null; // undefined while pending on the first SSR pass
153
153
  return <p>{user.name}</p>;
154
154
  };
155
155
  ```
156
156
 
157
- - **`key`** - string or string array; must be stable and unique within the page
157
+ The cache key is derived automatically from the call-site's position in the component tree via `useId()` no manual key is needed.
158
+
158
159
  - **Server (SSR)** - calls `fn()`, awaits the result across render iterations, returns `undefined` until resolved
159
160
  - **Client (hydration)** - reads the pre-resolved value from the hydration cache serialised by the server; `fn()` is never called in the browser
160
- - **Client (navigation)** - when a component mounts during client-side navigation and its key is not in the cache, hadars fires a single `GET <current-url>` with `Accept: application/json`; all `useServerData` calls within the same render are batched into one request and suspended via React Suspense until the server returns the JSON data map
161
+ - **Client (navigation)** - when a component mounts during client-side navigation and its data is not in the cache, hadars fires a single `GET <current-url>` with `Accept: application/json`; all `useServerData` calls within the same render are batched into one request and suspended via React Suspense until the server returns the JSON data map
162
+
163
+ ### Options
164
+
165
+ | Option | Type | Default | Description |
166
+ |---|---|---|---|
167
+ | `cache` | `boolean` | `true` | When `false`, the cached value is evicted when the component unmounts so the next mount fetches fresh data from the server. Safe with React Strict Mode. |
168
+
169
+ ```tsx
170
+ // Uptime changes every request — evict on unmount so re-mounting always fetches fresh
171
+ const uptime = useServerData(() => process.uptime(), { cache: false });
172
+ ```
161
173
 
162
174
  ## Data lifecycle hooks
163
175
 
@@ -192,6 +204,7 @@ const UserCard = ({ userId }: { userId: string }) => {
192
204
  | `paths` | `function` | - | Returns URL list to pre-render with `hadars export static`; receives `HadarsStaticContext` |
193
205
  | `sources` | `array` | - | Gatsby-compatible source plugins; hadars infers a GraphQL schema from their nodes |
194
206
  | `graphql` | `function` | - | Custom GraphQL executor passed to `paths()` and `getInitProps()` as `ctx.graphql` |
207
+ | `onError` | `function` | - | Called on every SSR render error; use to forward to Sentry, Datadog, etc. |
195
208
 
196
209
  ### moduleRules example
197
210
 
@@ -227,6 +240,24 @@ const config: HadarsOptions = {
227
240
  export default config;
228
241
  ```
229
242
 
243
+ ### Error monitoring example
244
+
245
+ ```ts
246
+ import * as Sentry from '@sentry/node';
247
+ import type { HadarsOptions } from 'hadars';
248
+
249
+ const config: HadarsOptions = {
250
+ entry: 'src/App.tsx',
251
+ onError: (err, req) => Sentry.captureException(err, {
252
+ extra: { url: req.url, method: req.method },
253
+ }),
254
+ };
255
+
256
+ export default config;
257
+ ```
258
+
259
+ `onError` is called on every SSR render error in `dev()`, `run()`, Lambda, and Cloudflare adapters. The handler may be async — hadars fires it without awaiting so it never delays the error response to the browser.
260
+
230
261
  ## Static Export
231
262
 
232
263
  > **Experimental.** Static export and Gatsby-compatible source plugins are new features. The API — including config shape, context object, and schema inference behaviour — may change in future releases without a major version bump.
package/dist/cli.js CHANGED
@@ -2737,6 +2737,8 @@ var dev = async (options) => {
2737
2737
  return buildSsrResponse(head, status, getAppBody, finalize, getPrecontentHtml);
2738
2738
  } catch (err) {
2739
2739
  console.error("[hadars] SSR render error:", err);
2740
+ options.onError?.(err, request)?.catch?.(() => {
2741
+ });
2740
2742
  const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, "&lt;");
2741
2743
  return new Response(`<!doctype html><pre style="white-space:pre-wrap">${msg}</pre>`, {
2742
2744
  status: 500,
@@ -2896,6 +2898,8 @@ var run = async (options) => {
2896
2898
  return buildSsrResponse(head, status, getAppBody, finalize, getPrecontentHtml);
2897
2899
  } catch (err) {
2898
2900
  console.error("[hadars] SSR render error:", err);
2901
+ options.onError?.(err, request)?.catch?.(() => {
2902
+ });
2899
2903
  return new Response("Internal Server Error", { status: 500 });
2900
2904
  }
2901
2905
  };
@@ -1387,6 +1387,8 @@ function createCloudflareHandler(options, bundled) {
1387
1387
  });
1388
1388
  } catch (err) {
1389
1389
  console.error("[hadars] SSR render error:", err);
1390
+ options.onError?.(err, request)?.catch?.(() => {
1391
+ });
1390
1392
  return new Response("Internal Server Error", { status: 500 });
1391
1393
  }
1392
1394
  };
@@ -1,4 +1,4 @@
1
- import { H as HadarsEntryModule, a as HadarsOptions } from './hadars-CSWWhlQC.cjs';
1
+ import { H as HadarsEntryModule, a as HadarsOptions } from './hadars-CPplIz_z.cjs';
2
2
 
3
3
  /**
4
4
  * Cloudflare Workers adapter for hadars.
@@ -1,4 +1,4 @@
1
- import { H as HadarsEntryModule, a as HadarsOptions } from './hadars-CSWWhlQC.js';
1
+ import { H as HadarsEntryModule, a as HadarsOptions } from './hadars-CPplIz_z.js';
2
2
 
3
3
  /**
4
4
  * Cloudflare Workers adapter for hadars.
@@ -53,6 +53,8 @@ function createCloudflareHandler(options, bundled) {
53
53
  });
54
54
  } catch (err) {
55
55
  console.error("[hadars] SSR render error:", err);
56
+ options.onError?.(err, request)?.catch?.(() => {
57
+ });
56
58
  return new Response("Internal Server Error", { status: 500 });
57
59
  }
58
60
  };
@@ -257,6 +257,23 @@ interface HadarsOptions {
257
257
  * ]
258
258
  */
259
259
  sources?: HadarsSourceEntry[];
260
+ /**
261
+ * Called whenever an SSR render error is caught, in both `dev()` and `run()` mode
262
+ * as well as the Lambda and Cloudflare adapters.
263
+ *
264
+ * Use this to forward errors to your monitoring service (Sentry, Datadog, etc.)
265
+ * without affecting the response sent to the browser.
266
+ * The handler may be async — hadars fires it and does not await the result,
267
+ * so it never delays the error response.
268
+ *
269
+ * @example
270
+ * import * as Sentry from '@sentry/node';
271
+ * onError: (err, req) => Sentry.captureException(err, { extra: { url: req.url } })
272
+ *
273
+ * @example
274
+ * onError: (err, req) => console.error('[myapp]', req.method, req.url, err)
275
+ */
276
+ onError?: (err: Error, req: Request) => void | Promise<void>;
260
277
  }
261
278
  /**
262
279
  * A Gatsby-compatible source plugin entry, matching the format used in
@@ -257,6 +257,23 @@ interface HadarsOptions {
257
257
  * ]
258
258
  */
259
259
  sources?: HadarsSourceEntry[];
260
+ /**
261
+ * Called whenever an SSR render error is caught, in both `dev()` and `run()` mode
262
+ * as well as the Lambda and Cloudflare adapters.
263
+ *
264
+ * Use this to forward errors to your monitoring service (Sentry, Datadog, etc.)
265
+ * without affecting the response sent to the browser.
266
+ * The handler may be async — hadars fires it and does not await the result,
267
+ * so it never delays the error response.
268
+ *
269
+ * @example
270
+ * import * as Sentry from '@sentry/node';
271
+ * onError: (err, req) => Sentry.captureException(err, { extra: { url: req.url } })
272
+ *
273
+ * @example
274
+ * onError: (err, req) => console.error('[myapp]', req.method, req.url, err)
275
+ */
276
+ onError?: (err: Error, req: Request) => void | Promise<void>;
260
277
  }
261
278
  /**
262
279
  * A Gatsby-compatible source plugin entry, matching the format used in
package/dist/index.cjs CHANGED
@@ -182,42 +182,46 @@ function getCtx() {
182
182
  var clientServerDataCache = /* @__PURE__ */ new Map();
183
183
  var pendingDataFetch = /* @__PURE__ */ new Map();
184
184
  var fetchedPaths = /* @__PURE__ */ new Set();
185
- var ssrInitialKeys = null;
186
- var unclaimedKeyCheckScheduled = false;
187
- function scheduleUnclaimedKeyCheck() {
188
- if (unclaimedKeyCheckScheduled) return;
189
- unclaimedKeyCheckScheduled = true;
190
- setTimeout(() => {
191
- unclaimedKeyCheckScheduled = false;
192
- if (ssrInitialKeys && ssrInitialKeys.size > 0) {
193
- console.warn(
194
- `[hadars] useServerData: ${ssrInitialKeys.size} server-resolved key(s) were never claimed during client hydration: ${[...ssrInitialKeys].map((k) => JSON.stringify(k)).join(", ")}. This usually means the key passed to useServerData was different on the server than on the client (e.g. it contains Date.now(), Math.random(), or another value that changes between renders). Keys must be stable and deterministic.`
195
- );
196
- }
197
- ssrInitialKeys = null;
198
- }, 0);
199
- }
185
+ var _navValues = [];
186
+ var _navIdx = 0;
200
187
  function initServerDataCache(data) {
201
188
  clientServerDataCache.clear();
202
- ssrInitialKeys = /* @__PURE__ */ new Set();
189
+ _navValues = [];
190
+ _navIdx = 0;
203
191
  for (const [k, v] of Object.entries(data)) {
204
192
  clientServerDataCache.set(k, v);
205
- ssrInitialKeys.add(k);
206
193
  }
207
194
  }
208
- function useServerData(key, fn) {
209
- const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
195
+ function useServerData(fn, options) {
196
+ const cacheKey = import_react.default.useId();
197
+ const evictTimerRef = import_react.default.useRef(null);
198
+ import_react.default.useEffect(() => {
199
+ if (options?.cache !== false) return;
200
+ if (evictTimerRef.current !== null) {
201
+ clearTimeout(evictTimerRef.current);
202
+ evictTimerRef.current = null;
203
+ }
204
+ return () => {
205
+ evictTimerRef.current = setTimeout(() => {
206
+ clientServerDataCache.delete(cacheKey);
207
+ evictTimerRef.current = null;
208
+ }, 0);
209
+ };
210
+ }, []);
210
211
  if (typeof window !== "undefined") {
211
212
  if (clientServerDataCache.has(cacheKey)) {
212
- ssrInitialKeys?.delete(cacheKey);
213
213
  return clientServerDataCache.get(cacheKey);
214
214
  }
215
- if (ssrInitialKeys !== null && ssrInitialKeys.size > 0) {
216
- scheduleUnclaimedKeyCheck();
217
- }
218
215
  const pathKey = window.location.pathname + window.location.search;
219
216
  if (fetchedPaths.has(pathKey)) {
220
- return void 0;
217
+ if (_navIdx < _navValues.length) {
218
+ const value = _navValues[_navIdx++];
219
+ clientServerDataCache.set(cacheKey, value);
220
+ return value;
221
+ }
222
+ fetchedPaths.delete(pathKey);
223
+ _navValues = [];
224
+ _navIdx = 0;
221
225
  }
222
226
  if (!pendingDataFetch.has(pathKey)) {
223
227
  let resolve;
@@ -236,9 +240,9 @@ function useServerData(key, fn) {
236
240
  const res = await fetch(pathKey, { headers: { "Accept": "application/json" } });
237
241
  if (res.ok) json = await res.json();
238
242
  }
239
- for (const [k, v] of Object.entries(json?.serverData ?? {})) {
240
- clientServerDataCache.set(k, v);
241
- }
243
+ _navValues = Object.values(json?.serverData ?? {});
244
+ _navIdx = 0;
245
+ fetchedPaths.clear();
242
246
  } finally {
243
247
  fetchedPaths.add(pathKey);
244
248
  pendingDataFetch.delete(pathKey);
@@ -250,12 +254,7 @@ function useServerData(key, fn) {
250
254
  }
251
255
  const unsuspend = globalThis.__hadarsUnsuspend;
252
256
  if (!unsuspend) return void 0;
253
- const _u = unsuspend;
254
- if (!_u.pendingCreated) _u.pendingCreated = 0;
255
257
  const existing = unsuspend.cache.get(cacheKey);
256
- if (existing?.status === "fulfilled" && _u.lastPendingKey === cacheKey) {
257
- _u.lastPendingKeyAccessed = true;
258
- }
259
258
  if (!existing) {
260
259
  const result = fn();
261
260
  const isThenable = result !== null && typeof result === "object" && typeof result.then === "function";
@@ -264,22 +263,6 @@ function useServerData(key, fn) {
264
263
  unsuspend.cache.set(cacheKey, { status: "fulfilled", value });
265
264
  return value;
266
265
  }
267
- if (_u.lastPendingKey != null && !_u.lastPendingKeyAccessed) {
268
- const prev = unsuspend.cache.get(_u.lastPendingKey);
269
- if (prev?.status === "fulfilled") {
270
- throw new Error(
271
- `[hadars] useServerData: key ${JSON.stringify(cacheKey)} is not stable between render passes. The previous pass resolved ${JSON.stringify(_u.lastPendingKey)} but it was not requested in this pass \u2014 the key is changing between renders. Avoid dynamic values in keys (e.g. Date.now() or Math.random()); use stable, deterministic identifiers instead.`
272
- );
273
- }
274
- }
275
- _u.pendingCreated++;
276
- if (_u.pendingCreated > 100) {
277
- throw new Error(
278
- `[hadars] useServerData: more than 100 async keys created in a single render. This usually means a key is not stable between renders (e.g. it contains Date.now() or Math.random()). Currently offending key: ${JSON.stringify(cacheKey)}.`
279
- );
280
- }
281
- _u.lastPendingKey = cacheKey;
282
- _u.lastPendingKeyAccessed = false;
283
266
  const promise = result.then(
284
267
  (value) => {
285
268
  unsuspend.cache.set(cacheKey, { status: "fulfilled", value });
@@ -297,18 +280,8 @@ function useServerData(key, fn) {
297
280
  if (existing.status === "rejected") throw existing.reason;
298
281
  return existing.value;
299
282
  }
300
- function toCacheKey(doc) {
301
- if (typeof doc === "string") return doc.trim();
302
- for (const def of doc?.definitions ?? []) {
303
- if (def.kind === "OperationDefinition" && def.name?.value) {
304
- return `op:${def.name.value}`;
305
- }
306
- }
307
- return JSON.stringify(doc?.definitions ?? doc);
308
- }
309
283
  function useGraphQL(query, variables) {
310
- const key = ["__gql", toCacheKey(query), JSON.stringify(variables ?? {})];
311
- return useServerData(key, async () => {
284
+ return useServerData(async () => {
312
285
  const executor = globalThis.__hadarsGraphQL;
313
286
  if (!executor) {
314
287
  throw new Error(
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { b as HadarsDocumentNode } from './hadars-CSWWhlQC.cjs';
2
- export { G as GraphQLExecutor, c as HadarsApp, H as HadarsEntryModule, d as HadarsGetClientProps, e as HadarsGetFinalProps, f as HadarsGetInitialProps, a as HadarsOptions, g as HadarsProps, h as HadarsRequest, i as HadarsSourceEntry, j as HadarsStaticContext } from './hadars-CSWWhlQC.cjs';
1
+ import { b as HadarsDocumentNode } from './hadars-CPplIz_z.cjs';
2
+ export { G as GraphQLExecutor, c as HadarsApp, H as HadarsEntryModule, d as HadarsGetClientProps, e as HadarsGetFinalProps, f as HadarsGetInitialProps, a as HadarsOptions, g as HadarsProps, h as HadarsRequest, i as HadarsSourceEntry, j as HadarsStaticContext } from './hadars-CPplIz_z.cjs';
3
3
  import React from 'react';
4
4
 
5
5
  /** Call this before hydrating to seed the client cache from the server's data.
@@ -14,9 +14,8 @@ declare function initServerDataCache(data: Record<string, unknown>): void;
14
14
  * On the client the pre-resolved value is read from the hydration cache
15
15
  * serialised into the page by the server, so no fetch is issued in the browser.
16
16
  *
17
- * The `key` (string or array of strings) uniquely identifies the cached value
18
- * across all SSR render passes and client hydration it must be stable and
19
- * unique within the page.
17
+ * The cache key is derived automatically from the call-site's position in the
18
+ * component tree via `useId()`no manual key is required.
20
19
  *
21
20
  * `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
22
21
  * The resolved value is serialised into `__serverData` and returned from cache
@@ -28,11 +27,16 @@ declare function initServerDataCache(data: Record<string, unknown>): void;
28
27
  * React Suspense until the server returns the JSON map of resolved values.
29
28
  *
30
29
  * @example
31
- * const user = useServerData('current_user', () => db.getUser(id));
32
- * const post = useServerData(['post', postId], () => db.getPost(postId));
30
+ * const user = useServerData(() => db.getUser(id));
31
+ * const post = useServerData(() => db.getPost(postId));
33
32
  * if (!user) return null; // undefined while pending on the first SSR pass
33
+ *
34
+ * // cache: false — evicts the entry on unmount so the next mount fetches fresh data
35
+ * const stats = useServerData(() => getServerStats(), { cache: false });
34
36
  */
35
- declare function useServerData<T>(key: string | string[], fn: () => Promise<T> | T): T | undefined;
37
+ declare function useServerData<T>(fn: () => Promise<T> | T, options?: {
38
+ cache?: boolean;
39
+ }): T | undefined;
36
40
  /**
37
41
  * Execute a GraphQL query server-side and return the result.
38
42
  *
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { b as HadarsDocumentNode } from './hadars-CSWWhlQC.js';
2
- export { G as GraphQLExecutor, c as HadarsApp, H as HadarsEntryModule, d as HadarsGetClientProps, e as HadarsGetFinalProps, f as HadarsGetInitialProps, a as HadarsOptions, g as HadarsProps, h as HadarsRequest, i as HadarsSourceEntry, j as HadarsStaticContext } from './hadars-CSWWhlQC.js';
1
+ import { b as HadarsDocumentNode } from './hadars-CPplIz_z.js';
2
+ export { G as GraphQLExecutor, c as HadarsApp, H as HadarsEntryModule, d as HadarsGetClientProps, e as HadarsGetFinalProps, f as HadarsGetInitialProps, a as HadarsOptions, g as HadarsProps, h as HadarsRequest, i as HadarsSourceEntry, j as HadarsStaticContext } from './hadars-CPplIz_z.js';
3
3
  import React from 'react';
4
4
 
5
5
  /** Call this before hydrating to seed the client cache from the server's data.
@@ -14,9 +14,8 @@ declare function initServerDataCache(data: Record<string, unknown>): void;
14
14
  * On the client the pre-resolved value is read from the hydration cache
15
15
  * serialised into the page by the server, so no fetch is issued in the browser.
16
16
  *
17
- * The `key` (string or array of strings) uniquely identifies the cached value
18
- * across all SSR render passes and client hydration it must be stable and
19
- * unique within the page.
17
+ * The cache key is derived automatically from the call-site's position in the
18
+ * component tree via `useId()`no manual key is required.
20
19
  *
21
20
  * `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
22
21
  * The resolved value is serialised into `__serverData` and returned from cache
@@ -28,11 +27,16 @@ declare function initServerDataCache(data: Record<string, unknown>): void;
28
27
  * React Suspense until the server returns the JSON map of resolved values.
29
28
  *
30
29
  * @example
31
- * const user = useServerData('current_user', () => db.getUser(id));
32
- * const post = useServerData(['post', postId], () => db.getPost(postId));
30
+ * const user = useServerData(() => db.getUser(id));
31
+ * const post = useServerData(() => db.getPost(postId));
33
32
  * if (!user) return null; // undefined while pending on the first SSR pass
33
+ *
34
+ * // cache: false — evicts the entry on unmount so the next mount fetches fresh data
35
+ * const stats = useServerData(() => getServerStats(), { cache: false });
34
36
  */
35
- declare function useServerData<T>(key: string | string[], fn: () => Promise<T> | T): T | undefined;
37
+ declare function useServerData<T>(fn: () => Promise<T> | T, options?: {
38
+ cache?: boolean;
39
+ }): T | undefined;
36
40
  /**
37
41
  * Execute a GraphQL query server-side and return the result.
38
42
  *
package/dist/index.js CHANGED
@@ -142,42 +142,46 @@ function getCtx() {
142
142
  var clientServerDataCache = /* @__PURE__ */ new Map();
143
143
  var pendingDataFetch = /* @__PURE__ */ new Map();
144
144
  var fetchedPaths = /* @__PURE__ */ new Set();
145
- var ssrInitialKeys = null;
146
- var unclaimedKeyCheckScheduled = false;
147
- function scheduleUnclaimedKeyCheck() {
148
- if (unclaimedKeyCheckScheduled) return;
149
- unclaimedKeyCheckScheduled = true;
150
- setTimeout(() => {
151
- unclaimedKeyCheckScheduled = false;
152
- if (ssrInitialKeys && ssrInitialKeys.size > 0) {
153
- console.warn(
154
- `[hadars] useServerData: ${ssrInitialKeys.size} server-resolved key(s) were never claimed during client hydration: ${[...ssrInitialKeys].map((k) => JSON.stringify(k)).join(", ")}. This usually means the key passed to useServerData was different on the server than on the client (e.g. it contains Date.now(), Math.random(), or another value that changes between renders). Keys must be stable and deterministic.`
155
- );
156
- }
157
- ssrInitialKeys = null;
158
- }, 0);
159
- }
145
+ var _navValues = [];
146
+ var _navIdx = 0;
160
147
  function initServerDataCache(data) {
161
148
  clientServerDataCache.clear();
162
- ssrInitialKeys = /* @__PURE__ */ new Set();
149
+ _navValues = [];
150
+ _navIdx = 0;
163
151
  for (const [k, v] of Object.entries(data)) {
164
152
  clientServerDataCache.set(k, v);
165
- ssrInitialKeys.add(k);
166
153
  }
167
154
  }
168
- function useServerData(key, fn) {
169
- const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
155
+ function useServerData(fn, options) {
156
+ const cacheKey = React.useId();
157
+ const evictTimerRef = React.useRef(null);
158
+ React.useEffect(() => {
159
+ if (options?.cache !== false) return;
160
+ if (evictTimerRef.current !== null) {
161
+ clearTimeout(evictTimerRef.current);
162
+ evictTimerRef.current = null;
163
+ }
164
+ return () => {
165
+ evictTimerRef.current = setTimeout(() => {
166
+ clientServerDataCache.delete(cacheKey);
167
+ evictTimerRef.current = null;
168
+ }, 0);
169
+ };
170
+ }, []);
170
171
  if (typeof window !== "undefined") {
171
172
  if (clientServerDataCache.has(cacheKey)) {
172
- ssrInitialKeys?.delete(cacheKey);
173
173
  return clientServerDataCache.get(cacheKey);
174
174
  }
175
- if (ssrInitialKeys !== null && ssrInitialKeys.size > 0) {
176
- scheduleUnclaimedKeyCheck();
177
- }
178
175
  const pathKey = window.location.pathname + window.location.search;
179
176
  if (fetchedPaths.has(pathKey)) {
180
- return void 0;
177
+ if (_navIdx < _navValues.length) {
178
+ const value = _navValues[_navIdx++];
179
+ clientServerDataCache.set(cacheKey, value);
180
+ return value;
181
+ }
182
+ fetchedPaths.delete(pathKey);
183
+ _navValues = [];
184
+ _navIdx = 0;
181
185
  }
182
186
  if (!pendingDataFetch.has(pathKey)) {
183
187
  let resolve;
@@ -196,9 +200,9 @@ function useServerData(key, fn) {
196
200
  const res = await fetch(pathKey, { headers: { "Accept": "application/json" } });
197
201
  if (res.ok) json = await res.json();
198
202
  }
199
- for (const [k, v] of Object.entries(json?.serverData ?? {})) {
200
- clientServerDataCache.set(k, v);
201
- }
203
+ _navValues = Object.values(json?.serverData ?? {});
204
+ _navIdx = 0;
205
+ fetchedPaths.clear();
202
206
  } finally {
203
207
  fetchedPaths.add(pathKey);
204
208
  pendingDataFetch.delete(pathKey);
@@ -210,12 +214,7 @@ function useServerData(key, fn) {
210
214
  }
211
215
  const unsuspend = globalThis.__hadarsUnsuspend;
212
216
  if (!unsuspend) return void 0;
213
- const _u = unsuspend;
214
- if (!_u.pendingCreated) _u.pendingCreated = 0;
215
217
  const existing = unsuspend.cache.get(cacheKey);
216
- if (existing?.status === "fulfilled" && _u.lastPendingKey === cacheKey) {
217
- _u.lastPendingKeyAccessed = true;
218
- }
219
218
  if (!existing) {
220
219
  const result = fn();
221
220
  const isThenable = result !== null && typeof result === "object" && typeof result.then === "function";
@@ -224,22 +223,6 @@ function useServerData(key, fn) {
224
223
  unsuspend.cache.set(cacheKey, { status: "fulfilled", value });
225
224
  return value;
226
225
  }
227
- if (_u.lastPendingKey != null && !_u.lastPendingKeyAccessed) {
228
- const prev = unsuspend.cache.get(_u.lastPendingKey);
229
- if (prev?.status === "fulfilled") {
230
- throw new Error(
231
- `[hadars] useServerData: key ${JSON.stringify(cacheKey)} is not stable between render passes. The previous pass resolved ${JSON.stringify(_u.lastPendingKey)} but it was not requested in this pass \u2014 the key is changing between renders. Avoid dynamic values in keys (e.g. Date.now() or Math.random()); use stable, deterministic identifiers instead.`
232
- );
233
- }
234
- }
235
- _u.pendingCreated++;
236
- if (_u.pendingCreated > 100) {
237
- throw new Error(
238
- `[hadars] useServerData: more than 100 async keys created in a single render. This usually means a key is not stable between renders (e.g. it contains Date.now() or Math.random()). Currently offending key: ${JSON.stringify(cacheKey)}.`
239
- );
240
- }
241
- _u.lastPendingKey = cacheKey;
242
- _u.lastPendingKeyAccessed = false;
243
226
  const promise = result.then(
244
227
  (value) => {
245
228
  unsuspend.cache.set(cacheKey, { status: "fulfilled", value });
@@ -257,18 +240,8 @@ function useServerData(key, fn) {
257
240
  if (existing.status === "rejected") throw existing.reason;
258
241
  return existing.value;
259
242
  }
260
- function toCacheKey(doc) {
261
- if (typeof doc === "string") return doc.trim();
262
- for (const def of doc?.definitions ?? []) {
263
- if (def.kind === "OperationDefinition" && def.name?.value) {
264
- return `op:${def.name.value}`;
265
- }
266
- }
267
- return JSON.stringify(doc?.definitions ?? doc);
268
- }
269
243
  function useGraphQL(query, variables) {
270
- const key = ["__gql", toCacheKey(query), JSON.stringify(variables ?? {})];
271
- return useServerData(key, async () => {
244
+ return useServerData(async () => {
272
245
  const executor = globalThis.__hadarsGraphQL;
273
246
  if (!executor) {
274
247
  throw new Error(
package/dist/lambda.cjs CHANGED
@@ -1510,6 +1510,8 @@ function createLambdaHandler(options, bundled) {
1510
1510
  });
1511
1511
  } catch (err) {
1512
1512
  console.error("[hadars] SSR render error:", err);
1513
+ options.onError?.(err, request)?.catch?.(() => {
1514
+ });
1513
1515
  return new Response("Internal Server Error", { status: 500 });
1514
1516
  }
1515
1517
  };
package/dist/lambda.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { H as HadarsEntryModule, a as HadarsOptions } from './hadars-CSWWhlQC.cjs';
1
+ import { H as HadarsEntryModule, a as HadarsOptions } from './hadars-CPplIz_z.cjs';
2
2
 
3
3
  /**
4
4
  * AWS Lambda adapter for hadars.
package/dist/lambda.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { H as HadarsEntryModule, a as HadarsOptions } from './hadars-CSWWhlQC.js';
1
+ import { H as HadarsEntryModule, a as HadarsOptions } from './hadars-CPplIz_z.js';
2
2
 
3
3
  /**
4
4
  * AWS Lambda adapter for hadars.
package/dist/lambda.js CHANGED
@@ -178,6 +178,8 @@ function createLambdaHandler(options, bundled) {
178
178
  });
179
179
  } catch (err) {
180
180
  console.error("[hadars] SSR render error:", err);
181
+ options.onError?.(err, request)?.catch?.(() => {
182
+ });
181
183
  return new Response("Internal Server Error", { status: 500 });
182
184
  }
183
185
  };
package/dist/loader.cjs CHANGED
@@ -55,8 +55,8 @@ function swcTransform(swc, source, isServer, resourcePath) {
55
55
  const name = callee.value;
56
56
  if (!isServer && name === "useServerData") {
57
57
  const args2 = node.arguments;
58
- if (!args2 || args2.length < 2) return;
59
- const fnArg = args2[1].expression ?? args2[1];
58
+ if (!args2 || args2.length < 1) return;
59
+ const fnArg = args2[0].expression ?? args2[0];
60
60
  replacements.push({
61
61
  start: fnArg.span.start - fileOffset,
62
62
  end: fnArg.span.end - fileOffset,
@@ -187,13 +187,8 @@ function stripUseServerDataFns(source) {
187
187
  let match;
188
188
  CALL_RE.lastIndex = 0;
189
189
  while ((match = CALL_RE.exec(source)) !== null) {
190
- const callStart = match.index;
191
190
  let i = match.index + match[0].length;
192
191
  while (i < source.length && /\s/.test(source[i])) i++;
193
- i = scanExpressionEnd(source, i);
194
- if (i >= source.length || source[i] !== ",") continue;
195
- i++;
196
- while (i < source.length && /\s/.test(source[i])) i++;
197
192
  const fnStart = i;
198
193
  const fnEnd = scanExpressionEnd(source, i);
199
194
  if (fnEnd <= fnStart) continue;