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 +48 -17
- package/dist/cli.js +4 -0
- package/dist/cloudflare.cjs +2 -0
- package/dist/cloudflare.d.cts +1 -1
- package/dist/cloudflare.d.ts +1 -1
- package/dist/cloudflare.js +2 -0
- package/dist/{hadars-CSWWhlQC.d.cts → hadars-CPplIz_z.d.cts} +17 -0
- package/dist/{hadars-CSWWhlQC.d.ts → hadars-CPplIz_z.d.ts} +17 -0
- package/dist/index.cjs +32 -59
- package/dist/index.d.cts +12 -8
- package/dist/index.d.ts +12 -8
- package/dist/index.js +32 -59
- package/dist/lambda.cjs +2 -0
- package/dist/lambda.d.cts +1 -1
- package/dist/lambda.d.ts +1 -1
- package/dist/lambda.js +2 -0
- package/dist/loader.cjs +2 -7
- package/dist/utils/Head.tsx +68 -116
- package/package.json +1 -1
- package/src/build.ts +2 -0
- package/src/cloudflare.ts +1 -0
- package/src/lambda.ts +1 -0
- package/src/types/hadars.ts +17 -0
- package/src/utils/Head.tsx +68 -116
- package/src/utils/loader.ts +5 -13
package/dist/utils/Head.tsx
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
let
|
|
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
|
-
|
|
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
|
|
192
|
-
*
|
|
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(
|
|
206
|
-
* const post = useServerData(
|
|
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>(
|
|
210
|
-
const cacheKey =
|
|
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
|
|
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
|
-
//
|
|
236
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
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, '<');
|
|
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
|
};
|
package/src/types/hadars.ts
CHANGED
|
@@ -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
|
/**
|
package/src/utils/Head.tsx
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
let
|
|
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
|
-
|
|
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
|
|
192
|
-
*
|
|
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(
|
|
206
|
-
* const post = useServerData(
|
|
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>(
|
|
210
|
-
const cacheKey =
|
|
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
|
|
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
|
-
//
|
|
236
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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/src/utils/loader.ts
CHANGED
|
@@ -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(
|
|
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 <
|
|
106
|
-
const fnArg = args[
|
|
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(
|
|
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
|
|
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
|