@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.
- package/AGENTS.md +4 -0
- package/README.md +78 -19
- package/dist/bin/rango.js +138 -50
- package/dist/vite/index.js +853 -435
- package/package.json +17 -16
- package/skills/breadcrumbs/SKILL.md +250 -0
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +45 -4
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/hooks/SKILL.md +22 -4
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +3 -1
- package/skills/loader/SKILL.md +71 -21
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +764 -0
- package/skills/parallel/SKILL.md +185 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +24 -22
- package/skills/route/SKILL.md +56 -2
- package/skills/router-setup/SKILL.md +92 -2
- package/skills/typesafety/SKILL.md +33 -21
- package/src/__internal.ts +92 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +125 -16
- package/src/browser/navigation-client.ts +154 -44
- package/src/browser/navigation-store.ts +43 -8
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +94 -17
- package/src/browser/prefetch/cache.ts +176 -27
- package/src/browser/prefetch/fetch.ts +110 -41
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/prefetch/queue.ts +92 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/Link.tsx +88 -9
- package/src/browser/react/NavigationProvider.tsx +40 -4
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/use-handle.ts +9 -58
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-router.ts +21 -8
- package/src/browser/rsc-router.tsx +143 -60
- package/src/browser/scroll-restoration.ts +41 -42
- package/src/browser/segment-reconciler.ts +36 -9
- package/src/browser/server-action-bridge.ts +8 -6
- package/src/browser/types.ts +60 -5
- package/src/build/generate-manifest.ts +6 -6
- package/src/build/generate-route-types.ts +3 -0
- package/src/build/route-trie.ts +50 -24
- package/src/build/route-types/include-resolution.ts +8 -1
- package/src/build/route-types/router-processing.ts +223 -74
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +48 -7
- package/src/cache/cf/cf-cache-store.ts +453 -11
- package/src/cache/cf/index.ts +5 -1
- package/src/cache/document-cache.ts +17 -7
- package/src/cache/index.ts +1 -0
- package/src/cache/taint.ts +55 -0
- package/src/client.rsc.tsx +2 -0
- package/src/client.tsx +85 -230
- package/src/context-var.ts +72 -2
- package/src/debug.ts +2 -2
- package/src/handle.ts +40 -0
- package/src/handles/breadcrumbs.ts +66 -0
- package/src/handles/index.ts +1 -0
- package/src/index.rsc.ts +6 -36
- package/src/index.ts +50 -43
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/reverse.ts +25 -1
- package/src/route-definition/dsl-helpers.ts +224 -37
- package/src/route-definition/helpers-types.ts +67 -19
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/redirect.ts +11 -3
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-map-builder.ts +7 -1
- package/src/route-types.ts +18 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/handler-context.ts +111 -25
- package/src/router/intercept-resolution.ts +11 -4
- package/src/router/lazy-includes.ts +9 -6
- package/src/router/loader-resolution.ts +156 -21
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +31 -16
- package/src/router/match-api.ts +125 -190
- package/src/router/match-middleware/background-revalidation.ts +30 -2
- package/src/router/match-middleware/cache-lookup.ts +94 -17
- package/src/router/match-middleware/cache-store.ts +53 -10
- package/src/router/match-middleware/intercept-resolution.ts +9 -7
- package/src/router/match-middleware/segment-resolution.ts +61 -5
- package/src/router/match-result.ts +104 -10
- package/src/router/metrics.ts +6 -1
- package/src/router/middleware-types.ts +40 -12
- package/src/router/middleware.ts +43 -79
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/prerender-match.ts +114 -10
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/revalidation.ts +21 -10
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +6 -1
- package/src/router/router-interfaces.ts +44 -5
- package/src/router/router-options.ts +49 -18
- package/src/router/segment-resolution/fresh.ts +198 -20
- package/src/router/segment-resolution/helpers.ts +30 -25
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +438 -300
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router/types.ts +1 -0
- package/src/router.ts +73 -13
- package/src/rsc/handler.ts +472 -372
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +14 -2
- package/src/rsc/rsc-rendering.ts +13 -1
- package/src/rsc/server-action.ts +8 -0
- package/src/rsc/ssr-setup.ts +2 -2
- package/src/rsc/types.ts +11 -1
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +109 -23
- package/src/server/context.ts +166 -17
- package/src/server/handle-store.ts +19 -0
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +204 -28
- package/src/ssr/index.tsx +4 -0
- package/src/static-handler.ts +18 -6
- package/src/types/cache-types.ts +4 -4
- package/src/types/handler-context.ts +149 -49
- package/src/types/loader-types.ts +36 -9
- package/src/types/route-entry.ts +19 -1
- package/src/types/segments.ts +2 -0
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +39 -6
- package/src/urls/path-helper.ts +48 -13
- package/src/urls/pattern-types.ts +12 -0
- package/src/urls/response-types.ts +16 -6
- package/src/use-loader.tsx +77 -5
- package/src/vite/discovery/bundle-postprocess.ts +30 -33
- package/src/vite/discovery/discover-routers.ts +5 -1
- package/src/vite/discovery/prerender-collection.ts +128 -74
- package/src/vite/discovery/state.ts +13 -6
- package/src/vite/index.ts +4 -0
- package/src/vite/plugin-types.ts +51 -79
- package/src/vite/plugins/expose-action-id.ts +1 -3
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-internal-ids.ts +257 -40
- package/src/vite/plugins/performance-tracks.ts +88 -0
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +163 -211
- package/src/vite/router-discovery.ts +178 -45
- package/src/vite/utils/banner.ts +3 -3
- package/src/vite/utils/prerender-utils.ts +37 -5
- 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
|
-
* - (
|
|
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
|
|
171
|
-
//
|
|
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) =>
|
|
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:
|
|
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:
|
|
190
|
-
matched:
|
|
191
|
-
diff:
|
|
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
|
-
|
|
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
|
}
|
package/src/router/metrics.ts
CHANGED
|
@@ -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) =>
|
|
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>(
|
|
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
|
|
75
|
-
*
|
|
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
|
|
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 -
|
|
199
|
+
* Mutable response holder - tracks the current response through the middleware chain.
|
|
172
200
|
*/
|
|
173
201
|
export interface ResponseHolder {
|
|
174
202
|
response: Response | null;
|
package/src/router/middleware.ts
CHANGED
|
@@ -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
|
|
205
|
-
|
|
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.
|
|
303
|
-
* - `ctx.header()` shorthand for setting
|
|
304
|
-
* - Forgiving: if middleware doesn't return, uses
|
|
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
|
+
}
|