@rangojs/router 0.0.0-experimental.27 → 0.0.0-experimental.289231ba

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 (160) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +78 -19
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +853 -435
  5. package/package.json +17 -16
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +32 -0
  8. package/skills/caching/SKILL.md +45 -4
  9. package/skills/handler-use/SKILL.md +362 -0
  10. package/skills/hooks/SKILL.md +22 -4
  11. package/skills/intercept/SKILL.md +20 -0
  12. package/skills/layout/SKILL.md +22 -0
  13. package/skills/links/SKILL.md +3 -1
  14. package/skills/loader/SKILL.md +71 -21
  15. package/skills/middleware/SKILL.md +34 -3
  16. package/skills/migrate-nextjs/SKILL.md +560 -0
  17. package/skills/migrate-react-router/SKILL.md +764 -0
  18. package/skills/parallel/SKILL.md +185 -0
  19. package/skills/prerender/SKILL.md +110 -68
  20. package/skills/rango/SKILL.md +24 -22
  21. package/skills/route/SKILL.md +56 -2
  22. package/skills/router-setup/SKILL.md +92 -2
  23. package/skills/typesafety/SKILL.md +33 -21
  24. package/src/__internal.ts +92 -0
  25. package/src/browser/app-version.ts +14 -0
  26. package/src/browser/event-controller.ts +5 -0
  27. package/src/browser/link-interceptor.ts +4 -0
  28. package/src/browser/navigation-bridge.ts +125 -16
  29. package/src/browser/navigation-client.ts +154 -44
  30. package/src/browser/navigation-store.ts +43 -8
  31. package/src/browser/navigation-transaction.ts +11 -9
  32. package/src/browser/partial-update.ts +94 -17
  33. package/src/browser/prefetch/cache.ts +176 -27
  34. package/src/browser/prefetch/fetch.ts +110 -41
  35. package/src/browser/prefetch/policy.ts +6 -0
  36. package/src/browser/prefetch/queue.ts +92 -20
  37. package/src/browser/prefetch/resource-ready.ts +77 -0
  38. package/src/browser/react/Link.tsx +88 -9
  39. package/src/browser/react/NavigationProvider.tsx +40 -4
  40. package/src/browser/react/context.ts +7 -2
  41. package/src/browser/react/use-handle.ts +9 -58
  42. package/src/browser/react/use-navigation.ts +22 -2
  43. package/src/browser/react/use-router.ts +21 -8
  44. package/src/browser/rsc-router.tsx +143 -60
  45. package/src/browser/scroll-restoration.ts +41 -42
  46. package/src/browser/segment-reconciler.ts +36 -9
  47. package/src/browser/server-action-bridge.ts +8 -6
  48. package/src/browser/types.ts +60 -5
  49. package/src/build/generate-manifest.ts +6 -6
  50. package/src/build/generate-route-types.ts +3 -0
  51. package/src/build/route-trie.ts +50 -24
  52. package/src/build/route-types/include-resolution.ts +8 -1
  53. package/src/build/route-types/router-processing.ts +223 -74
  54. package/src/build/route-types/scan-filter.ts +8 -1
  55. package/src/cache/cache-runtime.ts +15 -11
  56. package/src/cache/cache-scope.ts +48 -7
  57. package/src/cache/cf/cf-cache-store.ts +453 -11
  58. package/src/cache/cf/index.ts +5 -1
  59. package/src/cache/document-cache.ts +17 -7
  60. package/src/cache/index.ts +1 -0
  61. package/src/cache/taint.ts +55 -0
  62. package/src/client.rsc.tsx +2 -0
  63. package/src/client.tsx +85 -230
  64. package/src/context-var.ts +72 -2
  65. package/src/debug.ts +2 -2
  66. package/src/handle.ts +40 -0
  67. package/src/handles/breadcrumbs.ts +66 -0
  68. package/src/handles/index.ts +1 -0
  69. package/src/index.rsc.ts +6 -36
  70. package/src/index.ts +50 -43
  71. package/src/prerender/store.ts +5 -4
  72. package/src/prerender.ts +138 -77
  73. package/src/reverse.ts +25 -1
  74. package/src/route-definition/dsl-helpers.ts +224 -37
  75. package/src/route-definition/helpers-types.ts +67 -19
  76. package/src/route-definition/index.ts +3 -0
  77. package/src/route-definition/redirect.ts +11 -3
  78. package/src/route-definition/resolve-handler-use.ts +149 -0
  79. package/src/route-map-builder.ts +7 -1
  80. package/src/route-types.ts +18 -0
  81. package/src/router/content-negotiation.ts +100 -1
  82. package/src/router/find-match.ts +4 -2
  83. package/src/router/handler-context.ts +111 -25
  84. package/src/router/intercept-resolution.ts +11 -4
  85. package/src/router/lazy-includes.ts +9 -6
  86. package/src/router/loader-resolution.ts +156 -21
  87. package/src/router/logging.ts +5 -2
  88. package/src/router/manifest.ts +31 -16
  89. package/src/router/match-api.ts +125 -190
  90. package/src/router/match-middleware/background-revalidation.ts +30 -2
  91. package/src/router/match-middleware/cache-lookup.ts +94 -17
  92. package/src/router/match-middleware/cache-store.ts +53 -10
  93. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  94. package/src/router/match-middleware/segment-resolution.ts +61 -5
  95. package/src/router/match-result.ts +104 -10
  96. package/src/router/metrics.ts +6 -1
  97. package/src/router/middleware-types.ts +40 -12
  98. package/src/router/middleware.ts +43 -79
  99. package/src/router/navigation-snapshot.ts +182 -0
  100. package/src/router/prerender-match.ts +114 -10
  101. package/src/router/preview-match.ts +30 -102
  102. package/src/router/request-classification.ts +310 -0
  103. package/src/router/revalidation.ts +21 -10
  104. package/src/router/route-snapshot.ts +245 -0
  105. package/src/router/router-context.ts +6 -1
  106. package/src/router/router-interfaces.ts +44 -5
  107. package/src/router/router-options.ts +49 -18
  108. package/src/router/segment-resolution/fresh.ts +198 -20
  109. package/src/router/segment-resolution/helpers.ts +30 -25
  110. package/src/router/segment-resolution/loader-cache.ts +1 -0
  111. package/src/router/segment-resolution/revalidation.ts +438 -300
  112. package/src/router/segment-wrappers.ts +2 -0
  113. package/src/router/types.ts +1 -0
  114. package/src/router.ts +73 -13
  115. package/src/rsc/handler.ts +472 -372
  116. package/src/rsc/loader-fetch.ts +23 -3
  117. package/src/rsc/manifest-init.ts +5 -1
  118. package/src/rsc/progressive-enhancement.ts +14 -2
  119. package/src/rsc/rsc-rendering.ts +13 -1
  120. package/src/rsc/server-action.ts +8 -0
  121. package/src/rsc/ssr-setup.ts +2 -2
  122. package/src/rsc/types.ts +11 -1
  123. package/src/segment-content-promise.ts +67 -0
  124. package/src/segment-loader-promise.ts +122 -0
  125. package/src/segment-system.tsx +109 -23
  126. package/src/server/context.ts +166 -17
  127. package/src/server/handle-store.ts +19 -0
  128. package/src/server/loader-registry.ts +9 -8
  129. package/src/server/request-context.ts +204 -28
  130. package/src/ssr/index.tsx +4 -0
  131. package/src/static-handler.ts +18 -6
  132. package/src/types/cache-types.ts +4 -4
  133. package/src/types/handler-context.ts +149 -49
  134. package/src/types/loader-types.ts +36 -9
  135. package/src/types/route-entry.ts +19 -1
  136. package/src/types/segments.ts +2 -0
  137. package/src/urls/include-helper.ts +24 -14
  138. package/src/urls/path-helper-types.ts +39 -6
  139. package/src/urls/path-helper.ts +48 -13
  140. package/src/urls/pattern-types.ts +12 -0
  141. package/src/urls/response-types.ts +16 -6
  142. package/src/use-loader.tsx +77 -5
  143. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  144. package/src/vite/discovery/discover-routers.ts +5 -1
  145. package/src/vite/discovery/prerender-collection.ts +128 -74
  146. package/src/vite/discovery/state.ts +13 -6
  147. package/src/vite/index.ts +4 -0
  148. package/src/vite/plugin-types.ts +51 -79
  149. package/src/vite/plugins/expose-action-id.ts +1 -3
  150. package/src/vite/plugins/expose-id-utils.ts +12 -0
  151. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  152. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  153. package/src/vite/plugins/performance-tracks.ts +88 -0
  154. package/src/vite/plugins/refresh-cmd.ts +88 -26
  155. package/src/vite/plugins/version-plugin.ts +13 -1
  156. package/src/vite/rango.ts +163 -211
  157. package/src/vite/router-discovery.ts +178 -45
  158. package/src/vite/utils/banner.ts +3 -3
  159. package/src/vite/utils/prerender-utils.ts +37 -5
  160. package/src/vite/utils/shared-utils.ts +3 -2
@@ -67,10 +67,11 @@
67
67
  * Keep if:
68
68
  * - component !== null (needs rendering)
69
69
  * - type === "loader" (carries data even with null component)
70
+ * - client doesn't have the segment (structurally required parent node)
70
71
  *
71
72
  * Skip if:
72
- * - component === null AND type !== "loader"
73
- * - (Client already has this segment's UI)
73
+ * - component === null AND type !== "loader" AND client has it cached
74
+ * - (Revalidation skip — client already has this segment's UI)
74
75
  *
75
76
  *
76
77
  * INTERCEPT HANDLING
@@ -109,6 +110,7 @@
109
110
  import type { MatchResult, ResolvedSegment } from "../types.js";
110
111
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
111
112
  import { debugLog } from "./logging.js";
113
+ import { appendMetric } from "./metrics.js";
112
114
 
113
115
  /**
114
116
  * Collect all segments from an async generator
@@ -123,6 +125,69 @@ export async function collectSegments(
123
125
  return segments;
124
126
  }
125
127
 
128
+ /**
129
+ * Deduplicate inherited loader segments by loaderId.
130
+ *
131
+ * When a route has loaders and a child layout has parallel slots, the same
132
+ * loader is resolved twice: once for the route and once inherited into the
133
+ * layout (tagged with `_inherited`). The inherited copy is only needed when
134
+ * the route uses `loading()` — in that case, the loader data is inside a
135
+ * LoaderBoundary/Suspense that parallel slots can't reach through. Without
136
+ * loading(), useLoader() traverses parent contexts and finds the data.
137
+ */
138
+ function deduplicateLoaderSegments(
139
+ segments: ResolvedSegment[],
140
+ logPrefix: string,
141
+ ): ResolvedSegment[] {
142
+ // First pass: collect loaderIds of original (non-inherited) segments
143
+ // and whether their parent entry uses loading()
144
+ const originalLoaders = new Set<string>();
145
+ const loadersWithLoading = new Set<string>();
146
+ for (const s of segments) {
147
+ if (s.type === "loader" && s.loaderId && !s._inherited) {
148
+ originalLoaders.add(s.loaderId);
149
+ // If the segment has a sibling with loading, the parent uses loading()
150
+ // We detect this by checking if any non-loader segment in the same
151
+ // namespace has loading defined
152
+ }
153
+ }
154
+ // Check if any layout/route segment has loading — if a loader's namespace
155
+ // matches a segment with loading, the inherited copy is needed
156
+ for (const s of segments) {
157
+ if (s.type !== "loader" && s.loading !== undefined && s.loading !== false) {
158
+ // Find loaders in this namespace
159
+ for (const l of segments) {
160
+ if (l.type === "loader" && l.namespace === s.namespace && l.loaderId) {
161
+ loadersWithLoading.add(l.loaderId);
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ const result: ResolvedSegment[] = [];
168
+ let dedupCount = 0;
169
+
170
+ for (const s of segments) {
171
+ if (
172
+ s.type === "loader" &&
173
+ s.loaderId &&
174
+ s._inherited &&
175
+ originalLoaders.has(s.loaderId) &&
176
+ !loadersWithLoading.has(s.loaderId)
177
+ ) {
178
+ dedupCount++;
179
+ continue;
180
+ }
181
+ result.push(s);
182
+ }
183
+
184
+ if (dedupCount > 0) {
185
+ debugLog(logPrefix, `deduped ${dedupCount} inherited loader segment(s)`);
186
+ }
187
+
188
+ return result;
189
+ }
190
+
126
191
  /**
127
192
  * Build the final MatchResult from collected segments and context
128
193
  */
@@ -167,13 +232,23 @@ export function buildMatchResult<TEnv>(
167
232
  // Deduplicate allIds (defense-in-depth for partial match path)
168
233
  allIds = [...new Set(allIds)];
169
234
 
170
- // Filter out segments with null components (client already has them)
171
- // BUT always include loader segments - they carry data even with null component
235
+ // Filter out null-component segments only when the client already has
236
+ // them cached (revalidation skip). If the client doesn't have the segment,
237
+ // it must be included even with null component — it's structurally required
238
+ // as a parent node for child layouts/parallels to reconcile against.
239
+ // Loader segments are always included as they carry data.
240
+ const clientIdSet = new Set(ctx.clientSegmentIds);
172
241
  segmentsToRender = allSegments.filter(
173
- (s) => s.component !== null || s.type === "loader",
242
+ (s) =>
243
+ s.component !== null || s.type === "loader" || !clientIdSet.has(s.id),
174
244
  );
175
245
  }
176
246
 
247
+ const dedupedSegments = deduplicateLoaderSegments(
248
+ segmentsToRender,
249
+ logPrefix,
250
+ );
251
+
177
252
  debugLog(logPrefix, "all segments", {
178
253
  segments: allSegments.map((s) => ({
179
254
  id: s.id,
@@ -182,13 +257,23 @@ export function buildMatchResult<TEnv>(
182
257
  })),
183
258
  });
184
259
  debugLog(logPrefix, "segments to render", {
185
- segmentIds: segmentsToRender.map((s) => s.id),
260
+ segmentIds: dedupedSegments.map((s) => s.id),
186
261
  });
187
262
 
263
+ // Remove deduped loader IDs from matched so the client doesn't treat
264
+ // them as missing segments and trigger a fallback refetch.
265
+ const removedIds = new Set(
266
+ segmentsToRender
267
+ .filter((s) => !dedupedSegments.includes(s))
268
+ .map((s) => s.id),
269
+ );
270
+ const matchedIds =
271
+ removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
272
+
188
273
  return {
189
- segments: segmentsToRender,
190
- matched: allIds,
191
- diff: segmentsToRender.map((s) => s.id),
274
+ segments: dedupedSegments,
275
+ matched: matchedIds,
276
+ diff: dedupedSegments.map((s) => s.id),
192
277
  params: ctx.matched.params,
193
278
  routeName: ctx.routeKey,
194
279
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
@@ -210,10 +295,19 @@ export async function collectMatchResult<TEnv>(
210
295
  ): Promise<MatchResult> {
211
296
  const allSegments = await collectSegments(pipeline);
212
297
 
298
+ const buildStart = performance.now();
299
+
213
300
  // Update state with collected segments if not already set
214
301
  if (state.segments.length === 0) {
215
302
  state.segments = allSegments;
216
303
  }
217
304
 
218
- return buildMatchResult(allSegments, ctx, state);
305
+ const result = buildMatchResult(allSegments, ctx, state);
306
+ appendMetric(
307
+ ctx.metricsStore,
308
+ "collect-result",
309
+ buildStart,
310
+ performance.now() - buildStart,
311
+ );
312
+ return result;
219
313
  }
@@ -15,7 +15,12 @@ function formatMs(value: number): string {
15
15
  }
16
16
 
17
17
  function sortMetrics(metrics: PerformanceMetric[]): PerformanceMetric[] {
18
- return [...metrics].sort((a, b) => a.startTime - b.startTime);
18
+ return [...metrics].sort((a, b) => {
19
+ // handler:total always goes last (it wraps everything)
20
+ if (a.label === "handler:total") return 1;
21
+ if (b.label === "handler:total") return -1;
22
+ return a.startTime - b.startTime;
23
+ });
19
24
  }
20
25
 
21
26
  interface Span {
@@ -12,6 +12,8 @@ import type {
12
12
  DefaultVars,
13
13
  } from "../types/global-namespace.js";
14
14
  import type { ScopedReverseFunction } from "../reverse.js";
15
+ import type { Theme } from "../theme/types.js";
16
+ import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
15
17
 
16
18
  /**
17
19
  * Get variable function type
@@ -25,8 +27,12 @@ type GetVariableFn = {
25
27
  * Set variable function type
26
28
  */
27
29
  type SetVariableFn = {
28
- <T>(contextVar: ContextVar<T>, value: T): void;
29
- <K extends keyof DefaultVars>(key: K, value: DefaultVars[K]): void;
30
+ <T>(contextVar: ContextVar<T>, value: T, options?: { cache?: boolean }): void;
31
+ <K extends keyof DefaultVars>(
32
+ key: K,
33
+ value: DefaultVars[K],
34
+ options?: { cache?: boolean },
35
+ ): void;
30
36
  };
31
37
 
32
38
  /**
@@ -55,9 +61,15 @@ export interface MiddlewareContext<
55
61
  /** Original request */
56
62
  request: Request;
57
63
 
58
- /** Parsed URL */
64
+ /** Parsed URL (with internal `_rsc*` params stripped) */
59
65
  url: URL;
60
66
 
67
+ /**
68
+ * The original request URL with all parameters intact, including
69
+ * internal `_rsc*` transport params.
70
+ */
71
+ originalUrl: URL;
72
+
61
73
  /** URL pathname */
62
74
  pathname: string;
63
75
 
@@ -71,13 +83,11 @@ export interface MiddlewareContext<
71
83
  params: TParams;
72
84
 
73
85
  /**
74
- * Response stub (read-only). Before `next()`, returns the shared response stub
75
- * where headers and cookies accumulate. After `next()`, returns the downstream response.
76
- *
77
- * Use `ctx.header()` to set response headers, or `cookies()` for cookie mutations.
78
- * To replace the response entirely, return a new `Response` from the middleware.
86
+ * Response headers.
87
+ * Before `next()`, returns headers from the shared response stub.
88
+ * After `next()`, returns headers from the downstream response.
79
89
  */
80
- readonly res: Response;
90
+ readonly headers: Headers;
81
91
 
82
92
  /** Get a context variable (shared with route handlers) */
83
93
  get: GetVariableFn;
@@ -86,11 +96,10 @@ export interface MiddlewareContext<
86
96
  set: SetVariableFn;
87
97
 
88
98
  /**
89
- * Set a response header - can be called before or after `next()`
99
+ * Set a response header - can be called before or after `next()`.
90
100
  *
91
101
  * When called before `next()`, headers are queued and merged into the final response.
92
102
  * When called after `next()`, headers are set directly on the response.
93
- * Shorthand for `ctx.res.headers.set()`.
94
103
  */
95
104
  header(name: string, value: string): void;
96
105
 
@@ -113,6 +122,25 @@ export interface MiddlewareContext<
113
122
  */
114
123
  debugPerformance(): void;
115
124
 
125
+ /**
126
+ * Current theme (from cookie or default).
127
+ * Only available when theme is enabled in router config.
128
+ */
129
+ theme?: Theme;
130
+
131
+ /**
132
+ * Set the theme (only available when theme is enabled in router config).
133
+ * Sets a cookie with the new theme value.
134
+ */
135
+ setTheme?: (theme: Theme) => void;
136
+
137
+ /**
138
+ * Attach location state entries to this response.
139
+ * State is delivered to the client via history.pushState and accessible
140
+ * through the useLocationState() hook.
141
+ */
142
+ setLocationState(entries: LocationStateEntry | LocationStateEntry[]): void;
143
+
116
144
  /**
117
145
  * Generate URLs from route names.
118
146
  * - `name` — global route, from the named-routes definition
@@ -168,7 +196,7 @@ export interface MiddlewareEntry<TEnv = any> {
168
196
  }
169
197
 
170
198
  /**
171
- * Mutable response holder - allows ctx.res to be updated after next() is called
199
+ * Mutable response holder - tracks the current response through the middleware chain.
172
200
  */
173
201
  export interface ResponseHolder {
174
202
  response: Response | null;
@@ -21,6 +21,7 @@ import type {
21
21
  import { _getRequestContext } from "../server/request-context.js";
22
22
  import { isAutoGeneratedRouteName } from "../route-name.js";
23
23
  import { appendMetric, createMetricsStore } from "./metrics.js";
24
+ import { stripInternalParams } from "./handler-context.js";
24
25
 
25
26
  // Re-export types and cookie utilities for backward compatibility
26
27
  export type {
@@ -34,22 +35,6 @@ export type {
34
35
  } from "./middleware-types.js";
35
36
  export { parseCookies, serializeCookie } from "./middleware-cookies.js";
36
37
 
37
- // W5: Deduplicate by function reference so each distinct middleware warns once,
38
- // regardless of whether it is named or anonymous.
39
- let warnedRedirectMiddleware = new WeakSet<Function>();
40
-
41
- function warnCtxSetBeforeRedirect(handler: Function): void {
42
- if (warnedRedirectMiddleware.has(handler)) return;
43
- warnedRedirectMiddleware.add(handler);
44
- const label = handler.name || "(anonymous)";
45
- console.warn(
46
- `[rango] Route middleware "${label}" called ctx.set() then returned a ` +
47
- `redirect. Context variables are per-request and won't be available ` +
48
- `on the redirect target. Use cookies to persist state across ` +
49
- `redirects, or move ctx.set() to the target route's middleware.`,
50
- );
51
- }
52
-
53
38
  const MIDDLEWARE_METRIC_DEPTH = 1;
54
39
  /** Ignore post-next() durations below this threshold (measurement noise). */
55
40
  const POST_METRIC_MIN_DURATION_MS = 0.01;
@@ -75,11 +60,6 @@ function getMiddlewareMetricLabel<TEnv>(
75
60
  return `middleware:${getMiddlewareMetricBase(entry, ordinal)}`;
76
61
  }
77
62
 
78
- /** Reset W5 deduplication state (for tests only). */
79
- export function _resetW5Warnings(): void {
80
- warnedRedirectMiddleware = new WeakSet();
81
- }
82
-
83
63
  /**
84
64
  * Parse a route pattern into regex and param names
85
65
  * Supports: *, /path, /path/*, /path/:param, /path/:param/*
@@ -168,7 +148,7 @@ export function createMiddlewareContext<TEnv>(
168
148
  search?: Record<string, unknown>,
169
149
  ) => string,
170
150
  ): MiddlewareContext<TEnv> {
171
- const url = new URL(request.url);
151
+ const url = stripInternalParams(new URL(request.url));
172
152
 
173
153
  // Track the initial response to detect pre/post-next() phase.
174
154
  // Before next(): responseHolder.response === initialResponse (the stub).
@@ -184,9 +164,25 @@ export function createMiddlewareContext<TEnv>(
184
164
  // Cookie operations are handled by the standalone cookies() function which
185
165
  // delegates to the shared RequestContext internally.
186
166
  // The runtime implementation - types are enforced at call sites via MiddlewareContext<TEnv>
167
+ // Internal helper: resolve the current response (stub before next(), real after).
168
+ // Not exposed on the public MiddlewareContext type — use ctx.headers instead.
169
+ const getResponse = (): Response => {
170
+ if (isPreNext()) {
171
+ const reqCtx = _getRequestContext();
172
+ if (reqCtx) return reqCtx.res;
173
+ }
174
+ if (!responseHolder.response) {
175
+ throw new Error(
176
+ "Response is not available - responseHolder was not initialized",
177
+ );
178
+ }
179
+ return responseHolder.response;
180
+ };
181
+
187
182
  return {
188
183
  request,
189
184
  url,
185
+ originalUrl: new URL(request.url),
190
186
  pathname: url.pathname,
191
187
  searchParams: url.searchParams,
192
188
  env: env as MiddlewareContext<TEnv>["env"],
@@ -201,33 +197,16 @@ export function createMiddlewareContext<TEnv>(
201
197
  ) as MiddlewareContext<TEnv>["routeName"];
202
198
  },
203
199
 
204
- get res(): Response {
205
- // Before next(): return shared RequestContext stub so headers
206
- // set via ctx.header() are visible on ctx.res.
207
- if (isPreNext()) {
208
- const reqCtx = _getRequestContext();
209
- if (reqCtx) return reqCtx.res;
210
- }
211
- if (!responseHolder.response) {
212
- throw new Error(
213
- "ctx.res is not available - responseHolder was not initialized",
214
- );
215
- }
216
- return responseHolder.response;
217
- },
218
- set res(_: Response) {
219
- throw new Error(
220
- "ctx.res is read-only. Use ctx.header() to set response headers, or cookies() for cookie mutations.",
221
- );
200
+ get headers(): Headers {
201
+ return getResponse().headers;
222
202
  },
223
203
 
224
204
  get: ((keyOrVar: any) =>
225
205
  contextGet(variables, keyOrVar)) as MiddlewareContext<TEnv>["get"],
226
206
 
227
- set: ((keyOrVar: any, value: unknown) => {
228
- contextSet(variables, keyOrVar, value);
207
+ set: ((keyOrVar: any, value: unknown, options?: any) => {
208
+ contextSet(variables, keyOrVar, value, options);
229
209
  }) as MiddlewareContext<TEnv>["set"],
230
-
231
210
  header(name: string, value: string): void {
232
211
  // Before next(): delegate to shared RequestContext stub
233
212
  if (isPreNext()) {
@@ -246,6 +225,24 @@ export function createMiddlewareContext<TEnv>(
246
225
  responseHolder.response.headers.set(name, value);
247
226
  },
248
227
 
228
+ get theme(): MiddlewareContext<TEnv>["theme"] {
229
+ return _getRequestContext()?.theme;
230
+ },
231
+
232
+ get setTheme(): MiddlewareContext<TEnv>["setTheme"] {
233
+ return _getRequestContext()?.setTheme;
234
+ },
235
+
236
+ setLocationState(entries) {
237
+ const reqCtx = _getRequestContext();
238
+ if (!reqCtx) {
239
+ throw new Error(
240
+ "setLocationState() is not available outside a request context",
241
+ );
242
+ }
243
+ reqCtx.setLocationState(entries);
244
+ },
245
+
249
246
  reverse:
250
247
  reverse ??
251
248
  ((name: string) => {
@@ -299,9 +296,9 @@ export function matchMiddleware<TEnv>(
299
296
  *
300
297
  * Features:
301
298
  * - `await next()` returns actual Response
302
- * - `ctx.res` available after `await next()` (like Hono's `c.res`)
303
- * - `ctx.header()` shorthand for setting headers
304
- * - Forgiving: if middleware doesn't return, uses `ctx.res`
299
+ * - `ctx.headers` available before and after `await next()`
300
+ * - `ctx.header()` shorthand for setting a single header
301
+ * - Forgiving: if middleware doesn't return, uses the downstream response
305
302
  * - Short-circuit: return Response to stop chain
306
303
  * - Error catching: try/catch around `next()` works
307
304
  */
@@ -425,16 +422,6 @@ export async function executeMiddleware<TEnv>(
425
422
  return nextPromise;
426
423
  };
427
424
 
428
- // W5: track whether ctx.set() is called during this middleware
429
- let ctxSetCalled = false;
430
- if (process.env.NODE_ENV !== "production") {
431
- const originalSet = ctx.set;
432
- ctx.set = ((...args: any[]) => {
433
- ctxSetCalled = true;
434
- return (originalSet as Function).apply(ctx, args);
435
- }) as typeof ctx.set;
436
- }
437
-
438
425
  let result: Response | void;
439
426
  try {
440
427
  result = await entry.handler(ctx, wrappedNext);
@@ -464,16 +451,6 @@ export async function executeMiddleware<TEnv>(
464
451
  // RequestContext stub headers (from ctx.setCookie) into the
465
452
  // returned Response so they are not lost.
466
453
  if (result instanceof Response) {
467
- // W5: warn if ctx.set() was called but middleware returned a redirect
468
- if (
469
- process.env.NODE_ENV !== "production" &&
470
- ctxSetCalled &&
471
- result.status >= 300 &&
472
- result.status < 400
473
- ) {
474
- warnCtxSetBeforeRedirect(entry.handler);
475
- }
476
-
477
454
  const mergedHeaders = new Headers(result.headers);
478
455
  stubResponse.headers.forEach((value, name) => {
479
456
  if (name.toLowerCase() === "set-cookie") {
@@ -524,19 +501,6 @@ export async function executeMiddleware<TEnv>(
524
501
  // If middleware called next(), await it and return the response
525
502
  if (nextPromise) {
526
503
  await nextPromise;
527
-
528
- // W5: warn if ctx.set() was called but the downstream response is a redirect.
529
- // The ctx.set() values will be lost because the redirect navigates away.
530
- if (
531
- process.env.NODE_ENV !== "production" &&
532
- ctxSetCalled &&
533
- responseHolder.response &&
534
- responseHolder.response.status >= 300 &&
535
- responseHolder.response.status < 400
536
- ) {
537
- warnCtxSetBeforeRedirect(entry.handler);
538
- }
539
-
540
504
  return responseHolder.response!;
541
505
  }
542
506
 
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Navigation Snapshot
3
+ *
4
+ * Pure data type representing the navigation-specific state for partial requests.
5
+ * Consolidates the header parsing, previous-route matching, intercept-context
6
+ * detection, and segment ID filtering that previously lived inline in
7
+ * createMatchContextForPartial (match-api.ts).
8
+ *
9
+ * resolveNavigation() is the factory: given a request + URL + current route key,
10
+ * it returns a NavigationSnapshot (or null if no previous URL).
11
+ */
12
+
13
+ import type { RouteMatchResult } from "./pattern-matching.js";
14
+
15
+ /**
16
+ * Snapshot of navigation state for a partial (navigation/action) request.
17
+ *
18
+ * Contains the "where are we coming from?" data: previous route, intercept
19
+ * source, client segment state, and derived flags.
20
+ */
21
+ export interface NavigationSnapshot {
22
+ /** Previous page URL (from X-RSC-Router-Client-Path or Referer) */
23
+ prevUrl: URL;
24
+ /** Params from the previous route match */
25
+ prevParams: Record<string, string>;
26
+ /** Previous route match result (null if prev URL doesn't match any route) */
27
+ prevMatch: RouteMatchResult | null;
28
+
29
+ /** URL used as intercept context source */
30
+ interceptContextUrl: URL;
31
+ /** Route match for the intercept context URL */
32
+ interceptContextMatch: RouteMatchResult | null;
33
+
34
+ /** Raw segment IDs the client currently has */
35
+ clientSegmentIds: string[];
36
+ /** Set version for O(1) lookup */
37
+ clientSegmentSet: Set<string>;
38
+ /** Segment IDs filtered to remove parallel (.@) and loader (D\d+.) entries */
39
+ filteredSegmentIds: string[];
40
+
41
+ /** Whether client considers its cache stale */
42
+ stale: boolean;
43
+
44
+ /** Whether the intercept context route is the same as the current route */
45
+ isSameRouteNavigation: boolean;
46
+
47
+ /** Effective "from" URL (intercept source URL when present, else prevUrl) */
48
+ effectiveFromUrl: URL;
49
+ /** Effective "from" match (intercept source match when present, else prevMatch) */
50
+ effectiveFromMatch: RouteMatchResult | null;
51
+
52
+ /** Whether an intercept source header was present */
53
+ hasInterceptSource: boolean;
54
+
55
+ /** Whether an HMR request header was present */
56
+ isHmr: boolean;
57
+ }
58
+
59
+ export interface ResolveNavigationDeps {
60
+ findMatch: (pathname: string) => RouteMatchResult | null;
61
+ }
62
+
63
+ /**
64
+ * Resolve navigation state from a partial request.
65
+ *
66
+ * Returns null if no previous URL is available (required for partial navigation).
67
+ *
68
+ * @param request - The incoming HTTP request
69
+ * @param url - Parsed URL of the request
70
+ * @param currentRouteKey - Route key of the current (target) route match
71
+ * @param deps - Dependencies (findMatch)
72
+ */
73
+ export function resolveNavigation(
74
+ request: Request,
75
+ url: URL,
76
+ currentRouteKey: string,
77
+ deps: ResolveNavigationDeps,
78
+ ): NavigationSnapshot | null {
79
+ // Parse client state from RSC request params/headers
80
+ const clientSegmentIds =
81
+ url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
82
+ const stale = url.searchParams.get("_rsc_stale") === "true";
83
+ const previousUrl =
84
+ request.headers.get("X-RSC-Router-Client-Path") ||
85
+ request.headers.get("Referer");
86
+ const interceptSourceUrl = request.headers.get(
87
+ "X-RSC-Router-Intercept-Source",
88
+ );
89
+ const isHmr = !!request.headers.get("X-RSC-HMR");
90
+
91
+ if (!previousUrl) {
92
+ return null;
93
+ }
94
+
95
+ // Parse previous URL
96
+ let prevUrl: URL;
97
+ try {
98
+ prevUrl = new URL(previousUrl, url.origin);
99
+ } catch {
100
+ return null;
101
+ }
102
+
103
+ // Parse intercept context URL
104
+ let interceptContextUrl: URL;
105
+ try {
106
+ interceptContextUrl = interceptSourceUrl
107
+ ? new URL(interceptSourceUrl, url.origin)
108
+ : prevUrl;
109
+ } catch {
110
+ interceptContextUrl = prevUrl;
111
+ }
112
+
113
+ // Match previous and intercept context routes
114
+ const prevMatch = deps.findMatch(prevUrl.pathname);
115
+ const prevParams = prevMatch?.params || {};
116
+ const interceptContextMatch = interceptSourceUrl
117
+ ? deps.findMatch(interceptContextUrl.pathname)
118
+ : prevMatch;
119
+
120
+ // Derived state
121
+ const isSameRouteNavigation = !!(
122
+ interceptContextMatch && interceptContextMatch.routeKey === currentRouteKey
123
+ );
124
+
125
+ const hasInterceptSource = !!interceptSourceUrl;
126
+ const effectiveFromUrl = hasInterceptSource ? interceptContextUrl : prevUrl;
127
+ const effectiveFromMatch = hasInterceptSource
128
+ ? interceptContextMatch
129
+ : prevMatch;
130
+
131
+ // Filter segment IDs: remove parallel (.@) and loader (D\d+.) entries
132
+ const filteredSegmentIds = clientSegmentIds.filter((id) => {
133
+ if (id.includes(".@")) return false;
134
+ if (/D\d+\./.test(id)) return false;
135
+ return true;
136
+ });
137
+
138
+ const clientSegmentSet = new Set(clientSegmentIds);
139
+
140
+ return {
141
+ prevUrl,
142
+ prevParams,
143
+ prevMatch,
144
+ interceptContextUrl,
145
+ interceptContextMatch,
146
+ clientSegmentIds,
147
+ clientSegmentSet,
148
+ filteredSegmentIds,
149
+ stale,
150
+ isSameRouteNavigation,
151
+ effectiveFromUrl,
152
+ effectiveFromMatch,
153
+ hasInterceptSource,
154
+ isHmr,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Test helper: create a NavigationSnapshot with sensible defaults and overrides.
160
+ */
161
+ export function createNavigationSnapshot(
162
+ overrides?: Partial<NavigationSnapshot>,
163
+ ): NavigationSnapshot {
164
+ const defaultUrl = new URL("http://localhost/");
165
+ return {
166
+ prevUrl: defaultUrl,
167
+ prevParams: {},
168
+ prevMatch: null,
169
+ interceptContextUrl: defaultUrl,
170
+ interceptContextMatch: null,
171
+ clientSegmentIds: [],
172
+ clientSegmentSet: new Set(),
173
+ filteredSegmentIds: [],
174
+ stale: false,
175
+ isSameRouteNavigation: false,
176
+ effectiveFromUrl: defaultUrl,
177
+ effectiveFromMatch: null,
178
+ hasInterceptSource: false,
179
+ isHmr: false,
180
+ ...overrides,
181
+ };
182
+ }