@rangojs/router 0.0.0-experimental.19 → 0.0.0-experimental.1b930379

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.
Files changed (84) hide show
  1. package/README.md +46 -12
  2. package/dist/bin/rango.js +109 -15
  3. package/dist/vite/index.js +323 -121
  4. package/package.json +15 -16
  5. package/skills/breadcrumbs/SKILL.md +250 -0
  6. package/skills/caching/SKILL.md +4 -4
  7. package/skills/document-cache/SKILL.md +2 -2
  8. package/skills/hooks/SKILL.md +33 -31
  9. package/skills/host-router/SKILL.md +218 -0
  10. package/skills/loader/SKILL.md +55 -15
  11. package/skills/prerender/SKILL.md +2 -2
  12. package/skills/rango/SKILL.md +0 -1
  13. package/skills/route/SKILL.md +3 -4
  14. package/skills/router-setup/SKILL.md +8 -3
  15. package/skills/typesafety/SKILL.md +25 -23
  16. package/src/__internal.ts +92 -0
  17. package/src/bin/rango.ts +18 -0
  18. package/src/browser/link-interceptor.ts +4 -0
  19. package/src/browser/navigation-bridge.ts +95 -5
  20. package/src/browser/navigation-client.ts +97 -72
  21. package/src/browser/prefetch/cache.ts +112 -25
  22. package/src/browser/prefetch/fetch.ts +28 -30
  23. package/src/browser/prefetch/policy.ts +6 -0
  24. package/src/browser/react/Link.tsx +19 -7
  25. package/src/browser/rsc-router.tsx +11 -2
  26. package/src/browser/server-action-bridge.ts +448 -432
  27. package/src/browser/types.ts +24 -0
  28. package/src/build/generate-route-types.ts +2 -0
  29. package/src/build/route-trie.ts +19 -3
  30. package/src/build/route-types/router-processing.ts +125 -15
  31. package/src/client.rsc.tsx +2 -1
  32. package/src/client.tsx +1 -46
  33. package/src/handles/breadcrumbs.ts +66 -0
  34. package/src/handles/index.ts +1 -0
  35. package/src/host/index.ts +0 -3
  36. package/src/index.rsc.ts +5 -36
  37. package/src/index.ts +32 -66
  38. package/src/prerender/store.ts +56 -15
  39. package/src/route-definition/index.ts +0 -3
  40. package/src/router/handler-context.ts +30 -3
  41. package/src/router/loader-resolution.ts +1 -1
  42. package/src/router/match-api.ts +1 -1
  43. package/src/router/match-result.ts +0 -9
  44. package/src/router/metrics.ts +233 -13
  45. package/src/router/middleware-types.ts +53 -10
  46. package/src/router/middleware.ts +170 -81
  47. package/src/router/pattern-matching.ts +20 -5
  48. package/src/router/prerender-match.ts +4 -0
  49. package/src/router/revalidation.ts +27 -7
  50. package/src/router/router-interfaces.ts +14 -1
  51. package/src/router/router-options.ts +13 -8
  52. package/src/router/segment-resolution/fresh.ts +18 -0
  53. package/src/router/segment-resolution/helpers.ts +1 -1
  54. package/src/router/segment-resolution/revalidation.ts +22 -9
  55. package/src/router/trie-matching.ts +20 -2
  56. package/src/router.ts +29 -9
  57. package/src/rsc/handler.ts +106 -11
  58. package/src/rsc/index.ts +0 -20
  59. package/src/rsc/progressive-enhancement.ts +21 -8
  60. package/src/rsc/rsc-rendering.ts +30 -43
  61. package/src/rsc/server-action.ts +14 -10
  62. package/src/rsc/ssr-setup.ts +128 -0
  63. package/src/rsc/types.ts +2 -0
  64. package/src/search-params.ts +16 -13
  65. package/src/server/context.ts +8 -2
  66. package/src/server/request-context.ts +38 -16
  67. package/src/server.ts +6 -0
  68. package/src/theme/index.ts +4 -13
  69. package/src/types/handler-context.ts +12 -16
  70. package/src/types/route-config.ts +17 -8
  71. package/src/types/segments.ts +0 -5
  72. package/src/vite/discovery/bundle-postprocess.ts +31 -56
  73. package/src/vite/discovery/discover-routers.ts +18 -4
  74. package/src/vite/discovery/prerender-collection.ts +34 -14
  75. package/src/vite/discovery/state.ts +4 -7
  76. package/src/vite/index.ts +4 -3
  77. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  78. package/src/vite/plugins/refresh-cmd.ts +65 -0
  79. package/src/vite/rango.ts +11 -0
  80. package/src/vite/router-discovery.ts +16 -0
  81. package/src/vite/utils/prerender-utils.ts +60 -0
  82. package/skills/testing/SKILL.md +0 -226
  83. package/src/route-definition/route-function.ts +0 -119
  84. /package/{CLAUDE.md → AGENTS.md} +0 -0
@@ -20,6 +20,7 @@ import type {
20
20
  } from "./middleware-types.js";
21
21
  import { _getRequestContext } from "../server/request-context.js";
22
22
  import { isAutoGeneratedRouteName } from "../route-name.js";
23
+ import { appendMetric, createMetricsStore } from "./metrics.js";
23
24
 
24
25
  // Re-export types and cookie utilities for backward compatibility
25
26
  export type {
@@ -33,25 +34,29 @@ export type {
33
34
  } from "./middleware-types.js";
34
35
  export { parseCookies, serializeCookie } from "./middleware-cookies.js";
35
36
 
36
- // W5: Deduplicate by function reference so each distinct middleware warns once,
37
- // regardless of whether it is named or anonymous.
38
- let warnedRedirectMiddleware = new WeakSet<Function>();
39
-
40
- function warnCtxSetBeforeRedirect(handler: Function): void {
41
- if (warnedRedirectMiddleware.has(handler)) return;
42
- warnedRedirectMiddleware.add(handler);
43
- const label = handler.name || "(anonymous)";
44
- console.warn(
45
- `[rango] Route middleware "${label}" called ctx.set() then returned a ` +
46
- `redirect. Context variables are per-request and won't be available ` +
47
- `on the redirect target. Use cookies to persist state across ` +
48
- `redirects, or move ctx.set() to the target route's middleware.`,
49
- );
37
+ const MIDDLEWARE_METRIC_DEPTH = 1;
38
+ /** Ignore post-next() durations below this threshold (measurement noise). */
39
+ const POST_METRIC_MIN_DURATION_MS = 0.01;
40
+
41
+ function getMiddlewareMetricBase<TEnv>(
42
+ entry: MiddlewareEntry<TEnv>,
43
+ ordinal: number,
44
+ ): string {
45
+ const handlerName = entry.handler.name?.trim();
46
+ const scope = entry.pattern ?? "*";
47
+
48
+ if (handlerName) {
49
+ return `${handlerName}@${scope}`;
50
+ }
51
+
52
+ return `${scope}#${ordinal + 1}`;
50
53
  }
51
54
 
52
- /** Reset W5 deduplication state (for tests only). */
53
- export function _resetW5Warnings(): void {
54
- warnedRedirectMiddleware = new WeakSet();
55
+ function getMiddlewareMetricLabel<TEnv>(
56
+ entry: MiddlewareEntry<TEnv>,
57
+ ordinal: number,
58
+ ): string {
59
+ return `middleware:${getMiddlewareMetricBase(entry, ordinal)}`;
55
60
  }
56
61
 
57
62
  /**
@@ -158,9 +163,25 @@ export function createMiddlewareContext<TEnv>(
158
163
  // Cookie operations are handled by the standalone cookies() function which
159
164
  // delegates to the shared RequestContext internally.
160
165
  // The runtime implementation - types are enforced at call sites via MiddlewareContext<TEnv>
166
+ // Internal helper: resolve the current response (stub before next(), real after).
167
+ // Not exposed on the public MiddlewareContext type — use ctx.headers instead.
168
+ const getResponse = (): Response => {
169
+ if (isPreNext()) {
170
+ const reqCtx = _getRequestContext();
171
+ if (reqCtx) return reqCtx.res;
172
+ }
173
+ if (!responseHolder.response) {
174
+ throw new Error(
175
+ "Response is not available - responseHolder was not initialized",
176
+ );
177
+ }
178
+ return responseHolder.response;
179
+ };
180
+
161
181
  return {
162
182
  request,
163
183
  url,
184
+ originalUrl: new URL(request.url),
164
185
  pathname: url.pathname,
165
186
  searchParams: url.searchParams,
166
187
  env: env as MiddlewareContext<TEnv>["env"],
@@ -175,24 +196,8 @@ export function createMiddlewareContext<TEnv>(
175
196
  ) as MiddlewareContext<TEnv>["routeName"];
176
197
  },
177
198
 
178
- get res(): Response {
179
- // Before next(): return shared RequestContext stub so headers
180
- // set via ctx.header() are visible on ctx.res.
181
- if (isPreNext()) {
182
- const reqCtx = _getRequestContext();
183
- if (reqCtx) return reqCtx.res;
184
- }
185
- if (!responseHolder.response) {
186
- throw new Error(
187
- "ctx.res is not available - responseHolder was not initialized",
188
- );
189
- }
190
- return responseHolder.response;
191
- },
192
- set res(_: Response) {
193
- throw new Error(
194
- "ctx.res is read-only. Use ctx.header() to set response headers, or cookies() for cookie mutations.",
195
- );
199
+ get headers(): Headers {
200
+ return getResponse().headers;
196
201
  },
197
202
 
198
203
  get: ((keyOrVar: any) =>
@@ -202,6 +207,8 @@ export function createMiddlewareContext<TEnv>(
202
207
  contextSet(variables, keyOrVar, value);
203
208
  }) as MiddlewareContext<TEnv>["set"],
204
209
 
210
+ var: variables as MiddlewareContext<TEnv>["var"],
211
+
205
212
  header(name: string, value: string): void {
206
213
  // Before next(): delegate to shared RequestContext stub
207
214
  if (isPreNext()) {
@@ -220,6 +227,24 @@ export function createMiddlewareContext<TEnv>(
220
227
  responseHolder.response.headers.set(name, value);
221
228
  },
222
229
 
230
+ get theme(): MiddlewareContext<TEnv>["theme"] {
231
+ return _getRequestContext()?.theme;
232
+ },
233
+
234
+ get setTheme(): MiddlewareContext<TEnv>["setTheme"] {
235
+ return _getRequestContext()?.setTheme;
236
+ },
237
+
238
+ setLocationState(entries) {
239
+ const reqCtx = _getRequestContext();
240
+ if (!reqCtx) {
241
+ throw new Error(
242
+ "setLocationState() is not available outside a request context",
243
+ );
244
+ }
245
+ reqCtx.setLocationState(entries);
246
+ },
247
+
223
248
  reverse:
224
249
  reverse ??
225
250
  ((name: string) => {
@@ -227,6 +252,14 @@ export function createMiddlewareContext<TEnv>(
227
252
  `ctx.reverse() is not available - route map was not provided to middleware context`,
228
253
  );
229
254
  }),
255
+
256
+ debugPerformance(): void {
257
+ const reqCtx = _getRequestContext();
258
+ if (reqCtx) {
259
+ reqCtx._debugPerformance = true;
260
+ reqCtx._metricsStore ??= createMetricsStore(true);
261
+ }
262
+ },
230
263
  };
231
264
  }
232
265
 
@@ -265,9 +298,9 @@ export function matchMiddleware<TEnv>(
265
298
  *
266
299
  * Features:
267
300
  * - `await next()` returns actual Response
268
- * - `ctx.res` available after `await next()` (like Hono's `c.res`)
269
- * - `ctx.header()` shorthand for setting headers
270
- * - Forgiving: if middleware doesn't return, uses `ctx.res`
301
+ * - `ctx.headers` available before and after `await next()`
302
+ * - `ctx.header()` shorthand for setting a single header
303
+ * - Forgiving: if middleware doesn't return, uses the downstream response
271
304
  * - Short-circuit: return Response to stop chain
272
305
  * - Error catching: try/catch around `next()` works
273
306
  */
@@ -309,14 +342,21 @@ export async function executeMiddleware<TEnv>(
309
342
  }
310
343
  });
311
344
  // Also merge shared RequestContext stub (cookies written via cookies().set()).
312
- // Set-Cookie duplication is prevented by createResponseWithMergedHeaders
313
- // draining Set-Cookie from ctx.res after merging (helpers.ts).
345
+ // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
346
+ // may have already merged the same reqCtx cookies into the response.
314
347
  const reqCtx = _getRequestContext();
315
348
  if (reqCtx) {
349
+ const stubCookies = reqCtx.res.headers.getSetCookie();
350
+ if (stubCookies.length > 0) {
351
+ const existing = new Set(mergedHeaders.getSetCookie());
352
+ for (const cookie of stubCookies) {
353
+ if (!existing.has(cookie)) {
354
+ mergedHeaders.append("set-cookie", cookie);
355
+ }
356
+ }
357
+ }
316
358
  reqCtx.res.headers.forEach((value, name) => {
317
- if (name.toLowerCase() === "set-cookie") {
318
- mergedHeaders.append(name, value);
319
- } else if (!mergedHeaders.has(name)) {
359
+ if (name !== "set-cookie" && !mergedHeaders.has(name)) {
320
360
  mergedHeaders.set(name, value);
321
361
  }
322
362
  });
@@ -332,6 +372,7 @@ export async function executeMiddleware<TEnv>(
332
372
  return responseHolder.response;
333
373
  }
334
374
 
375
+ const middlewareOrdinal = index;
335
376
  const { entry, params } = middlewares[index++];
336
377
  const ctx = createMiddlewareContext(
337
378
  request,
@@ -341,48 +382,77 @@ export async function executeMiddleware<TEnv>(
341
382
  responseHolder,
342
383
  reverse,
343
384
  );
385
+ const metricStart = performance.now();
386
+ const metricLabel = getMiddlewareMetricLabel(entry, middlewareOrdinal);
387
+ let middlewareFinished = false;
388
+ const finishMiddleware = () => {
389
+ if (!middlewareFinished) {
390
+ middlewareFinished = true;
391
+ appendMetric(
392
+ _getRequestContext()?._metricsStore,
393
+ `${metricLabel}:pre`,
394
+ metricStart,
395
+ performance.now() - metricStart,
396
+ MIDDLEWARE_METRIC_DEPTH,
397
+ );
398
+ }
399
+ };
344
400
 
345
401
  // Track if next() was called and capture its Promise.
346
402
  // Guard against double-calling: a second call would re-enter the
347
403
  // downstream chain and overwrite responseHolder.response.
348
404
  let nextPromise: Promise<Response> | null = null;
405
+ let nextResolvedAt: number | undefined;
349
406
  const wrappedNext = (): Promise<Response> => {
350
407
  if (nextPromise) {
351
408
  throw new Error(
352
409
  `[@rangojs/router] Middleware called next() more than once.`,
353
410
  );
354
411
  }
355
- nextPromise = next();
412
+ finishMiddleware();
413
+ const downstream = next();
414
+ nextPromise = downstream.then(
415
+ (res) => {
416
+ nextResolvedAt = performance.now();
417
+ return res;
418
+ },
419
+ (err) => {
420
+ nextResolvedAt = performance.now();
421
+ throw err;
422
+ },
423
+ );
356
424
  return nextPromise;
357
425
  };
358
426
 
359
- // W5: track whether ctx.set() is called during this middleware
360
- let ctxSetCalled = false;
361
- if (process.env.NODE_ENV !== "production") {
362
- const originalSet = ctx.set;
363
- ctx.set = ((...args: any[]) => {
364
- ctxSetCalled = true;
365
- return (originalSet as Function).apply(ctx, args);
366
- }) as typeof ctx.set;
427
+ let result: Response | void;
428
+ try {
429
+ result = await entry.handler(ctx, wrappedNext);
430
+ } catch (error) {
431
+ finishMiddleware();
432
+ throw error;
433
+ }
434
+ finishMiddleware();
435
+
436
+ // Record post-next() processing time when middleware did work after
437
+ // the downstream chain resolved (e.g. adding headers, logging).
438
+ if (nextResolvedAt !== undefined) {
439
+ const postDur = performance.now() - nextResolvedAt;
440
+ if (postDur > POST_METRIC_MIN_DURATION_MS) {
441
+ appendMetric(
442
+ _getRequestContext()?._metricsStore,
443
+ `${metricLabel}:post`,
444
+ nextResolvedAt,
445
+ postDur,
446
+ MIDDLEWARE_METRIC_DEPTH,
447
+ );
448
+ }
367
449
  }
368
-
369
- const result = await entry.handler(ctx, wrappedNext);
370
450
 
371
451
  // Explicit return takes precedence (middleware short-circuit).
372
452
  // Merge stub headers (from ctx.header before this point) and
373
453
  // RequestContext stub headers (from ctx.setCookie) into the
374
454
  // returned Response so they are not lost.
375
455
  if (result instanceof Response) {
376
- // W5: warn if ctx.set() was called but middleware returned a redirect
377
- if (
378
- process.env.NODE_ENV !== "production" &&
379
- ctxSetCalled &&
380
- result.status >= 300 &&
381
- result.status < 400
382
- ) {
383
- warnCtxSetBeforeRedirect(entry.handler);
384
- }
385
-
386
456
  const mergedHeaders = new Headers(result.headers);
387
457
  stubResponse.headers.forEach((value, name) => {
388
458
  if (name.toLowerCase() === "set-cookie") {
@@ -391,13 +461,22 @@ export async function executeMiddleware<TEnv>(
391
461
  mergedHeaders.set(name, value);
392
462
  }
393
463
  });
394
- // Also merge shared RequestContext stub (cookies written via setCookie)
464
+ // Also merge shared RequestContext stub (cookies written via setCookie).
465
+ // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
466
+ // may have already merged the same reqCtx cookies into the response.
395
467
  const reqCtx = _getRequestContext();
396
468
  if (reqCtx) {
469
+ const stubCookies = reqCtx.res.headers.getSetCookie();
470
+ if (stubCookies.length > 0) {
471
+ const existing = new Set(mergedHeaders.getSetCookie());
472
+ for (const cookie of stubCookies) {
473
+ if (!existing.has(cookie)) {
474
+ mergedHeaders.append("set-cookie", cookie);
475
+ }
476
+ }
477
+ }
397
478
  reqCtx.res.headers.forEach((value, name) => {
398
- if (name.toLowerCase() === "set-cookie") {
399
- mergedHeaders.append(name, value);
400
- } else if (!mergedHeaders.has(name)) {
479
+ if (name !== "set-cookie" && !mergedHeaders.has(name)) {
401
480
  mergedHeaders.set(name, value);
402
481
  }
403
482
  });
@@ -424,19 +503,6 @@ export async function executeMiddleware<TEnv>(
424
503
  // If middleware called next(), await it and return the response
425
504
  if (nextPromise) {
426
505
  await nextPromise;
427
-
428
- // W5: warn if ctx.set() was called but the downstream response is a redirect.
429
- // The ctx.set() values will be lost because the redirect navigates away.
430
- if (
431
- process.env.NODE_ENV !== "production" &&
432
- ctxSetCalled &&
433
- responseHolder.response &&
434
- responseHolder.response.status >= 300 &&
435
- responseHolder.response.status < 400
436
- ) {
437
- warnCtxSetBeforeRedirect(entry.handler);
438
- }
439
-
440
506
  return responseHolder.response!;
441
507
  }
442
508
 
@@ -459,6 +525,29 @@ export async function executeMiddleware<TEnv>(
459
525
  throw new Error("No response generated by middleware chain");
460
526
  }
461
527
 
528
+ // Final re-merge: capture any RequestContext stub headers added after the
529
+ // last merge point (e.g. cookies().set() called after await next()).
530
+ // The reqCtx stub may have already been partially merged during finalHandler
531
+ // or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
532
+ const reqCtx = _getRequestContext();
533
+ if (reqCtx) {
534
+ const stubCookies = reqCtx.res.headers.getSetCookie();
535
+ if (stubCookies.length > 0) {
536
+ const existingCookies = new Set(finalResponse.headers.getSetCookie());
537
+ for (const cookie of stubCookies) {
538
+ if (!existingCookies.has(cookie)) {
539
+ finalResponse.headers.append("set-cookie", cookie);
540
+ }
541
+ }
542
+ }
543
+ // Fill in non-cookie headers that aren't already on the response
544
+ reqCtx.res.headers.forEach((value, name) => {
545
+ if (name !== "set-cookie" && !finalResponse.headers.has(name)) {
546
+ finalResponse.headers.set(name, value);
547
+ }
548
+ });
549
+ }
550
+
462
551
  return finalResponse;
463
552
  }
464
553
 
@@ -16,6 +16,7 @@ export interface ParsedSegment {
16
16
  value: string; // static text, param name, or "*"
17
17
  optional: boolean;
18
18
  constraint?: string[]; // enum values like ["en", "gb"]
19
+ suffix?: string; // literal text after param in same segment (e.g., ".html")
19
20
  }
20
21
 
21
22
  /**
@@ -39,11 +40,21 @@ export function parsePattern(pattern: string): ParsedSegment[] {
39
40
  // - :param(a|b)?
40
41
  // - *
41
42
  const segmentRegex =
42
- /\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?|(\*)|([^/]+))/g;
43
+ /\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?([^/]*)|(\*)|([^/]+))/g;
43
44
 
44
45
  let match;
45
46
  while ((match = segmentRegex.exec(pattern)) !== null) {
46
- const [, , paramName, , constraint, optional, wildcard, staticText] = match;
47
+ const [
48
+ ,
49
+ ,
50
+ paramName,
51
+ ,
52
+ constraint,
53
+ optional,
54
+ suffix,
55
+ wildcard,
56
+ staticText,
57
+ ] = match;
47
58
 
48
59
  if (wildcard) {
49
60
  segments.push({ type: "wildcard", value: "*", optional: false });
@@ -53,6 +64,7 @@ export function parsePattern(pattern: string): ParsedSegment[] {
53
64
  value: paramName,
54
65
  optional: optional === "?",
55
66
  constraint: constraint ? constraint.split("|") : undefined,
67
+ suffix: suffix || undefined,
56
68
  });
57
69
  } else if (staticText) {
58
70
  segments.push({ type: "static", value: staticText, optional: false });
@@ -139,16 +151,19 @@ export function compilePattern(pattern: string): CompiledPattern {
139
151
  regexPattern += "/(.*)";
140
152
  } else if (segment.type === "param") {
141
153
  paramNames.push(segment.value);
154
+ const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
142
155
  const valuePattern = segment.constraint
143
156
  ? `(${segment.constraint.map(escapeRegex).join("|")})`
144
- : "([^/]+)";
157
+ : segment.suffix
158
+ ? "([^/]+?)"
159
+ : "([^/]+)";
145
160
 
146
161
  if (segment.optional) {
147
162
  optionalParams.add(segment.value);
148
163
  // Optional: make the whole /segment optional
149
- regexPattern += `(?:/${valuePattern})?`;
164
+ regexPattern += `(?:/${valuePattern}${suffixPattern})?`;
150
165
  } else {
151
- regexPattern += `/${valuePattern}`;
166
+ regexPattern += `/${valuePattern}${suffixPattern}`;
152
167
  }
153
168
  } else {
154
169
  // Static segment
@@ -101,6 +101,7 @@ export async function matchForPrerender<TEnv = any>(
101
101
  env: {} as TEnv,
102
102
  request: new Request("http://prerender" + pathname),
103
103
  url: new URL("http://prerender" + pathname),
104
+ originalUrl: new URL("http://prerender" + pathname),
104
105
  pathname,
105
106
  searchParams: new URLSearchParams(),
106
107
  var: variables,
@@ -116,6 +117,7 @@ export async function matchForPrerender<TEnv = any>(
116
117
  deleteCookie: () => {},
117
118
  header: () => {},
118
119
  setStatus: () => {},
120
+ _setStatus: () => {},
119
121
  use: (() => {
120
122
  throw new Error("use() not available during pre-rendering");
121
123
  }) as any,
@@ -331,6 +333,7 @@ export async function renderStaticSegment<TEnv = any>(
331
333
  env: {} as TEnv,
332
334
  request: syntheticRequest,
333
335
  url: syntheticUrl,
336
+ originalUrl: syntheticUrl,
334
337
  pathname: "/",
335
338
  searchParams: syntheticUrl.searchParams,
336
339
  var: {},
@@ -344,6 +347,7 @@ export async function renderStaticSegment<TEnv = any>(
344
347
  deleteCookie: () => {},
345
348
  header: () => {},
346
349
  setStatus: () => {},
350
+ _setStatus: () => {},
347
351
  use: (() => {
348
352
  throw new Error("use() not available during static pre-rendering");
349
353
  }) as any,
@@ -84,6 +84,7 @@ export async function evaluateRevalidation<TEnv>(
84
84
  } = options;
85
85
  const nextParams = segment.params || {};
86
86
  const paramsChanged = !paramsEqual(nextParams, prevParams);
87
+ const searchChanged = prevUrl.search !== nextUrl.search;
87
88
 
88
89
  // Trace helper: push a structured entry to the request-scoped trace buffer.
89
90
  // Guarded by isTraceActive() so object construction is skipped in production.
@@ -134,19 +135,38 @@ export async function evaluateRevalidation<TEnv>(
134
135
  // Only the route segment revalidates by default - all others require explicit opt-in
135
136
 
136
137
  if (segment.type === "route") {
137
- // Route segments revalidate when params change
138
- // Routes are the primary param-dependent content and always need updates
139
- defaultShouldRevalidate = paramsChanged;
138
+ // Route segments revalidate when path params OR search params change.
139
+ // Search params (e.g., ?page=2&sort=price) are server-parsed via ctx.search,
140
+ // so the handler must re-execute to produce updated content.
141
+ const routeChanged = paramsChanged || searchChanged;
142
+ defaultShouldRevalidate = routeChanged;
140
143
  defaultReason = paramsChanged
141
144
  ? "nav:params-changed"
142
- : "nav:params-unchanged";
143
- if (paramsChanged) {
144
- debugLog("revalidation", "route params changed, revalidating", {
145
+ : searchChanged
146
+ ? "nav:search-changed"
147
+ : "nav:params-unchanged";
148
+ if (routeChanged) {
149
+ debugLog("revalidation", "route revalidating", {
145
150
  segmentId: segment.id,
151
+ paramsChanged,
152
+ searchChanged,
146
153
  });
147
154
  }
155
+ } else if (segment.belongsToRoute && (paramsChanged || searchChanged)) {
156
+ // Children of the route path (loaders, orphan layouts/parallels)
157
+ // revalidate when path params or search params change
158
+ defaultShouldRevalidate = true;
159
+ defaultReason = paramsChanged
160
+ ? "nav:route-child-params-changed"
161
+ : "nav:route-child-search-changed";
162
+ debugLog("revalidation", "route child revalidating", {
163
+ segmentId: segment.id,
164
+ segmentType: segment.type,
165
+ paramsChanged,
166
+ searchChanged,
167
+ });
148
168
  } else {
149
- // Layouts and parallels default to no revalidation
169
+ // Parent layouts and parallels default to no revalidation
150
170
  // Cannot assume these segments depend on params without explicit declaration
151
171
  // Use custom revalidation functions to opt-in when needed
152
172
  defaultShouldRevalidate = false;
@@ -258,10 +258,17 @@ export interface RSCRouterInternal<
258
258
 
259
259
  /**
260
260
  * Cache-Control header value for prefetch responses.
261
- * False means no browser caching of prefetch responses.
261
+ * False means no caching of prefetch responses.
262
+ * Derived from prefetchCacheTTL.
262
263
  */
263
264
  readonly prefetchCacheControl: string | false;
264
265
 
266
+ /**
267
+ * TTL in milliseconds for the client-side in-memory prefetch cache.
268
+ * 0 means caching is disabled.
269
+ */
270
+ readonly prefetchCacheTTL: number;
271
+
265
272
  /**
266
273
  * Whether connection warmup is enabled.
267
274
  * When true, the client sends HEAD /?_rsc_warmup after idle periods
@@ -269,6 +276,12 @@ export interface RSCRouterInternal<
269
276
  */
270
277
  readonly warmupEnabled: boolean;
271
278
 
279
+ /**
280
+ * Whether router-wide performance debugging is enabled.
281
+ * Used by the request handler to create metrics before middleware runs.
282
+ */
283
+ readonly debugPerformance?: boolean;
284
+
272
285
  /**
273
286
  * Whether ?__debug_manifest is allowed in production.
274
287
  * Always enabled in development.
@@ -239,7 +239,7 @@ export interface RSCRouterOptions<TEnv = any> {
239
239
  *
240
240
  * @example Static config
241
241
  * ```typescript
242
- * import { MemorySegmentCacheStore } from "rsc-router/rsc";
242
+ * import { MemorySegmentCacheStore } from "@rangojs/router/cache";
243
243
  *
244
244
  * const router = createRouter({
245
245
  * cache: {
@@ -415,16 +415,21 @@ export interface RSCRouterOptions<TEnv = any> {
415
415
  version?: string;
416
416
 
417
417
  /**
418
- * Cache-Control header value for prefetch responses.
419
- * Only applied to non-intercept partial responses that include the
420
- * `X-Rango-Prefetch` header (sent by the Link component's prefetch fetch).
421
- * Navigation responses are never cached by the browser.
418
+ * TTL (in seconds) for the in-memory prefetch cache and the
419
+ * Cache-Control header on prefetch responses.
422
420
  *
423
- * Set to `false` to disable browser caching of prefetch responses entirely.
421
+ * Controls how long prefetch responses are kept in the client-side
422
+ * in-memory cache and sets `Cache-Control: private, max-age=<ttl>`
423
+ * on server responses for CDN/edge caching.
424
424
  *
425
- * @default "private, max-age=300"
425
+ * The cache is automatically invalidated on server actions regardless
426
+ * of TTL, so this is primarily a staleness safety net.
427
+ *
428
+ * Set to `false` to disable prefetch caching entirely.
429
+ *
430
+ * @default 300 (5 minutes)
426
431
  */
427
- prefetchCacheControl?: string | false;
432
+ prefetchCacheTTL?: number | false;
428
433
 
429
434
  /**
430
435
  * Enable connection warmup to keep TCP+TLS alive after idle periods.