hadars 0.1.33 → 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
@@ -1181,7 +1181,8 @@ var getConfigBase = (mode, isServerBuild = false) => {
1181
1181
  // Transforms loadModule('./path') based on build target.
1182
1182
  // Runs before swc-loader (loaders execute right-to-left).
1183
1183
  {
1184
- loader: loaderPath
1184
+ loader: loaderPath,
1185
+ options: { server: isServerBuild }
1185
1186
  },
1186
1187
  {
1187
1188
  loader: "builtin:swc-loader",
@@ -1212,7 +1213,8 @@ var getConfigBase = (mode, isServerBuild = false) => {
1212
1213
  exclude: [loaderPath],
1213
1214
  use: [
1214
1215
  {
1215
- loader: loaderPath
1216
+ loader: loaderPath,
1217
+ options: { server: isServerBuild }
1216
1218
  },
1217
1219
  {
1218
1220
  loader: "builtin:swc-loader",
@@ -2206,6 +2208,13 @@ var dev = async (options) => {
2206
2208
  getFinalProps
2207
2209
  }
2208
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
+ }
2209
2218
  return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
2210
2219
  } catch (err) {
2211
2220
  console.error("[hadars] SSR render error:", err);
@@ -2342,7 +2351,7 @@ var run = async (options) => {
2342
2351
  getAfterRenderProps,
2343
2352
  getFinalProps
2344
2353
  } = await import(componentPath);
2345
- if (renderPool) {
2354
+ if (renderPool && request.headers.get("Accept") !== "application/json") {
2346
2355
  const serialReq = await serializeRequest(request);
2347
2356
  const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
2348
2357
  const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
@@ -2360,6 +2369,13 @@ var run = async (options) => {
2360
2369
  getFinalProps
2361
2370
  }
2362
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
+ }
2363
2379
  return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
2364
2380
  } catch (err) {
2365
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")
package/dist/loader.cjs CHANGED
@@ -22,7 +22,8 @@ __export(loader_exports, {
22
22
  });
23
23
  module.exports = __toCommonJS(loader_exports);
24
24
  function loader(source) {
25
- const isServer = this.target === "node" || this.target === "async-node";
25
+ const opts = this.getOptions?.() ?? {};
26
+ const isServer = typeof opts.server === "boolean" ? opts.server : this.target === "node" || this.target === "async-node";
26
27
  const resourcePath = this.resourcePath ?? this.resource ?? "(unknown)";
27
28
  let swc;
28
29
  try {
@@ -51,7 +52,22 @@ function swcTransform(swc, source, isServer, resourcePath) {
51
52
  if (node.type !== "CallExpression")
52
53
  return;
53
54
  const callee = node.callee;
54
- if (!callee || callee.type !== "Identifier" || callee.value !== "loadModule")
55
+ if (!callee || callee.type !== "Identifier")
56
+ return;
57
+ const name = callee.value;
58
+ if (!isServer && name === "useServerData") {
59
+ const args2 = node.arguments;
60
+ if (!args2 || args2.length < 2)
61
+ return;
62
+ const fnArg = args2[1].expression ?? args2[1];
63
+ replacements.push({
64
+ start: fnArg.span.start - fileOffset,
65
+ end: fnArg.span.end - fileOffset,
66
+ replacement: "()=>undefined"
67
+ });
68
+ return;
69
+ }
70
+ if (name !== "loadModule")
55
71
  return;
56
72
  const args = node.arguments;
57
73
  if (!args || args.length === 0)
@@ -136,11 +152,87 @@ function countLeadingNonCodeBytes(source) {
136
152
  }
137
153
  const LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
138
154
  const DYNAMIC_LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
155
+ function scanExpressionEnd(source, pos) {
156
+ let depth = 0;
157
+ let i = pos;
158
+ while (i < source.length) {
159
+ const ch = source[i];
160
+ if (ch === "(" || ch === "[" || ch === "{") {
161
+ depth++;
162
+ i++;
163
+ continue;
164
+ }
165
+ if (ch === ")" || ch === "]" || ch === "}") {
166
+ if (depth === 0)
167
+ break;
168
+ depth--;
169
+ i++;
170
+ continue;
171
+ }
172
+ if (ch === "," && depth === 0)
173
+ break;
174
+ if (ch === '"' || ch === "'" || ch === "`") {
175
+ const q = ch;
176
+ i++;
177
+ while (i < source.length && source[i] !== q) {
178
+ if (source[i] === "\\")
179
+ i++;
180
+ i++;
181
+ }
182
+ i++;
183
+ continue;
184
+ }
185
+ if (ch === "/" && source[i + 1] === "/") {
186
+ while (i < source.length && source[i] !== "\n")
187
+ i++;
188
+ continue;
189
+ }
190
+ if (ch === "/" && source[i + 1] === "*") {
191
+ i += 2;
192
+ while (i + 1 < source.length && !(source[i] === "*" && source[i + 1] === "/"))
193
+ i++;
194
+ i += 2;
195
+ continue;
196
+ }
197
+ i++;
198
+ }
199
+ return i;
200
+ }
201
+ function stripUseServerDataFns(source) {
202
+ const CALL_RE = /\buseServerData\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
203
+ let result = "";
204
+ let lastIndex = 0;
205
+ let match;
206
+ CALL_RE.lastIndex = 0;
207
+ while ((match = CALL_RE.exec(source)) !== null) {
208
+ const callStart = match.index;
209
+ let i = match.index + match[0].length;
210
+ while (i < source.length && /\s/.test(source[i]))
211
+ i++;
212
+ i = scanExpressionEnd(source, i);
213
+ if (i >= source.length || source[i] !== ",")
214
+ continue;
215
+ i++;
216
+ while (i < source.length && /\s/.test(source[i]))
217
+ i++;
218
+ const fnStart = i;
219
+ const fnEnd = scanExpressionEnd(source, i);
220
+ if (fnEnd <= fnStart)
221
+ continue;
222
+ result += source.slice(lastIndex, fnStart) + "()=>undefined";
223
+ lastIndex = fnEnd;
224
+ CALL_RE.lastIndex = fnEnd;
225
+ }
226
+ return lastIndex === 0 ? source : result + source.slice(lastIndex);
227
+ }
139
228
  function regexTransform(source, isServer, resourcePath) {
140
- const transformed = source.replace(
229
+ let transformed = source.replace(
141
230
  LOAD_MODULE_RE,
142
231
  (_match, quote, modulePath) => isServer ? `Promise.resolve(require(${quote}${modulePath}${quote}))` : `import(${quote}${modulePath}${quote})`
143
232
  );
233
+ if (!isServer) {
234
+ transformed = stripUseServerDataFns(transformed);
235
+ }
144
236
  let match;
145
237
  DYNAMIC_LOAD_MODULE_RE.lastIndex = 0;
146
238
  while ((match = DYNAMIC_LOAD_MODULE_RE.exec(transformed)) !== null) {
package/dist/ssr-watch.js CHANGED
@@ -52,7 +52,8 @@ var getConfigBase = (mode, isServerBuild = false) => {
52
52
  // Transforms loadModule('./path') based on build target.
53
53
  // Runs before swc-loader (loaders execute right-to-left).
54
54
  {
55
- loader: loaderPath
55
+ loader: loaderPath,
56
+ options: { server: isServerBuild }
56
57
  },
57
58
  {
58
59
  loader: "builtin:swc-loader",
@@ -83,7 +84,8 @@ var getConfigBase = (mode, isServerBuild = false) => {
83
84
  exclude: [loaderPath],
84
85
  use: [
85
86
  {
86
- loader: loaderPath
87
+ loader: loaderPath,
88
+ options: { server: isServerBuild }
87
89
  },
88
90
  {
89
91
  loader: "builtin:swc-loader",
@@ -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.33",
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;
@@ -1,34 +1,49 @@
1
1
  /**
2
- * Rspack/webpack loader that transforms `loadModule('path')` calls based on
3
- * the compilation target:
2
+ * Rspack/webpack loader that applies two source-level transforms based on the
3
+ * compilation target (web vs node):
4
4
  *
5
+ * ── loadModule('path') ────────────────────────────────────────────────────────
5
6
  * - web (browser): replaced with `import('./path')` — rspack treats this as
6
7
  * a true dynamic import and splits the module into a separate chunk.
7
- *
8
8
  * - node (SSR): replaced with `Promise.resolve(require('./path'))` —
9
- * rspack bundles the module statically so it is always available
10
- * synchronously on the server, wrapped in Promise.resolve to keep the
11
- * API shape identical to the client side.
9
+ * bundled statically, wrapped in Promise.resolve to keep the API shape.
10
+ *
11
+ * ── useServerData(key, fn) ───────────────────────────────────────────────────
12
+ * - web (browser): the second argument `fn` is replaced with `()=>undefined`.
13
+ * `fn` is a server-only callback that may reference internal endpoints,
14
+ * credentials, or other sensitive information. It is never called in the
15
+ * browser (the hook returns the SSR-cached value immediately), but without
16
+ * this transform it would still be compiled into the client bundle — exposing
17
+ * those details to anyone who inspects the JS. Stripping it at bundle time
18
+ * prevents the leak entirely.
19
+ * - node (SSR): kept as-is — the real fn is needed to fetch data.
12
20
  *
13
21
  * Transformation strategy:
14
22
  * Primary — SWC AST parsing via @swc/core. Handles any valid TS/JS syntax
15
23
  * including arbitrarily-nested generics, comments, and string
16
- * literals that contain the text "loadModule".
17
- * Fallback — Regex transform used when @swc/core is unavailable.
24
+ * literals that contain the function names.
25
+ * Fallback — Scanner-based transform used when @swc/core is unavailable.
18
26
  *
19
- * Example usage:
27
+ * Example:
20
28
  *
21
- * import { loadModule } from 'hadars';
29
+ * // Source (shared component):
30
+ * const user = useServerData('user', () => db.getUser(req.userId));
22
31
  *
23
- * // Code-split React component (wrap with React.lazy + Suspense):
24
- * const MyComp = React.lazy(() => loadModule('./MyComp'));
32
+ * // Client bundle after transform:
33
+ * const user = useServerData('user', ()=>undefined);
25
34
  *
26
- * // Dynamic module load:
27
- * const { default: fn } = await loadModule('./heavyUtil');
35
+ * // Server bundle (unchanged):
36
+ * const user = useServerData('user', () => db.getUser(req.userId));
28
37
  */
29
38
 
30
39
  export default function loader(this: any, source: string): string {
31
- const isServer = this.target === 'node' || this.target === 'async-node';
40
+ // Prefer the explicit `server` option injected by rspack.ts over the legacy
41
+ // `this.target` heuristic (which is unreliable when `target` is not set in
42
+ // the rspack config — rspack then reports 'web' for every build).
43
+ const opts = this.getOptions?.() ?? {};
44
+ const isServer: boolean = (typeof opts.server === 'boolean')
45
+ ? opts.server
46
+ : (this.target === 'node' || this.target === 'async-node');
32
47
  const resourcePath: string = this.resourcePath ?? this.resource ?? '(unknown)';
33
48
 
34
49
  let swc: any;
@@ -80,7 +95,26 @@ function swcTransform(this: any, swc: any, source: string, isServer: boolean, re
80
95
  if (node.type !== 'CallExpression') return;
81
96
 
82
97
  const callee = node.callee;
83
- if (!callee || callee.type !== 'Identifier' || callee.value !== 'loadModule') return;
98
+ if (!callee || callee.type !== 'Identifier') return;
99
+
100
+ const name: string = callee.value;
101
+
102
+ // ── useServerData(key, fn) — strip fn on client builds ────────────────
103
+ if (!isServer && name === 'useServerData') {
104
+ const args: any[] = node.arguments;
105
+ if (!args || args.length < 2) return;
106
+ const fnArg = args[1].expression ?? args[1];
107
+ // Normalise to 0-based local byte offsets and replace with stub.
108
+ replacements.push({
109
+ start: fnArg.span.start - fileOffset,
110
+ end: fnArg.span.end - fileOffset,
111
+ replacement: '()=>undefined',
112
+ });
113
+ return;
114
+ }
115
+
116
+ // ── loadModule(path) ─────────────────────────────────────────────────
117
+ if (name !== 'loadModule') return;
84
118
 
85
119
  const args: any[] = node.arguments;
86
120
  if (!args || args.length === 0) return;
@@ -199,13 +233,99 @@ const LOAD_MODULE_RE =
199
233
  // (i.e. a dynamic / non-literal path argument).
200
234
  const DYNAMIC_LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
201
235
 
236
+ /**
237
+ * Scan forward from `pos` in `source`, skipping over a balanced JS expression
238
+ * (handles nested parens/brackets/braces and string literals).
239
+ * Returns the index of the first character AFTER the expression
240
+ * (i.e. the position of the trailing `,` or `)` at depth 0).
241
+ */
242
+ function scanExpressionEnd(source: string, pos: number): number {
243
+ let depth = 0;
244
+ let i = pos;
245
+ while (i < source.length) {
246
+ const ch = source[i]!;
247
+ if (ch === '(' || ch === '[' || ch === '{') { depth++; i++; continue; }
248
+ if (ch === ')' || ch === ']' || ch === '}') {
249
+ if (depth === 0) break; // end of expression — closing delimiter of outer call
250
+ depth--; i++; continue;
251
+ }
252
+ if (ch === ',' && depth === 0) break; // end of expression — next argument
253
+ // String / template literals
254
+ if (ch === '"' || ch === "'" || ch === '`') {
255
+ const q = ch; i++;
256
+ while (i < source.length && source[i] !== q) {
257
+ if (source[i] === '\\') i++; // escape sequence
258
+ i++;
259
+ }
260
+ i++; // closing quote
261
+ continue;
262
+ }
263
+ // Line comment
264
+ if (ch === '/' && source[i + 1] === '/') {
265
+ while (i < source.length && source[i] !== '\n') i++;
266
+ continue;
267
+ }
268
+ // Block comment
269
+ if (ch === '/' && source[i + 1] === '*') {
270
+ i += 2;
271
+ while (i + 1 < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++;
272
+ i += 2;
273
+ continue;
274
+ }
275
+ i++;
276
+ }
277
+ return i;
278
+ }
279
+
280
+ /**
281
+ * Strip the `fn` argument from `useServerData(key, fn)` calls in client builds.
282
+ * Uses a character-level scanner to handle arbitrary fn expressions (arrow
283
+ * functions with nested calls, async functions, object literals, etc.).
284
+ */
285
+ function stripUseServerDataFns(source: string): string {
286
+ // Match `useServerData` + optional generic + opening paren
287
+ const CALL_RE = /\buseServerData\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
288
+ let result = '';
289
+ let lastIndex = 0;
290
+ let match: RegExpExecArray | null;
291
+ CALL_RE.lastIndex = 0;
292
+ while ((match = CALL_RE.exec(source)) !== null) {
293
+ const callStart = match.index;
294
+ let i = match.index + match[0].length;
295
+ // Skip whitespace before first arg
296
+ while (i < source.length && /\s/.test(source[i]!)) i++;
297
+ // Skip first argument (key: string or array)
298
+ i = scanExpressionEnd(source, i);
299
+ // Expect comma separator
300
+ if (i >= source.length || source[i] !== ',') continue;
301
+ i++; // skip comma
302
+ // Skip whitespace before fn
303
+ while (i < source.length && /\s/.test(source[i]!)) i++;
304
+ const fnStart = i;
305
+ // Scan to end of fn argument
306
+ const fnEnd = scanExpressionEnd(source, i);
307
+ if (fnEnd <= fnStart) continue;
308
+ // Emit everything up to fn, then the stub, skip the original fn
309
+ result += source.slice(lastIndex, fnStart) + '()=>undefined';
310
+ lastIndex = fnEnd;
311
+ // Advance regex past this call to avoid re-matching
312
+ CALL_RE.lastIndex = fnEnd;
313
+ }
314
+ return lastIndex === 0 ? source : result + source.slice(lastIndex);
315
+ }
316
+
202
317
  function regexTransform(this: any, source: string, isServer: boolean, resourcePath: string): string {
203
- const transformed = source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) =>
318
+ let transformed = source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) =>
204
319
  isServer
205
320
  ? `Promise.resolve(require(${quote}${modulePath}${quote}))`
206
321
  : `import(${quote}${modulePath}${quote})`
207
322
  );
208
323
 
324
+ // Strip server-only fn arguments from useServerData on client builds.
325
+ if (!isServer) {
326
+ transformed = stripUseServerDataFns(transformed);
327
+ }
328
+
209
329
  // Warn for any remaining dynamic calls
210
330
  let match: RegExpExecArray | null;
211
331
  DYNAMIC_LOAD_MODULE_RE.lastIndex = 0;
@@ -59,6 +59,7 @@ const getConfigBase = (mode: "development" | "production", isServerBuild = false
59
59
  // Runs before swc-loader (loaders execute right-to-left).
60
60
  {
61
61
  loader: loaderPath,
62
+ options: { server: isServerBuild },
62
63
  },
63
64
  {
64
65
  loader: 'builtin:swc-loader',
@@ -90,6 +91,7 @@ const getConfigBase = (mode: "development" | "production", isServerBuild = false
90
91
  use: [
91
92
  {
92
93
  loader: loaderPath,
94
+ options: { server: isServerBuild },
93
95
  },
94
96
  {
95
97
  loader: 'builtin:swc-loader',