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 +3 -2
- package/dist/cli.js +15 -1
- package/dist/index.cjs +79 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +79 -1
- package/dist/utils/Head.tsx +137 -4
- package/package.json +1 -1
- package/src/build.ts +24 -1
- package/src/utils/Head.tsx +137 -4
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
|
-
|
|
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
|
-
|
|
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")
|
package/dist/utils/Head.tsx
CHANGED
|
@@ -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
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
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
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);
|
package/src/utils/Head.tsx
CHANGED
|
@@ -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
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
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;
|