hadars 0.1.34 → 0.1.35

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
@@ -116,8 +116,9 @@ const UserCard = ({ userId }: { userId: string }) => {
116
116
  ```
117
117
 
118
118
  - **`key`** - string or string array; must be stable and unique within the page
119
- - **Server** - calls `fn()`, awaits the result across render iterations, returns `undefined` until resolved
120
- - **Client** - reads the pre-resolved value from the hydration cache serialised by the server; `fn()` is never called in the browser
119
+ - **Server (SSR)** - calls `fn()`, awaits the result across render iterations, returns `undefined` until resolved
120
+ - **Client (hydration)** - reads the pre-resolved value from the hydration cache serialised by the server; `fn()` is never called in the browser
121
+ - **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
121
122
 
122
123
  ## Data lifecycle hooks
123
124
 
package/dist/cli.js CHANGED
@@ -2208,6 +2208,13 @@ var dev = async (options) => {
2208
2208
  getFinalProps
2209
2209
  }
2210
2210
  });
2211
+ if (request.headers.get("Accept") === "application/json") {
2212
+ const serverData = clientProps.__serverData ?? {};
2213
+ return new Response(JSON.stringify({ serverData }), {
2214
+ status,
2215
+ headers: { "Content-Type": "application/json; charset=utf-8" }
2216
+ });
2217
+ }
2211
2218
  return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
2212
2219
  } catch (err) {
2213
2220
  console.error("[hadars] SSR render error:", err);
@@ -2344,7 +2351,7 @@ var run = async (options) => {
2344
2351
  getAfterRenderProps,
2345
2352
  getFinalProps
2346
2353
  } = await import(componentPath);
2347
- if (renderPool) {
2354
+ if (renderPool && request.headers.get("Accept") !== "application/json") {
2348
2355
  const serialReq = await serializeRequest(request);
2349
2356
  const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
2350
2357
  const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
@@ -2362,6 +2369,13 @@ var run = async (options) => {
2362
2369
  getFinalProps
2363
2370
  }
2364
2371
  });
2372
+ if (request.headers.get("Accept") === "application/json") {
2373
+ const serverData = clientProps.__serverData ?? {};
2374
+ return new Response(JSON.stringify({ serverData }), {
2375
+ status,
2376
+ headers: { "Content-Type": "application/json; charset=utf-8" }
2377
+ });
2378
+ }
2365
2379
  return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
2366
2380
  } catch (err) {
2367
2381
  console.error("[hadars] SSR render error:", err);
package/dist/index.cjs CHANGED
@@ -174,22 +174,98 @@ var AppProviderCSR = import_react.default.memo(({ children }) => {
174
174
  });
175
175
  var useApp = () => import_react.default.useContext(AppContext);
176
176
  var clientServerDataCache = /* @__PURE__ */ new Map();
177
+ var pendingDataFetch = /* @__PURE__ */ new Map();
178
+ var fetchedPaths = /* @__PURE__ */ new Set();
179
+ var ssrInitialKeys = null;
180
+ var unclaimedKeyCheckScheduled = false;
181
+ function scheduleUnclaimedKeyCheck() {
182
+ if (unclaimedKeyCheckScheduled)
183
+ return;
184
+ unclaimedKeyCheckScheduled = true;
185
+ setTimeout(() => {
186
+ unclaimedKeyCheckScheduled = false;
187
+ if (ssrInitialKeys && ssrInitialKeys.size > 0) {
188
+ console.warn(
189
+ `[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.`
190
+ );
191
+ }
192
+ ssrInitialKeys = null;
193
+ }, 0);
194
+ }
177
195
  function initServerDataCache(data) {
178
196
  clientServerDataCache.clear();
197
+ ssrInitialKeys = /* @__PURE__ */ new Set();
179
198
  for (const [k, v] of Object.entries(data)) {
180
199
  clientServerDataCache.set(k, v);
200
+ ssrInitialKeys.add(k);
181
201
  }
182
202
  }
183
203
  function useServerData(key, fn) {
184
204
  const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
185
205
  if (typeof window !== "undefined") {
186
- return clientServerDataCache.get(cacheKey);
206
+ if (clientServerDataCache.has(cacheKey)) {
207
+ ssrInitialKeys?.delete(cacheKey);
208
+ return clientServerDataCache.get(cacheKey);
209
+ }
210
+ if (ssrInitialKeys !== null && ssrInitialKeys.size > 0) {
211
+ scheduleUnclaimedKeyCheck();
212
+ }
213
+ const pathKey = window.location.pathname + window.location.search;
214
+ if (fetchedPaths.has(pathKey)) {
215
+ return void 0;
216
+ }
217
+ if (!pendingDataFetch.has(pathKey)) {
218
+ let resolve;
219
+ const p = new Promise((res) => {
220
+ resolve = res;
221
+ });
222
+ pendingDataFetch.set(pathKey, p);
223
+ queueMicrotask(async () => {
224
+ try {
225
+ const res = await fetch(pathKey, {
226
+ headers: { "Accept": "application/json" }
227
+ });
228
+ if (res.ok) {
229
+ const json = await res.json();
230
+ for (const [k, v] of Object.entries(json.serverData ?? {})) {
231
+ clientServerDataCache.set(k, v);
232
+ }
233
+ }
234
+ } finally {
235
+ fetchedPaths.add(pathKey);
236
+ pendingDataFetch.delete(pathKey);
237
+ resolve();
238
+ }
239
+ });
240
+ }
241
+ throw pendingDataFetch.get(pathKey);
187
242
  }
188
243
  const unsuspend = globalThis.__hadarsUnsuspend;
189
244
  if (!unsuspend)
190
245
  return void 0;
246
+ const _u = unsuspend;
247
+ if (!_u.seenThisPass)
248
+ _u.seenThisPass = /* @__PURE__ */ new Set();
249
+ if (!_u.seenLastPass)
250
+ _u.seenLastPass = /* @__PURE__ */ new Set();
251
+ if (_u.newPassStarting) {
252
+ _u.seenLastPass = new Set(_u.seenThisPass);
253
+ _u.seenThisPass.clear();
254
+ _u.newPassStarting = false;
255
+ }
256
+ _u.seenThisPass.add(cacheKey);
191
257
  const existing = unsuspend.cache.get(cacheKey);
192
258
  if (!existing) {
259
+ if (_u.seenLastPass.size > 0) {
260
+ const hasVanishedKey = [..._u.seenLastPass].some(
261
+ (k) => !_u.seenThisPass.has(k)
262
+ );
263
+ if (hasVanishedKey) {
264
+ throw new Error(
265
+ `[hadars] useServerData: key ${JSON.stringify(cacheKey)} appeared in this pass but a key that was present in the previous pass is now missing. This means the key is not stable across render passes (e.g. it contains Date.now(), Math.random(), or a value that changes on every render). Keys must be deterministic.`
266
+ );
267
+ }
268
+ }
193
269
  const result = fn();
194
270
  const isThenable = result !== null && typeof result === "object" && typeof result.then === "function";
195
271
  if (!isThenable) {
@@ -206,9 +282,11 @@ function useServerData(key, fn) {
206
282
  }
207
283
  );
208
284
  unsuspend.cache.set(cacheKey, { status: "pending", promise });
285
+ _u.newPassStarting = true;
209
286
  throw promise;
210
287
  }
211
288
  if (existing.status === "pending") {
289
+ _u.newPassStarting = true;
212
290
  throw existing.promise;
213
291
  }
214
292
  if (existing.status === "rejected")
package/dist/index.d.ts CHANGED
@@ -195,7 +195,10 @@ declare function initServerDataCache(data: Record<string, unknown>): void;
195
195
  * The resolved value is serialised into `__serverData` and returned from cache
196
196
  * during hydration.
197
197
  *
198
- * `fn` is **server-only**: it is never called in the browser.
198
+ * `fn` is **server-only**: it is never called in the browser. On client-side
199
+ * navigation (after the initial SSR load), hadars automatically fires a
200
+ * data-only request to the current URL (`X-Hadars-Data: 1`) and suspends via
201
+ * React Suspense until the server returns the JSON map of resolved values.
199
202
  *
200
203
  * @example
201
204
  * const user = useServerData('current_user', () => db.getUser(id));
package/dist/index.js CHANGED
@@ -131,22 +131,98 @@ var AppProviderCSR = React.memo(({ children }) => {
131
131
  });
132
132
  var useApp = () => React.useContext(AppContext);
133
133
  var clientServerDataCache = /* @__PURE__ */ new Map();
134
+ var pendingDataFetch = /* @__PURE__ */ new Map();
135
+ var fetchedPaths = /* @__PURE__ */ new Set();
136
+ var ssrInitialKeys = null;
137
+ var unclaimedKeyCheckScheduled = false;
138
+ function scheduleUnclaimedKeyCheck() {
139
+ if (unclaimedKeyCheckScheduled)
140
+ return;
141
+ unclaimedKeyCheckScheduled = true;
142
+ setTimeout(() => {
143
+ unclaimedKeyCheckScheduled = false;
144
+ if (ssrInitialKeys && ssrInitialKeys.size > 0) {
145
+ console.warn(
146
+ `[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.`
147
+ );
148
+ }
149
+ ssrInitialKeys = null;
150
+ }, 0);
151
+ }
134
152
  function initServerDataCache(data) {
135
153
  clientServerDataCache.clear();
154
+ ssrInitialKeys = /* @__PURE__ */ new Set();
136
155
  for (const [k, v] of Object.entries(data)) {
137
156
  clientServerDataCache.set(k, v);
157
+ ssrInitialKeys.add(k);
138
158
  }
139
159
  }
140
160
  function useServerData(key, fn) {
141
161
  const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
142
162
  if (typeof window !== "undefined") {
143
- return clientServerDataCache.get(cacheKey);
163
+ if (clientServerDataCache.has(cacheKey)) {
164
+ ssrInitialKeys?.delete(cacheKey);
165
+ return clientServerDataCache.get(cacheKey);
166
+ }
167
+ if (ssrInitialKeys !== null && ssrInitialKeys.size > 0) {
168
+ scheduleUnclaimedKeyCheck();
169
+ }
170
+ const pathKey = window.location.pathname + window.location.search;
171
+ if (fetchedPaths.has(pathKey)) {
172
+ return void 0;
173
+ }
174
+ if (!pendingDataFetch.has(pathKey)) {
175
+ let resolve;
176
+ const p = new Promise((res) => {
177
+ resolve = res;
178
+ });
179
+ pendingDataFetch.set(pathKey, p);
180
+ queueMicrotask(async () => {
181
+ try {
182
+ const res = await fetch(pathKey, {
183
+ headers: { "Accept": "application/json" }
184
+ });
185
+ if (res.ok) {
186
+ const json = await res.json();
187
+ for (const [k, v] of Object.entries(json.serverData ?? {})) {
188
+ clientServerDataCache.set(k, v);
189
+ }
190
+ }
191
+ } finally {
192
+ fetchedPaths.add(pathKey);
193
+ pendingDataFetch.delete(pathKey);
194
+ resolve();
195
+ }
196
+ });
197
+ }
198
+ throw pendingDataFetch.get(pathKey);
144
199
  }
145
200
  const unsuspend = globalThis.__hadarsUnsuspend;
146
201
  if (!unsuspend)
147
202
  return void 0;
203
+ const _u = unsuspend;
204
+ if (!_u.seenThisPass)
205
+ _u.seenThisPass = /* @__PURE__ */ new Set();
206
+ if (!_u.seenLastPass)
207
+ _u.seenLastPass = /* @__PURE__ */ new Set();
208
+ if (_u.newPassStarting) {
209
+ _u.seenLastPass = new Set(_u.seenThisPass);
210
+ _u.seenThisPass.clear();
211
+ _u.newPassStarting = false;
212
+ }
213
+ _u.seenThisPass.add(cacheKey);
148
214
  const existing = unsuspend.cache.get(cacheKey);
149
215
  if (!existing) {
216
+ if (_u.seenLastPass.size > 0) {
217
+ const hasVanishedKey = [..._u.seenLastPass].some(
218
+ (k) => !_u.seenThisPass.has(k)
219
+ );
220
+ if (hasVanishedKey) {
221
+ throw new Error(
222
+ `[hadars] useServerData: key ${JSON.stringify(cacheKey)} appeared in this pass but a key that was present in the previous pass is now missing. This means the key is not stable across render passes (e.g. it contains Date.now(), Math.random(), or a value that changes on every render). Keys must be deterministic.`
223
+ );
224
+ }
225
+ }
150
226
  const result = fn();
151
227
  const isThenable = result !== null && typeof result === "object" && typeof result.then === "function";
152
228
  if (!isThenable) {
@@ -163,9 +239,11 @@ function useServerData(key, fn) {
163
239
  }
164
240
  );
165
241
  unsuspend.cache.set(cacheKey, { status: "pending", promise });
242
+ _u.newPassStarting = true;
166
243
  throw promise;
167
244
  }
168
245
  if (existing.status === "pending") {
246
+ _u.newPassStarting = true;
169
247
  throw existing.promise;
170
248
  }
171
249
  if (existing.status === "rejected")
@@ -174,13 +174,46 @@ export const useApp = () => React.useContext(AppContext);
174
174
  // hydration. Keyed by the same React useId() values that the server used.
175
175
  const clientServerDataCache = new Map<string, unknown>();
176
176
 
177
+ // Tracks in-flight data-only requests keyed by pathname+search so that all
178
+ // useServerData calls within a single Suspense pass share one network request.
179
+ const pendingDataFetch = new Map<string, Promise<void>>();
180
+ // Paths for which a client data fetch has already completed. Prevents re-fetching
181
+ // when a key is genuinely absent from the server response for this path.
182
+ const fetchedPaths = new Set<string>();
183
+
184
+ // Keys that were seeded from SSR data and not yet claimed by any useServerData
185
+ // call on the client. Used to detect server↔client key mismatches.
186
+ let ssrInitialKeys: Set<string> | null = null;
187
+ let unclaimedKeyCheckScheduled = false;
188
+
189
+ function scheduleUnclaimedKeyCheck() {
190
+ if (unclaimedKeyCheckScheduled) return;
191
+ unclaimedKeyCheckScheduled = true;
192
+ // Wait for the current synchronous hydration pass to finish, then check.
193
+ setTimeout(() => {
194
+ unclaimedKeyCheckScheduled = false;
195
+ if (ssrInitialKeys && ssrInitialKeys.size > 0) {
196
+ console.warn(
197
+ `[hadars] useServerData: ${ssrInitialKeys.size} server-resolved key(s) were ` +
198
+ `never claimed during client hydration: ${[...ssrInitialKeys].map(k => JSON.stringify(k)).join(', ')}. ` +
199
+ `This usually means the key passed to useServerData was different on the server ` +
200
+ `than on the client (e.g. it contains Date.now(), Math.random(), or another ` +
201
+ `value that changes between renders). Keys must be stable and deterministic.`,
202
+ );
203
+ }
204
+ ssrInitialKeys = null;
205
+ }, 0);
206
+ }
207
+
177
208
  /** Call this before hydrating to seed the client cache from the server's data.
178
209
  * Invoked automatically by the hadars client bootstrap.
179
210
  * Always clears the existing cache before populating — call with `{}` to just clear. */
180
211
  export function initServerDataCache(data: Record<string, unknown>) {
181
212
  clientServerDataCache.clear();
213
+ ssrInitialKeys = new Set<string>();
182
214
  for (const [k, v] of Object.entries(data)) {
183
215
  clientServerDataCache.set(k, v);
216
+ ssrInitialKeys.add(k);
184
217
  }
185
218
  }
186
219
 
@@ -200,7 +233,10 @@ export function initServerDataCache(data: Record<string, unknown>) {
200
233
  * The resolved value is serialised into `__serverData` and returned from cache
201
234
  * during hydration.
202
235
  *
203
- * `fn` is **server-only**: it is never called in the browser.
236
+ * `fn` is **server-only**: it is never called in the browser. On client-side
237
+ * navigation (after the initial SSR load), hadars automatically fires a
238
+ * data-only request to the current URL (`X-Hadars-Data: 1`) and suspends via
239
+ * React Suspense until the server returns the JSON map of resolved values.
204
240
  *
205
241
  * @example
206
242
  * const user = useServerData('current_user', () => db.getUser(id));
@@ -211,9 +247,59 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
211
247
  const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
212
248
 
213
249
  if (typeof window !== 'undefined') {
214
- // Client: if the server serialised a value for this key, return it directly.
215
- // fn() is a server-only operation and must never run in the browser.
216
- return clientServerDataCache.get(cacheKey) as T | undefined;
250
+ // Cache hit — return the server-resolved value directly (covers both initial
251
+ // SSR hydration and values fetched during client-side navigation).
252
+ if (clientServerDataCache.has(cacheKey)) {
253
+ // Mark this SSR key as claimed so the unclaimed-key check doesn't warn about it.
254
+ ssrInitialKeys?.delete(cacheKey);
255
+ return clientServerDataCache.get(cacheKey) as T;
256
+ }
257
+
258
+ // Cache miss during the initial hydration pass (SSR data is present but
259
+ // this key wasn't in it) — schedule a deferred check for orphaned SSR keys
260
+ // which would signal a server↔client key mismatch.
261
+ if (ssrInitialKeys !== null && ssrInitialKeys.size > 0) {
262
+ scheduleUnclaimedKeyCheck();
263
+ }
264
+
265
+ // Cache miss — this component is mounting during client-side navigation
266
+ // (the server hasn't sent data for this path yet). Fire a data-only
267
+ // request to the server at the current URL and suspend via React Suspense
268
+ // until it completes. All useServerData calls within the same React render
269
+ // share one Promise so only one network request is made per navigation.
270
+ const pathKey = window.location.pathname + window.location.search;
271
+
272
+ // If we already fetched this path and the key is still missing, the server
273
+ // doesn't produce a value for it — return undefined rather than looping.
274
+ if (fetchedPaths.has(pathKey)) {
275
+ return undefined;
276
+ }
277
+
278
+ if (!pendingDataFetch.has(pathKey)) {
279
+ let resolve!: () => void;
280
+ const p = new Promise<void>(res => { resolve = res; });
281
+ pendingDataFetch.set(pathKey, p);
282
+ // Fire in a microtask so that every useServerData call in this React
283
+ // render is registered against the same deferred before the fetch starts.
284
+ queueMicrotask(async () => {
285
+ try {
286
+ const res = await fetch(pathKey, {
287
+ headers: { 'Accept': 'application/json' },
288
+ });
289
+ if (res.ok) {
290
+ const json = await res.json() as { serverData: Record<string, unknown> };
291
+ for (const [k, v] of Object.entries(json.serverData ?? {})) {
292
+ clientServerDataCache.set(k, v);
293
+ }
294
+ }
295
+ } finally {
296
+ fetchedPaths.add(pathKey);
297
+ pendingDataFetch.delete(pathKey);
298
+ resolve();
299
+ }
300
+ });
301
+ }
302
+ throw pendingDataFetch.get(pathKey)!;
217
303
  }
218
304
 
219
305
  // Server: communicate via globalThis.__hadarsUnsuspend which is set by the
@@ -223,9 +309,54 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
223
309
  const unsuspend: AppUnsuspend | undefined = (globalThis as any).__hadarsUnsuspend;
224
310
  if (!unsuspend) return undefined;
225
311
 
312
+ // ── per-pass key tracking ────────────────────────────────────────────────
313
+ // We keep two sets: keys seen in the current pass and keys seen in the
314
+ // previous pass. When a pass throws a promise, the *next* call to
315
+ // useServerData marks the start of a new pass and rotates the sets.
316
+ //
317
+ // A key is unstable when a key that WAS seen in the previous pass is now
318
+ // absent from the current pass while a new key appears instead. This means
319
+ // a component produced a different key string between passes (e.g. Date.now()
320
+ // in the key). We fire immediately — there is no need to wait for other
321
+ // entries to settle first, because a legitimately-new component always extends
322
+ // seenLastPass (all previous keys remain present in seenThisPass).
323
+ const _u = unsuspend as any;
324
+ if (!_u.seenThisPass) _u.seenThisPass = new Set<string>();
325
+ if (!_u.seenLastPass) _u.seenLastPass = new Set<string>();
326
+
327
+ if (_u.newPassStarting) {
328
+ // This is the first useServerData call after a thrown promise — rotate.
329
+ _u.seenLastPass = new Set(_u.seenThisPass);
330
+ _u.seenThisPass.clear();
331
+ _u.newPassStarting = false;
332
+ }
333
+ _u.seenThisPass.add(cacheKey);
334
+ // ────────────────────────────────────────────────────────────────────────
335
+
226
336
  const existing = unsuspend.cache.get(cacheKey);
227
337
 
228
338
  if (!existing) {
339
+ // Detect an unstable key: a key that was called in the previous pass is
340
+ // now absent while a new key has appeared. This means a component
341
+ // generated a different key between passes — it will loop forever.
342
+ //
343
+ // We intentionally do NOT fire when seenLastPass is empty (first pass
344
+ // ever) or when all previous keys are still present (legitimate
345
+ // "new component reached for the first time" scenario).
346
+ if (_u.seenLastPass.size > 0) {
347
+ const hasVanishedKey = [..._u.seenLastPass as Set<string>].some(
348
+ (k: string) => !(_u.seenThisPass as Set<string>).has(k),
349
+ );
350
+ if (hasVanishedKey) {
351
+ throw new Error(
352
+ `[hadars] useServerData: key ${JSON.stringify(cacheKey)} appeared in this pass ` +
353
+ `but a key that was present in the previous pass is now missing. This means ` +
354
+ `the key is not stable across render passes (e.g. it contains Date.now(), ` +
355
+ `Math.random(), or a value that changes on every render). Keys must be deterministic.`,
356
+ );
357
+ }
358
+ }
359
+
229
360
  // First encounter — call fn(), which may:
230
361
  // (a) return a Promise<T> — async usage (serialised for the client)
231
362
  // (b) return T synchronously — e.g. a sync data source
@@ -246,9 +377,11 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
246
377
  reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
247
378
  );
248
379
  unsuspend.cache.set(cacheKey, { status: 'pending', promise });
380
+ _u.newPassStarting = true; // next useServerData call opens a new pass
249
381
  throw promise; // slim-react will await and retry
250
382
  }
251
383
  if (existing.status === 'pending') {
384
+ _u.newPassStarting = true;
252
385
  throw existing.promise; // slim-react will await and retry
253
386
  }
254
387
  if (existing.status === 'rejected') throw existing.reason;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
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
@@ -689,6 +689,18 @@ export const dev = async (options: HadarsRuntimeOptions) => {
689
689
  },
690
690
  });
691
691
 
692
+ // Content negotiation: if the client only accepts JSON (client-side
693
+ // navigation via useServerData), return the resolved data map as JSON
694
+ // instead of a full HTML page. The same auth context applies — cookies
695
+ // and headers are forwarded unchanged, so no new attack surface is created.
696
+ if (request.headers.get('Accept') === 'application/json') {
697
+ const serverData = (clientProps as any).__serverData ?? {};
698
+ return new Response(JSON.stringify({ serverData }), {
699
+ status,
700
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
701
+ });
702
+ }
703
+
692
704
  return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
693
705
  } catch (err: any) {
694
706
  console.error('[hadars] SSR render error:', err);
@@ -858,7 +870,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
858
870
  getFinalProps,
859
871
  } = (await import(componentPath)) as HadarsEntryModule<any>;
860
872
 
861
- if (renderPool) {
873
+ if (renderPool && request.headers.get('Accept') !== 'application/json') {
862
874
  // Worker runs the full lifecycle — no non-serializable objects cross the thread boundary.
863
875
  const serialReq = await serializeRequest(request);
864
876
  const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
@@ -879,6 +891,17 @@ export const run = async (options: HadarsRuntimeOptions) => {
879
891
  },
880
892
  });
881
893
 
894
+ // Content negotiation: if the client only accepts JSON (client-side
895
+ // navigation via useServerData), return the resolved data map as JSON
896
+ // instead of a full HTML page.
897
+ if (request.headers.get('Accept') === 'application/json') {
898
+ const serverData = (clientProps as any).__serverData ?? {};
899
+ return new Response(JSON.stringify({ serverData }), {
900
+ status,
901
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
902
+ });
903
+ }
904
+
882
905
  return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
883
906
  } catch (err: any) {
884
907
  console.error('[hadars] SSR render error:', err);
@@ -174,13 +174,46 @@ export const useApp = () => React.useContext(AppContext);
174
174
  // hydration. Keyed by the same React useId() values that the server used.
175
175
  const clientServerDataCache = new Map<string, unknown>();
176
176
 
177
+ // Tracks in-flight data-only requests keyed by pathname+search so that all
178
+ // useServerData calls within a single Suspense pass share one network request.
179
+ const pendingDataFetch = new Map<string, Promise<void>>();
180
+ // Paths for which a client data fetch has already completed. Prevents re-fetching
181
+ // when a key is genuinely absent from the server response for this path.
182
+ const fetchedPaths = new Set<string>();
183
+
184
+ // Keys that were seeded from SSR data and not yet claimed by any useServerData
185
+ // call on the client. Used to detect server↔client key mismatches.
186
+ let ssrInitialKeys: Set<string> | null = null;
187
+ let unclaimedKeyCheckScheduled = false;
188
+
189
+ function scheduleUnclaimedKeyCheck() {
190
+ if (unclaimedKeyCheckScheduled) return;
191
+ unclaimedKeyCheckScheduled = true;
192
+ // Wait for the current synchronous hydration pass to finish, then check.
193
+ setTimeout(() => {
194
+ unclaimedKeyCheckScheduled = false;
195
+ if (ssrInitialKeys && ssrInitialKeys.size > 0) {
196
+ console.warn(
197
+ `[hadars] useServerData: ${ssrInitialKeys.size} server-resolved key(s) were ` +
198
+ `never claimed during client hydration: ${[...ssrInitialKeys].map(k => JSON.stringify(k)).join(', ')}. ` +
199
+ `This usually means the key passed to useServerData was different on the server ` +
200
+ `than on the client (e.g. it contains Date.now(), Math.random(), or another ` +
201
+ `value that changes between renders). Keys must be stable and deterministic.`,
202
+ );
203
+ }
204
+ ssrInitialKeys = null;
205
+ }, 0);
206
+ }
207
+
177
208
  /** Call this before hydrating to seed the client cache from the server's data.
178
209
  * Invoked automatically by the hadars client bootstrap.
179
210
  * Always clears the existing cache before populating — call with `{}` to just clear. */
180
211
  export function initServerDataCache(data: Record<string, unknown>) {
181
212
  clientServerDataCache.clear();
213
+ ssrInitialKeys = new Set<string>();
182
214
  for (const [k, v] of Object.entries(data)) {
183
215
  clientServerDataCache.set(k, v);
216
+ ssrInitialKeys.add(k);
184
217
  }
185
218
  }
186
219
 
@@ -200,7 +233,10 @@ export function initServerDataCache(data: Record<string, unknown>) {
200
233
  * The resolved value is serialised into `__serverData` and returned from cache
201
234
  * during hydration.
202
235
  *
203
- * `fn` is **server-only**: it is never called in the browser.
236
+ * `fn` is **server-only**: it is never called in the browser. On client-side
237
+ * navigation (after the initial SSR load), hadars automatically fires a
238
+ * data-only request to the current URL (`X-Hadars-Data: 1`) and suspends via
239
+ * React Suspense until the server returns the JSON map of resolved values.
204
240
  *
205
241
  * @example
206
242
  * const user = useServerData('current_user', () => db.getUser(id));
@@ -211,9 +247,59 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
211
247
  const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
212
248
 
213
249
  if (typeof window !== 'undefined') {
214
- // Client: if the server serialised a value for this key, return it directly.
215
- // fn() is a server-only operation and must never run in the browser.
216
- return clientServerDataCache.get(cacheKey) as T | undefined;
250
+ // Cache hit — return the server-resolved value directly (covers both initial
251
+ // SSR hydration and values fetched during client-side navigation).
252
+ if (clientServerDataCache.has(cacheKey)) {
253
+ // Mark this SSR key as claimed so the unclaimed-key check doesn't warn about it.
254
+ ssrInitialKeys?.delete(cacheKey);
255
+ return clientServerDataCache.get(cacheKey) as T;
256
+ }
257
+
258
+ // Cache miss during the initial hydration pass (SSR data is present but
259
+ // this key wasn't in it) — schedule a deferred check for orphaned SSR keys
260
+ // which would signal a server↔client key mismatch.
261
+ if (ssrInitialKeys !== null && ssrInitialKeys.size > 0) {
262
+ scheduleUnclaimedKeyCheck();
263
+ }
264
+
265
+ // Cache miss — this component is mounting during client-side navigation
266
+ // (the server hasn't sent data for this path yet). Fire a data-only
267
+ // request to the server at the current URL and suspend via React Suspense
268
+ // until it completes. All useServerData calls within the same React render
269
+ // share one Promise so only one network request is made per navigation.
270
+ const pathKey = window.location.pathname + window.location.search;
271
+
272
+ // If we already fetched this path and the key is still missing, the server
273
+ // doesn't produce a value for it — return undefined rather than looping.
274
+ if (fetchedPaths.has(pathKey)) {
275
+ return undefined;
276
+ }
277
+
278
+ if (!pendingDataFetch.has(pathKey)) {
279
+ let resolve!: () => void;
280
+ const p = new Promise<void>(res => { resolve = res; });
281
+ pendingDataFetch.set(pathKey, p);
282
+ // Fire in a microtask so that every useServerData call in this React
283
+ // render is registered against the same deferred before the fetch starts.
284
+ queueMicrotask(async () => {
285
+ try {
286
+ const res = await fetch(pathKey, {
287
+ headers: { 'Accept': 'application/json' },
288
+ });
289
+ if (res.ok) {
290
+ const json = await res.json() as { serverData: Record<string, unknown> };
291
+ for (const [k, v] of Object.entries(json.serverData ?? {})) {
292
+ clientServerDataCache.set(k, v);
293
+ }
294
+ }
295
+ } finally {
296
+ fetchedPaths.add(pathKey);
297
+ pendingDataFetch.delete(pathKey);
298
+ resolve();
299
+ }
300
+ });
301
+ }
302
+ throw pendingDataFetch.get(pathKey)!;
217
303
  }
218
304
 
219
305
  // Server: communicate via globalThis.__hadarsUnsuspend which is set by the
@@ -223,9 +309,54 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
223
309
  const unsuspend: AppUnsuspend | undefined = (globalThis as any).__hadarsUnsuspend;
224
310
  if (!unsuspend) return undefined;
225
311
 
312
+ // ── per-pass key tracking ────────────────────────────────────────────────
313
+ // We keep two sets: keys seen in the current pass and keys seen in the
314
+ // previous pass. When a pass throws a promise, the *next* call to
315
+ // useServerData marks the start of a new pass and rotates the sets.
316
+ //
317
+ // A key is unstable when a key that WAS seen in the previous pass is now
318
+ // absent from the current pass while a new key appears instead. This means
319
+ // a component produced a different key string between passes (e.g. Date.now()
320
+ // in the key). We fire immediately — there is no need to wait for other
321
+ // entries to settle first, because a legitimately-new component always extends
322
+ // seenLastPass (all previous keys remain present in seenThisPass).
323
+ const _u = unsuspend as any;
324
+ if (!_u.seenThisPass) _u.seenThisPass = new Set<string>();
325
+ if (!_u.seenLastPass) _u.seenLastPass = new Set<string>();
326
+
327
+ if (_u.newPassStarting) {
328
+ // This is the first useServerData call after a thrown promise — rotate.
329
+ _u.seenLastPass = new Set(_u.seenThisPass);
330
+ _u.seenThisPass.clear();
331
+ _u.newPassStarting = false;
332
+ }
333
+ _u.seenThisPass.add(cacheKey);
334
+ // ────────────────────────────────────────────────────────────────────────
335
+
226
336
  const existing = unsuspend.cache.get(cacheKey);
227
337
 
228
338
  if (!existing) {
339
+ // Detect an unstable key: a key that was called in the previous pass is
340
+ // now absent while a new key has appeared. This means a component
341
+ // generated a different key between passes — it will loop forever.
342
+ //
343
+ // We intentionally do NOT fire when seenLastPass is empty (first pass
344
+ // ever) or when all previous keys are still present (legitimate
345
+ // "new component reached for the first time" scenario).
346
+ if (_u.seenLastPass.size > 0) {
347
+ const hasVanishedKey = [..._u.seenLastPass as Set<string>].some(
348
+ (k: string) => !(_u.seenThisPass as Set<string>).has(k),
349
+ );
350
+ if (hasVanishedKey) {
351
+ throw new Error(
352
+ `[hadars] useServerData: key ${JSON.stringify(cacheKey)} appeared in this pass ` +
353
+ `but a key that was present in the previous pass is now missing. This means ` +
354
+ `the key is not stable across render passes (e.g. it contains Date.now(), ` +
355
+ `Math.random(), or a value that changes on every render). Keys must be deterministic.`,
356
+ );
357
+ }
358
+ }
359
+
229
360
  // First encounter — call fn(), which may:
230
361
  // (a) return a Promise<T> — async usage (serialised for the client)
231
362
  // (b) return T synchronously — e.g. a sync data source
@@ -246,9 +377,11 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
246
377
  reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
247
378
  );
248
379
  unsuspend.cache.set(cacheKey, { status: 'pending', promise });
380
+ _u.newPassStarting = true; // next useServerData call opens a new pass
249
381
  throw promise; // slim-react will await and retry
250
382
  }
251
383
  if (existing.status === 'pending') {
384
+ _u.newPassStarting = true;
252
385
  throw existing.promise; // slim-react will await and retry
253
386
  }
254
387
  if (existing.status === 'rejected') throw existing.reason;