@ivogt/rsc-router 0.0.0-experimental.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/package.json +131 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +891 -0
- package/src/browser/navigation-client.ts +155 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +545 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +228 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +149 -0
- package/src/browser/rsc-router.tsx +310 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +443 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
- package/src/cache/cf/cf-cache-store.ts +274 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/index.ts +52 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +366 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +609 -0
- package/src/components/DefaultDocument.tsx +20 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +178 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href.ts +139 -0
- package/src/index.rsc.ts +69 -0
- package/src/index.ts +84 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1333 -0
- package/src/route-map-builder.ts +140 -0
- package/src/route-types.ts +148 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +60 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +116 -0
- package/src/router/match-context.ts +261 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +250 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +212 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +271 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3484 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +942 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +225 -0
- package/src/segment-system.tsx +405 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +340 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +470 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +126 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +215 -0
- package/src/types.ts +1473 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +608 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +109 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,3484 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
import { CacheScope, createCacheScope } from "./cache/cache-scope.js";
|
|
4
|
+
import type { SegmentCacheStore } from "./cache/types.js";
|
|
5
|
+
import { DefaultDocument } from "./components/DefaultDocument.js";
|
|
6
|
+
import { DefaultErrorFallback } from "./default-error-boundary.js";
|
|
7
|
+
import {
|
|
8
|
+
DataNotFoundError,
|
|
9
|
+
RouteNotFoundError,
|
|
10
|
+
invariant,
|
|
11
|
+
sanitizeError,
|
|
12
|
+
} from "./errors";
|
|
13
|
+
import {
|
|
14
|
+
createHref,
|
|
15
|
+
type HrefFunction,
|
|
16
|
+
type PrefixedRoutes,
|
|
17
|
+
type SanitizePrefix,
|
|
18
|
+
} from "./href.js";
|
|
19
|
+
import { registerRouteMap } from "./route-map-builder.js";
|
|
20
|
+
import type { AllUseItems } from "./route-types.js";
|
|
21
|
+
import {
|
|
22
|
+
EntryData,
|
|
23
|
+
InterceptEntry,
|
|
24
|
+
InterceptSelectorContext,
|
|
25
|
+
LoaderEntry,
|
|
26
|
+
getContext,
|
|
27
|
+
} from "./server/context";
|
|
28
|
+
import { createHandleStore, type HandleStore } from "./server/handle-store.js";
|
|
29
|
+
import { getRequestContext } from "./server/request-context.js";
|
|
30
|
+
import type {
|
|
31
|
+
ErrorBoundaryHandler,
|
|
32
|
+
ErrorInfo,
|
|
33
|
+
ErrorPhase,
|
|
34
|
+
HandlerContext,
|
|
35
|
+
LoaderDataResult,
|
|
36
|
+
MatchResult,
|
|
37
|
+
NotFoundBoundaryHandler,
|
|
38
|
+
OnErrorCallback,
|
|
39
|
+
OnErrorContext,
|
|
40
|
+
ResolvedRouteMap,
|
|
41
|
+
ResolvedSegment,
|
|
42
|
+
RouteDefinition,
|
|
43
|
+
RouteEntry,
|
|
44
|
+
ShouldRevalidateFn,
|
|
45
|
+
TrailingSlashMode,
|
|
46
|
+
} from "./types";
|
|
47
|
+
|
|
48
|
+
// Extracted router utilities
|
|
49
|
+
import {
|
|
50
|
+
createErrorInfo,
|
|
51
|
+
createErrorSegment,
|
|
52
|
+
createNotFoundInfo,
|
|
53
|
+
createNotFoundSegment,
|
|
54
|
+
findNearestErrorBoundary as findErrorBoundary,
|
|
55
|
+
findNearestNotFoundBoundary as findNotFoundBoundary,
|
|
56
|
+
invokeOnError,
|
|
57
|
+
} from "./router/error-handling.js";
|
|
58
|
+
import { createHandlerContext } from "./router/handler-context.js";
|
|
59
|
+
import {
|
|
60
|
+
revalidate,
|
|
61
|
+
setupLoaderAccess,
|
|
62
|
+
setupLoaderAccessSilent,
|
|
63
|
+
wrapLoaderWithErrorHandling,
|
|
64
|
+
} from "./router/loader-resolution.js";
|
|
65
|
+
import { loadManifest } from "./router/manifest.js";
|
|
66
|
+
import {
|
|
67
|
+
createMetricsStore,
|
|
68
|
+
generateServerTiming,
|
|
69
|
+
logMetrics,
|
|
70
|
+
} from "./router/metrics.js";
|
|
71
|
+
import {
|
|
72
|
+
collectRouteMiddleware,
|
|
73
|
+
executeInterceptMiddleware,
|
|
74
|
+
parsePattern,
|
|
75
|
+
type MiddlewareEntry,
|
|
76
|
+
type MiddlewareFn,
|
|
77
|
+
} from "./router/middleware.js";
|
|
78
|
+
import {
|
|
79
|
+
findMatch as findRouteMatch,
|
|
80
|
+
traverseBack,
|
|
81
|
+
} from "./router/pattern-matching.js";
|
|
82
|
+
import { evaluateRevalidation } from "./router/revalidation.js";
|
|
83
|
+
import {
|
|
84
|
+
type RouterContext,
|
|
85
|
+
runWithRouterContext,
|
|
86
|
+
} from "./router/router-context.js";
|
|
87
|
+
import {
|
|
88
|
+
type ActionContext,
|
|
89
|
+
type MatchContext,
|
|
90
|
+
type MatchPipelineState,
|
|
91
|
+
createPipelineState,
|
|
92
|
+
} from "./router/match-context.js";
|
|
93
|
+
import { createMatchPartialPipeline } from "./router/match-pipelines.js";
|
|
94
|
+
import { collectMatchResult } from "./router/match-result.js";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Props passed to the root layout component
|
|
98
|
+
*/
|
|
99
|
+
export interface RootLayoutProps {
|
|
100
|
+
children: ReactNode;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Router configuration options
|
|
105
|
+
*/
|
|
106
|
+
export interface RSCRouterOptions<TEnv = any> {
|
|
107
|
+
/**
|
|
108
|
+
* Enable performance metrics collection
|
|
109
|
+
* When enabled, metrics are output to console and available via Server-Timing header
|
|
110
|
+
*/
|
|
111
|
+
debugPerformance?: boolean;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Document component that wraps the entire application.
|
|
115
|
+
*
|
|
116
|
+
* This component provides the HTML structure for your app and wraps
|
|
117
|
+
* both normal route content AND error states, preventing the app shell
|
|
118
|
+
* from unmounting during errors (avoids FOUC).
|
|
119
|
+
*
|
|
120
|
+
* Must be a client component ("use client") that accepts { children }.
|
|
121
|
+
*
|
|
122
|
+
* If not provided, a default document with basic HTML structure is used:
|
|
123
|
+
* `<html><head><meta charset/viewport></head><body>{children}</body></html>`
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```typescript
|
|
127
|
+
* // components/Document.tsx
|
|
128
|
+
* "use client";
|
|
129
|
+
* export function Document({ children }: { children: ReactNode }) {
|
|
130
|
+
* return (
|
|
131
|
+
* <html lang="en">
|
|
132
|
+
* <head>
|
|
133
|
+
* <link rel="stylesheet" href="/styles.css" />
|
|
134
|
+
* </head>
|
|
135
|
+
* <body>
|
|
136
|
+
* <nav>...</nav>
|
|
137
|
+
* {children}
|
|
138
|
+
* </body>
|
|
139
|
+
* </html>
|
|
140
|
+
* );
|
|
141
|
+
* }
|
|
142
|
+
*
|
|
143
|
+
* // router.tsx
|
|
144
|
+
* const router = createRSCRouter<AppEnv>({
|
|
145
|
+
* document: Document,
|
|
146
|
+
* });
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
document?: ComponentType<RootLayoutProps>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Default error boundary fallback used when no error boundary is defined in the route tree
|
|
153
|
+
* If not provided, errors will propagate and crash the request
|
|
154
|
+
*/
|
|
155
|
+
defaultErrorBoundary?: ReactNode | ErrorBoundaryHandler;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Default not-found boundary fallback used when no notFoundBoundary is defined in the route tree
|
|
159
|
+
* If not provided, DataNotFoundError will be treated as a regular error
|
|
160
|
+
*/
|
|
161
|
+
defaultNotFoundBoundary?: ReactNode | NotFoundBoundaryHandler;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Callback invoked when an error occurs during request handling.
|
|
165
|
+
*
|
|
166
|
+
* This callback is for notification/logging purposes - it cannot modify
|
|
167
|
+
* the error handling flow. Use errorBoundary() in route definitions to
|
|
168
|
+
* customize error UI.
|
|
169
|
+
*
|
|
170
|
+
* The callback receives comprehensive context about the error including:
|
|
171
|
+
* - The error itself
|
|
172
|
+
* - Phase where it occurred (routing, middleware, loader, handler, etc.)
|
|
173
|
+
* - Request info (URL, method, params)
|
|
174
|
+
* - Route info (routeKey, segmentId)
|
|
175
|
+
* - Environment/bindings
|
|
176
|
+
* - Duration from request start
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* const router = createRSCRouter<AppEnv>({
|
|
181
|
+
* onError: (context) => {
|
|
182
|
+
* // Send to error tracking service
|
|
183
|
+
* Sentry.captureException(context.error, {
|
|
184
|
+
* tags: {
|
|
185
|
+
* phase: context.phase,
|
|
186
|
+
* route: context.routeKey,
|
|
187
|
+
* },
|
|
188
|
+
* extra: {
|
|
189
|
+
* url: context.url.toString(),
|
|
190
|
+
* params: context.params,
|
|
191
|
+
* duration: context.duration,
|
|
192
|
+
* },
|
|
193
|
+
* });
|
|
194
|
+
* },
|
|
195
|
+
* });
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
onError?: OnErrorCallback<TEnv>;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Cache store for segment caching.
|
|
202
|
+
*
|
|
203
|
+
* When provided, enables route-level caching via cache() boundaries.
|
|
204
|
+
* The store handles persistence (memory, KV, Redis, etc.).
|
|
205
|
+
*
|
|
206
|
+
* Can be a static config or a function receiving env for runtime bindings.
|
|
207
|
+
* Can be overridden in createRSCHandler.
|
|
208
|
+
*
|
|
209
|
+
* @example Static config
|
|
210
|
+
* ```typescript
|
|
211
|
+
* import { MemorySegmentCacheStore } from "rsc-router/rsc";
|
|
212
|
+
*
|
|
213
|
+
* const router = createRSCRouter({
|
|
214
|
+
* cache: {
|
|
215
|
+
* store: new MemorySegmentCacheStore({ defaults: { ttl: 60 } }),
|
|
216
|
+
* },
|
|
217
|
+
* });
|
|
218
|
+
* ```
|
|
219
|
+
*
|
|
220
|
+
* @example Dynamic config with env
|
|
221
|
+
* ```typescript
|
|
222
|
+
* const router = createRSCRouter<AppEnv>({
|
|
223
|
+
* cache: (env) => ({
|
|
224
|
+
* store: new KVSegmentCacheStore(env.Bindings.MY_CACHE),
|
|
225
|
+
* }),
|
|
226
|
+
* });
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
cache?:
|
|
230
|
+
| { store: SegmentCacheStore; enabled?: boolean }
|
|
231
|
+
| ((env: TEnv) => { store: SegmentCacheStore; enabled?: boolean });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Router builder for chaining .use() and .map()
|
|
236
|
+
* TRoutes accumulates all registered route types through the chain
|
|
237
|
+
*/
|
|
238
|
+
interface RouteBuilder<
|
|
239
|
+
T extends RouteDefinition,
|
|
240
|
+
TEnv,
|
|
241
|
+
TRoutes extends Record<string, string>,
|
|
242
|
+
> {
|
|
243
|
+
/**
|
|
244
|
+
* Add middleware scoped to this mount
|
|
245
|
+
* Called between .routes() and .map()
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```typescript
|
|
249
|
+
* .routes("/admin", adminRoutes)
|
|
250
|
+
* .use(authMiddleware) // All of /admin/*
|
|
251
|
+
* .use("/danger/*", superAuth) // Only /admin/danger/*
|
|
252
|
+
* .map(() => import("./admin"))
|
|
253
|
+
* ```
|
|
254
|
+
*/
|
|
255
|
+
use(
|
|
256
|
+
patternOrMiddleware: string | MiddlewareFn<TEnv>,
|
|
257
|
+
middleware?: MiddlewareFn<TEnv>
|
|
258
|
+
): RouteBuilder<T, TEnv, TRoutes>;
|
|
259
|
+
|
|
260
|
+
map(
|
|
261
|
+
handler: () =>
|
|
262
|
+
| Array<AllUseItems>
|
|
263
|
+
| Promise<{ default: () => Array<AllUseItems> }>
|
|
264
|
+
| Promise<() => Array<AllUseItems>>
|
|
265
|
+
): RSCRouter<TEnv, TRoutes>;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Accumulated route map for typeof extraction
|
|
269
|
+
* Used for module augmentation: `type AppRoutes = typeof _router.routeMap`
|
|
270
|
+
*/
|
|
271
|
+
readonly routeMap: TRoutes;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* RSC Router interface
|
|
276
|
+
* TRoutes accumulates all registered route types through the builder chain
|
|
277
|
+
*/
|
|
278
|
+
export interface RSCRouter<
|
|
279
|
+
TEnv = any,
|
|
280
|
+
TRoutes extends Record<string, string> = Record<string, string>,
|
|
281
|
+
> {
|
|
282
|
+
/**
|
|
283
|
+
* Register routes with a prefix
|
|
284
|
+
* Route types are accumulated through the chain
|
|
285
|
+
*/
|
|
286
|
+
routes<TPrefix extends string, T extends ResolvedRouteMap<any>>(
|
|
287
|
+
prefix: TPrefix,
|
|
288
|
+
routes: T
|
|
289
|
+
): RouteBuilder<
|
|
290
|
+
RouteDefinition,
|
|
291
|
+
TEnv,
|
|
292
|
+
TRoutes & PrefixedRoutes<T, SanitizePrefix<TPrefix>>
|
|
293
|
+
>;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Register routes without a prefix
|
|
297
|
+
* Route types are accumulated through the chain
|
|
298
|
+
*/
|
|
299
|
+
routes<T extends ResolvedRouteMap<any>>(
|
|
300
|
+
routes: T
|
|
301
|
+
): RouteBuilder<RouteDefinition, TEnv, TRoutes & T>;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Add global middleware that runs on all routes
|
|
305
|
+
* Position matters: middleware before any .routes() is global
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* ```typescript
|
|
309
|
+
* createRSCRouter({ document: RootLayout })
|
|
310
|
+
* .use(loggerMiddleware) // All routes
|
|
311
|
+
* .use("/api/*", rateLimiter) // Pattern match
|
|
312
|
+
* .routes(homeRoutes)
|
|
313
|
+
* .map(() => import("./home"))
|
|
314
|
+
* ```
|
|
315
|
+
*/
|
|
316
|
+
use(
|
|
317
|
+
patternOrMiddleware: string | MiddlewareFn<TEnv>,
|
|
318
|
+
middleware?: MiddlewareFn<TEnv>
|
|
319
|
+
): RSCRouter<TEnv, TRoutes>;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Type-safe URL builder for registered routes
|
|
323
|
+
* Types are inferred from the accumulated route registrations
|
|
324
|
+
*
|
|
325
|
+
* @example
|
|
326
|
+
* ```typescript
|
|
327
|
+
* router.href("shop.cart"); // "/shop/cart"
|
|
328
|
+
* router.href("shop.products.detail", { slug: "widget" }); // "/shop/product/widget"
|
|
329
|
+
* ```
|
|
330
|
+
*/
|
|
331
|
+
href: HrefFunction<TRoutes>;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Accumulated route map for typeof extraction
|
|
335
|
+
* Used for module augmentation: `type AppRoutes = typeof _router.routeMap`
|
|
336
|
+
*
|
|
337
|
+
* @example
|
|
338
|
+
* ```typescript
|
|
339
|
+
* const _router = createRSCRouter<AppEnv>()
|
|
340
|
+
* .routes(homeRoutes).map(() => import('./home'))
|
|
341
|
+
* .routes('/shop', shopRoutes).map(() => import('./shop'));
|
|
342
|
+
*
|
|
343
|
+
* type AppRoutes = typeof _router.routeMap;
|
|
344
|
+
*
|
|
345
|
+
* declare global {
|
|
346
|
+
* namespace RSCRouter {
|
|
347
|
+
* interface RegisteredRoutes extends AppRoutes {}
|
|
348
|
+
* }
|
|
349
|
+
* }
|
|
350
|
+
* ```
|
|
351
|
+
*/
|
|
352
|
+
readonly routeMap: TRoutes;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Root layout component that wraps the entire application
|
|
356
|
+
* Access this to pass to renderSegments
|
|
357
|
+
*/
|
|
358
|
+
readonly rootLayout?: ComponentType<RootLayoutProps>;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Error callback for monitoring/alerting
|
|
362
|
+
* Called when errors occur in loaders, actions, or routes
|
|
363
|
+
*/
|
|
364
|
+
readonly onError?: RSCRouterOptions<TEnv>["onError"];
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Cache configuration (for internal use by RSC handler)
|
|
368
|
+
*/
|
|
369
|
+
readonly cache?: RSCRouterOptions<TEnv>["cache"];
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* App-level middleware entries (for internal use by RSC handler)
|
|
373
|
+
* These wrap the entire request/response cycle
|
|
374
|
+
*/
|
|
375
|
+
readonly middleware: MiddlewareEntry<TEnv>[];
|
|
376
|
+
|
|
377
|
+
match(request: Request, context: TEnv): Promise<MatchResult>;
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Preview match - returns route middleware without segment resolution
|
|
381
|
+
* Used by RSC handler to execute route middleware before full matching
|
|
382
|
+
*/
|
|
383
|
+
previewMatch(
|
|
384
|
+
request: Request,
|
|
385
|
+
context: TEnv
|
|
386
|
+
): Promise<{
|
|
387
|
+
routeMiddleware?: Array<{
|
|
388
|
+
handler: import("./router/middleware.js").MiddlewareFn;
|
|
389
|
+
params: Record<string, string>;
|
|
390
|
+
}>;
|
|
391
|
+
} | null>;
|
|
392
|
+
|
|
393
|
+
matchPartial(
|
|
394
|
+
request: Request,
|
|
395
|
+
context: TEnv,
|
|
396
|
+
actionContext?: {
|
|
397
|
+
actionId?: string;
|
|
398
|
+
actionUrl?: URL;
|
|
399
|
+
actionResult?: any;
|
|
400
|
+
formData?: FormData;
|
|
401
|
+
}
|
|
402
|
+
): Promise<MatchResult | null>;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Match an error to the nearest error boundary and return error segments
|
|
406
|
+
*
|
|
407
|
+
* Used when an action or other operation fails and we need to render
|
|
408
|
+
* the error boundary UI. Finds the nearest errorBoundary in the route tree
|
|
409
|
+
* for the current URL and renders it with the error info.
|
|
410
|
+
*
|
|
411
|
+
* @param request - The current request (used to match the route)
|
|
412
|
+
* @param context - Environment context
|
|
413
|
+
* @param error - The error that occurred
|
|
414
|
+
* @param segmentType - Type of segment where error occurred (default: "route")
|
|
415
|
+
* @returns MatchResult with error segment, or null if no error boundary found
|
|
416
|
+
*/
|
|
417
|
+
matchError(
|
|
418
|
+
request: Request,
|
|
419
|
+
context: TEnv,
|
|
420
|
+
error: unknown,
|
|
421
|
+
segmentType?: ErrorInfo["segmentType"]
|
|
422
|
+
): Promise<MatchResult | null>;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Create an RSC router with generic context type
|
|
427
|
+
* Route types are accumulated automatically through the builder chain
|
|
428
|
+
*
|
|
429
|
+
* @example
|
|
430
|
+
* ```typescript
|
|
431
|
+
* interface AppContext {
|
|
432
|
+
* db: Database;
|
|
433
|
+
* user?: User;
|
|
434
|
+
* }
|
|
435
|
+
*
|
|
436
|
+
* const router = createRSCRouter<AppContext>({
|
|
437
|
+
* debugPerformance: true // Enable metrics
|
|
438
|
+
* });
|
|
439
|
+
*
|
|
440
|
+
* // Route types accumulate through the chain - no module augmentation needed!
|
|
441
|
+
* router
|
|
442
|
+
* .routes(homeRoutes) // accumulates homeRoutes
|
|
443
|
+
* .map(() => import('./home'))
|
|
444
|
+
* .routes('/shop', shopRoutes) // accumulates PrefixedRoutes<shopRoutes, "shop">
|
|
445
|
+
* .map(() => import('./shop'));
|
|
446
|
+
*
|
|
447
|
+
* // router.href now has type-safe autocomplete for all registered routes
|
|
448
|
+
* router.href("shop.cart");
|
|
449
|
+
* ```
|
|
450
|
+
*/
|
|
451
|
+
export function createRSCRouter<TEnv = any>(
|
|
452
|
+
options: RSCRouterOptions<TEnv> = {}
|
|
453
|
+
): RSCRouter<TEnv, {}> {
|
|
454
|
+
const {
|
|
455
|
+
debugPerformance = false,
|
|
456
|
+
document: documentOption,
|
|
457
|
+
defaultErrorBoundary,
|
|
458
|
+
defaultNotFoundBoundary,
|
|
459
|
+
onError,
|
|
460
|
+
cache,
|
|
461
|
+
} = options;
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Wrapper for invokeOnError that binds the router's onError callback.
|
|
465
|
+
* Uses the shared utility from router/error-handling.ts for consistent behavior.
|
|
466
|
+
*/
|
|
467
|
+
function callOnError(
|
|
468
|
+
error: unknown,
|
|
469
|
+
phase: ErrorPhase,
|
|
470
|
+
context: Parameters<typeof invokeOnError<TEnv>>[3]
|
|
471
|
+
): void {
|
|
472
|
+
invokeOnError(onError, error, phase, context, "Router");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Validate document is a function (component)
|
|
476
|
+
// Note: We cannot validate "use client" at runtime since it's a bundler directive.
|
|
477
|
+
// If a server component is passed, React will throw during rendering with a
|
|
478
|
+
// "Functions cannot be passed to Client Components" error.
|
|
479
|
+
if (documentOption !== undefined && typeof documentOption !== "function") {
|
|
480
|
+
throw new Error(
|
|
481
|
+
`document must be a client component function with "use client" directive. ` +
|
|
482
|
+
`Make sure to pass the component itself, not a JSX element: ` +
|
|
483
|
+
`document: MyDocument (correct) vs document: <MyDocument /> (incorrect)`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Use default document if none provided (keeps internal name as rootLayout)
|
|
488
|
+
const rootLayout = documentOption ?? DefaultDocument;
|
|
489
|
+
const routesEntries: RouteEntry<TEnv>[] = [];
|
|
490
|
+
let mountIndex = 0;
|
|
491
|
+
|
|
492
|
+
// Global middleware storage
|
|
493
|
+
const globalMiddleware: MiddlewareEntry<TEnv>[] = [];
|
|
494
|
+
|
|
495
|
+
// Helper to add middleware entry
|
|
496
|
+
function addMiddleware(
|
|
497
|
+
patternOrMiddleware: string | MiddlewareFn<TEnv>,
|
|
498
|
+
middleware?: MiddlewareFn<TEnv>,
|
|
499
|
+
mountPrefix: string | null = null
|
|
500
|
+
): void {
|
|
501
|
+
let pattern: string | null = null;
|
|
502
|
+
let handler: MiddlewareFn<TEnv>;
|
|
503
|
+
|
|
504
|
+
if (typeof patternOrMiddleware === "string") {
|
|
505
|
+
// Pattern + middleware
|
|
506
|
+
pattern = patternOrMiddleware;
|
|
507
|
+
if (!middleware) {
|
|
508
|
+
throw new Error(
|
|
509
|
+
"Middleware function required when pattern is provided"
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
handler = middleware;
|
|
513
|
+
} else {
|
|
514
|
+
// Just middleware (no pattern)
|
|
515
|
+
handler = patternOrMiddleware;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// If mount-scoped, prepend mount prefix to pattern
|
|
519
|
+
let fullPattern = pattern;
|
|
520
|
+
if (mountPrefix && pattern) {
|
|
521
|
+
// e.g., mountPrefix="/blog", pattern="/admin/*" → "/blog/admin/*"
|
|
522
|
+
fullPattern =
|
|
523
|
+
pattern === "*" ? `${mountPrefix}/*` : `${mountPrefix}${pattern}`;
|
|
524
|
+
} else if (mountPrefix && !pattern) {
|
|
525
|
+
// Mount-scoped middleware without pattern applies to all of mount
|
|
526
|
+
fullPattern = `${mountPrefix}/*`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Parse pattern into regex
|
|
530
|
+
let regex: RegExp | null = null;
|
|
531
|
+
let paramNames: string[] = [];
|
|
532
|
+
if (fullPattern) {
|
|
533
|
+
const parsed = parsePattern(fullPattern);
|
|
534
|
+
regex = parsed.regex;
|
|
535
|
+
paramNames = parsed.paramNames;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
globalMiddleware.push({
|
|
539
|
+
pattern: fullPattern,
|
|
540
|
+
regex,
|
|
541
|
+
paramNames,
|
|
542
|
+
handler,
|
|
543
|
+
mountPrefix,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Track all registered routes with their prefixes for href()
|
|
548
|
+
const mergedRouteMap: Record<string, string> = {};
|
|
549
|
+
|
|
550
|
+
// Wrapper to pass debugPerformance to external createMetricsStore
|
|
551
|
+
const getMetricsStore = () => createMetricsStore(debugPerformance);
|
|
552
|
+
|
|
553
|
+
// Wrapper to pass defaults to error/notFound boundary finders
|
|
554
|
+
const findNearestErrorBoundary = (entry: EntryData | null) =>
|
|
555
|
+
findErrorBoundary(entry, defaultErrorBoundary);
|
|
556
|
+
|
|
557
|
+
const findNearestNotFoundBoundary = (entry: EntryData | null) =>
|
|
558
|
+
findNotFoundBoundary(entry, defaultNotFoundBoundary);
|
|
559
|
+
|
|
560
|
+
// Helper to get handleStore from request context
|
|
561
|
+
const getHandleStore = (): HandleStore | undefined => {
|
|
562
|
+
return getRequestContext()?._handleStore;
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// Track a pending handler promise (non-blocking)
|
|
566
|
+
const trackHandler = <T>(promise: Promise<T>): Promise<T> => {
|
|
567
|
+
const store = getHandleStore();
|
|
568
|
+
return store ? store.track(promise) : promise;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
// Wrapper for wrapLoaderWithErrorHandling that uses router's error boundary finder
|
|
572
|
+
// Includes onError callback for loader error notification
|
|
573
|
+
function wrapLoaderPromise<T>(
|
|
574
|
+
promise: Promise<T>,
|
|
575
|
+
entry: EntryData,
|
|
576
|
+
segmentId: string,
|
|
577
|
+
pathname: string,
|
|
578
|
+
errorContext?: {
|
|
579
|
+
request: Request;
|
|
580
|
+
url: URL;
|
|
581
|
+
routeKey?: string;
|
|
582
|
+
params?: Record<string, string>;
|
|
583
|
+
env?: TEnv;
|
|
584
|
+
isPartial?: boolean;
|
|
585
|
+
requestStartTime?: number;
|
|
586
|
+
}
|
|
587
|
+
): Promise<LoaderDataResult<T>> {
|
|
588
|
+
return wrapLoaderWithErrorHandling(
|
|
589
|
+
promise,
|
|
590
|
+
entry,
|
|
591
|
+
segmentId,
|
|
592
|
+
pathname,
|
|
593
|
+
findNearestErrorBoundary,
|
|
594
|
+
createErrorInfo,
|
|
595
|
+
// Invoke onError when loader fails
|
|
596
|
+
errorContext
|
|
597
|
+
? (error, ctx) => {
|
|
598
|
+
callOnError(error, "loader", {
|
|
599
|
+
request: errorContext.request,
|
|
600
|
+
url: errorContext.url,
|
|
601
|
+
routeKey: errorContext.routeKey,
|
|
602
|
+
params: errorContext.params,
|
|
603
|
+
segmentId: ctx.segmentId,
|
|
604
|
+
segmentType: "loader",
|
|
605
|
+
loaderName: ctx.loaderName,
|
|
606
|
+
env: errorContext.env,
|
|
607
|
+
isPartial: errorContext.isPartial,
|
|
608
|
+
handledByBoundary: ctx.handledByBoundary,
|
|
609
|
+
requestStartTime: errorContext.requestStartTime,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
: undefined
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Wrapper for findMatch that uses routesEntries
|
|
617
|
+
function findMatch(pathname: string) {
|
|
618
|
+
return findRouteMatch(pathname, routesEntries);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Resolve loaders for an entry and emit segments
|
|
623
|
+
* Loaders are run lazily via ctx.use() and memoized for parallel execution
|
|
624
|
+
*
|
|
625
|
+
* @param shortCodeOverride - Optional override for the shortCode used in segment IDs.
|
|
626
|
+
* For parallel entries, pass the parent layout/route's shortCode so loaders
|
|
627
|
+
* are correctly associated in the segment tree.
|
|
628
|
+
*/
|
|
629
|
+
async function resolveLoaders(
|
|
630
|
+
entry: EntryData,
|
|
631
|
+
ctx: HandlerContext<any, TEnv>,
|
|
632
|
+
belongsToRoute: boolean,
|
|
633
|
+
shortCodeOverride?: string
|
|
634
|
+
): Promise<ResolvedSegment[]> {
|
|
635
|
+
const loaderEntries = entry.loader ?? [];
|
|
636
|
+
if (loaderEntries.length === 0) return [];
|
|
637
|
+
|
|
638
|
+
const shortCode = shortCodeOverride ?? entry.shortCode;
|
|
639
|
+
|
|
640
|
+
// Check if entry has loading property (cache entries don't)
|
|
641
|
+
const hasLoading = "loading" in entry && entry.loading !== undefined;
|
|
642
|
+
const loadingDisabled = hasLoading && entry.loading === false;
|
|
643
|
+
|
|
644
|
+
// Trigger all loaders in parallel via ctx.use() (memoized, so safe to call multiple times)
|
|
645
|
+
// Don't await - wrap promises with error handling for deferred client-side resolution
|
|
646
|
+
return Promise.all(
|
|
647
|
+
loaderEntries.map(async ({ loader }, i) => {
|
|
648
|
+
const segmentId = `${shortCode}D${i}.${loader.$$id}`;
|
|
649
|
+
return {
|
|
650
|
+
id: segmentId,
|
|
651
|
+
namespace: entry.id,
|
|
652
|
+
type: "loader" as const,
|
|
653
|
+
index: i,
|
|
654
|
+
component: null, // Loaders don't render directly
|
|
655
|
+
params: ctx.params,
|
|
656
|
+
loaderId: loader.$$id,
|
|
657
|
+
loaderData: await wrapLoaderPromise(
|
|
658
|
+
loadingDisabled ? await ctx.use(loader) : ctx.use(loader),
|
|
659
|
+
entry,
|
|
660
|
+
segmentId,
|
|
661
|
+
ctx.pathname
|
|
662
|
+
),
|
|
663
|
+
belongsToRoute,
|
|
664
|
+
};
|
|
665
|
+
})
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Result of resolving loaders with revalidation
|
|
671
|
+
* Contains both segments to render and all matched segment IDs
|
|
672
|
+
*/
|
|
673
|
+
interface LoaderRevalidationResult {
|
|
674
|
+
segments: ResolvedSegment[];
|
|
675
|
+
matchedIds: string[];
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Resolve loaders with revalidation awareness (for partial rendering)
|
|
680
|
+
* Checks each loader's revalidation functions before deciding to emit segment
|
|
681
|
+
* Loaders are run lazily via ctx.use() - this function only handles segment emission
|
|
682
|
+
* Returns both segments to render AND all matched segment IDs (including skipped ones)
|
|
683
|
+
*
|
|
684
|
+
* @param shortCodeOverride - Optional override for the shortCode used in segment IDs.
|
|
685
|
+
* For parallel entries, pass the parent layout/route's shortCode so loaders
|
|
686
|
+
* are correctly associated in the segment tree.
|
|
687
|
+
*/
|
|
688
|
+
async function resolveLoadersWithRevalidation(
|
|
689
|
+
entry: EntryData,
|
|
690
|
+
ctx: HandlerContext<any, TEnv>,
|
|
691
|
+
belongsToRoute: boolean,
|
|
692
|
+
clientSegmentIds: Set<string>,
|
|
693
|
+
prevParams: Record<string, string>,
|
|
694
|
+
request: Request,
|
|
695
|
+
prevUrl: URL,
|
|
696
|
+
nextUrl: URL,
|
|
697
|
+
routeKey: string,
|
|
698
|
+
actionContext?: {
|
|
699
|
+
actionId?: string;
|
|
700
|
+
actionUrl?: URL;
|
|
701
|
+
actionResult?: any;
|
|
702
|
+
formData?: FormData;
|
|
703
|
+
},
|
|
704
|
+
shortCodeOverride?: string,
|
|
705
|
+
stale?: boolean
|
|
706
|
+
): Promise<LoaderRevalidationResult> {
|
|
707
|
+
const loaderEntries = entry.loader ?? [];
|
|
708
|
+
if (loaderEntries.length === 0) return { segments: [], matchedIds: [] };
|
|
709
|
+
|
|
710
|
+
const shortCode = shortCodeOverride ?? entry.shortCode;
|
|
711
|
+
|
|
712
|
+
// Build segment IDs and matchedIds upfront
|
|
713
|
+
const loaderMeta = loaderEntries.map(
|
|
714
|
+
({ loader, revalidate: loaderRevalidateFns }, i) => ({
|
|
715
|
+
loader,
|
|
716
|
+
loaderRevalidateFns,
|
|
717
|
+
segmentId: `${shortCode}D${i}.${loader.$$id}`,
|
|
718
|
+
index: i,
|
|
719
|
+
})
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
const matchedIds = loaderMeta.map((m) => m.segmentId);
|
|
723
|
+
|
|
724
|
+
// Phase 1: Check all revalidation in parallel
|
|
725
|
+
const revalidationChecks = await Promise.all(
|
|
726
|
+
loaderMeta.map(
|
|
727
|
+
async ({ loader, loaderRevalidateFns, segmentId, index }) => {
|
|
728
|
+
const shouldRun = await revalidate(
|
|
729
|
+
async () => {
|
|
730
|
+
// New segment - always run
|
|
731
|
+
if (!clientSegmentIds.has(segmentId)) return true;
|
|
732
|
+
|
|
733
|
+
// Create dummy segment for evaluation
|
|
734
|
+
const dummySegment: ResolvedSegment = {
|
|
735
|
+
id: segmentId,
|
|
736
|
+
namespace: entry.id,
|
|
737
|
+
type: "loader",
|
|
738
|
+
index,
|
|
739
|
+
component: null,
|
|
740
|
+
params: ctx.params,
|
|
741
|
+
loaderId: loader.$$id,
|
|
742
|
+
belongsToRoute,
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
// Evaluate loader's revalidation functions
|
|
746
|
+
return await evaluateRevalidation({
|
|
747
|
+
segment: dummySegment,
|
|
748
|
+
prevParams,
|
|
749
|
+
getPrevSegment: null,
|
|
750
|
+
request,
|
|
751
|
+
prevUrl,
|
|
752
|
+
nextUrl,
|
|
753
|
+
revalidations: loaderRevalidateFns.map((fn, j) => ({
|
|
754
|
+
name: `loader-revalidate${j}`,
|
|
755
|
+
fn,
|
|
756
|
+
})),
|
|
757
|
+
routeKey,
|
|
758
|
+
context: ctx,
|
|
759
|
+
actionContext,
|
|
760
|
+
stale,
|
|
761
|
+
});
|
|
762
|
+
},
|
|
763
|
+
async () => true,
|
|
764
|
+
() => false
|
|
765
|
+
);
|
|
766
|
+
return { shouldRun, loader, segmentId, index };
|
|
767
|
+
}
|
|
768
|
+
)
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
// Phase 2: Build segments for loaders that need revalidation
|
|
772
|
+
// Don't await - wrap promises with error handling for deferred client-side resolution
|
|
773
|
+
const loadersToRun = revalidationChecks.filter((c) => c.shouldRun);
|
|
774
|
+
const segments: ResolvedSegment[] = loadersToRun.map(
|
|
775
|
+
({ loader, segmentId, index }) => ({
|
|
776
|
+
id: segmentId,
|
|
777
|
+
namespace: entry.id,
|
|
778
|
+
type: "loader" as const,
|
|
779
|
+
index,
|
|
780
|
+
component: null,
|
|
781
|
+
params: ctx.params,
|
|
782
|
+
loaderId: loader.$$id,
|
|
783
|
+
loaderData: wrapLoaderPromise(
|
|
784
|
+
ctx.use(loader),
|
|
785
|
+
entry,
|
|
786
|
+
segmentId,
|
|
787
|
+
ctx.pathname
|
|
788
|
+
),
|
|
789
|
+
belongsToRoute,
|
|
790
|
+
})
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
return { segments, matchedIds };
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Resolve segments from EntryData
|
|
797
|
+
* Executes middlewares, loaders, parallels, and handlers in correct order
|
|
798
|
+
* Returns array: [main segment, ...orphan layout segments]
|
|
799
|
+
*/
|
|
800
|
+
async function resolveSegment(
|
|
801
|
+
entry: EntryData,
|
|
802
|
+
routeKey: string,
|
|
803
|
+
params: Record<string, string>,
|
|
804
|
+
context: HandlerContext<any, TEnv>,
|
|
805
|
+
loaderPromises: Map<string, Promise<any>>,
|
|
806
|
+
isRouteEntry: boolean = false
|
|
807
|
+
): Promise<ResolvedSegment[]> {
|
|
808
|
+
const segments: ResolvedSegment[] = [];
|
|
809
|
+
|
|
810
|
+
if (entry.type === "layout" || entry.type === "cache") {
|
|
811
|
+
// Layout/Cache execution order:
|
|
812
|
+
// 1. Loaders → 2. Parallels (emit segments) → 3. Handler (emit segment) → 4. Orphan Layouts
|
|
813
|
+
// Note: Middleware is now collected and executed at the top level (coreRequestHandler)
|
|
814
|
+
|
|
815
|
+
// Step 1: Run layout loaders
|
|
816
|
+
const loaderSegments = await resolveLoaders(
|
|
817
|
+
entry,
|
|
818
|
+
context,
|
|
819
|
+
false // Parent chain layouts don't belong to specific route
|
|
820
|
+
);
|
|
821
|
+
segments.push(...loaderSegments);
|
|
822
|
+
|
|
823
|
+
// Step 3: Process and emit layout parallel segments
|
|
824
|
+
for (const parallelEntry of entry.parallel) {
|
|
825
|
+
const parallelSegments = await resolveParallelEntry(
|
|
826
|
+
parallelEntry,
|
|
827
|
+
params,
|
|
828
|
+
context,
|
|
829
|
+
false, // Parent chain parallels don't belong to specific route
|
|
830
|
+
entry.shortCode // Pass parent layout's shortCode for segment ID association
|
|
831
|
+
);
|
|
832
|
+
segments.push(...parallelSegments);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Step 4: Execute layout handler and emit layout segment
|
|
836
|
+
// Set current segment ID for handle data attribution
|
|
837
|
+
context._currentSegmentId = entry.shortCode;
|
|
838
|
+
const component =
|
|
839
|
+
typeof entry.handler === "function"
|
|
840
|
+
? await entry.handler(context)
|
|
841
|
+
: entry.handler;
|
|
842
|
+
|
|
843
|
+
segments.push({
|
|
844
|
+
id: entry.shortCode,
|
|
845
|
+
namespace: entry.id,
|
|
846
|
+
type: "layout", // Cache entries also emit "layout" type segments
|
|
847
|
+
index: 0,
|
|
848
|
+
component,
|
|
849
|
+
loading: entry.loading === false ? null : entry.loading,
|
|
850
|
+
params,
|
|
851
|
+
belongsToRoute: false, // Parent chain layouts/cache don't belong to specific route
|
|
852
|
+
layoutName: entry.id,
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// Step 5: Process orphan layouts
|
|
856
|
+
for (const orphan of entry.layout) {
|
|
857
|
+
const orphanSegments = await resolveOrphanLayout(
|
|
858
|
+
orphan,
|
|
859
|
+
params,
|
|
860
|
+
context,
|
|
861
|
+
loaderPromises,
|
|
862
|
+
false // Parent chain layouts don't belong to specific route
|
|
863
|
+
);
|
|
864
|
+
segments.push(...orphanSegments);
|
|
865
|
+
}
|
|
866
|
+
} else if (entry.type === "route") {
|
|
867
|
+
// Route execution order:
|
|
868
|
+
// 1. Route Loader → 2. Orphan Layouts → 3. Route Parallels (emit segments) → 4. Route Handler (emit segment)
|
|
869
|
+
// Note: Route middleware is now collected and executed at the top level (coreRequestHandler)
|
|
870
|
+
|
|
871
|
+
// Step 1: Run route loaders
|
|
872
|
+
const loaderSegments = await resolveLoaders(
|
|
873
|
+
entry,
|
|
874
|
+
context,
|
|
875
|
+
true // Route loaders belong to the route
|
|
876
|
+
);
|
|
877
|
+
segments.push(...loaderSegments);
|
|
878
|
+
|
|
879
|
+
// Step 3: Process orphan layouts first
|
|
880
|
+
for (const orphan of entry.layout) {
|
|
881
|
+
const orphanSegments = await resolveOrphanLayout(
|
|
882
|
+
orphan,
|
|
883
|
+
params,
|
|
884
|
+
context,
|
|
885
|
+
loaderPromises,
|
|
886
|
+
true // Route's orphan layouts belong to the route
|
|
887
|
+
);
|
|
888
|
+
segments.push(...orphanSegments);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Step 4: Process and emit route parallel segments
|
|
892
|
+
for (const parallelEntry of entry.parallel) {
|
|
893
|
+
const parallelSegments = await resolveParallelEntry(
|
|
894
|
+
parallelEntry,
|
|
895
|
+
params,
|
|
896
|
+
context,
|
|
897
|
+
true, // Route's parallels belong to the route
|
|
898
|
+
entry.shortCode // Pass parent route's shortCode for segment ID association
|
|
899
|
+
);
|
|
900
|
+
segments.push(...parallelSegments);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Step 5: Execute route handler and emit route segment
|
|
904
|
+
// If loading is defined, wrap in Suspense for RSC streaming
|
|
905
|
+
// This allows the fallback to be sent immediately while content streams in
|
|
906
|
+
// Set current segment ID for handle data attribution
|
|
907
|
+
context._currentSegmentId = entry.shortCode;
|
|
908
|
+
let component: ReactNode | Promise<ReactNode>;
|
|
909
|
+
if (entry.loading) {
|
|
910
|
+
const result = entry.handler(context);
|
|
911
|
+
component = result instanceof Promise ? trackHandler(result) : result;
|
|
912
|
+
} else {
|
|
913
|
+
component = await entry.handler(context);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
segments.push({
|
|
917
|
+
id: entry.shortCode,
|
|
918
|
+
namespace: entry.id,
|
|
919
|
+
type: "route",
|
|
920
|
+
index: 0,
|
|
921
|
+
component,
|
|
922
|
+
loading: entry.loading === false ? null : entry.loading,
|
|
923
|
+
params,
|
|
924
|
+
belongsToRoute: true, // Route always belongs to itself
|
|
925
|
+
});
|
|
926
|
+
} else {
|
|
927
|
+
throw new Error(`Unknown entry type: ${(entry as any).type}`);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return segments;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Helper: Resolve orphan layout with its middlewares, loaders, and parallels
|
|
935
|
+
* Also handles cache entries in the layout array (structural boundaries)
|
|
936
|
+
*/
|
|
937
|
+
async function resolveOrphanLayout(
|
|
938
|
+
orphan: EntryData,
|
|
939
|
+
params: Record<string, string>,
|
|
940
|
+
context: HandlerContext<any, TEnv>,
|
|
941
|
+
loaderPromises: Map<string, Promise<any>>,
|
|
942
|
+
belongsToRoute: boolean
|
|
943
|
+
): Promise<ResolvedSegment[]> {
|
|
944
|
+
// Orphans must be layouts or cache entries
|
|
945
|
+
invariant(
|
|
946
|
+
orphan.type === "layout" || orphan.type === "cache",
|
|
947
|
+
`Expected orphan to be a layout or cache, got: ${orphan.type}`
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
// Orphan Loader → Orphan Parallels → Orphan Handler
|
|
951
|
+
// Note: Orphan middleware is now collected and executed at the top level (coreRequestHandler)
|
|
952
|
+
|
|
953
|
+
// Step 1: Run orphan loaders
|
|
954
|
+
const loaderSegments = await resolveLoaders(
|
|
955
|
+
orphan,
|
|
956
|
+
context,
|
|
957
|
+
belongsToRoute
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
// Step 3: Process and emit orphan parallel segments
|
|
961
|
+
const segments: ResolvedSegment[] = [...loaderSegments];
|
|
962
|
+
for (const parallelEntry of orphan.parallel) {
|
|
963
|
+
const parallelSegments = await resolveParallelEntry(
|
|
964
|
+
parallelEntry,
|
|
965
|
+
params,
|
|
966
|
+
context,
|
|
967
|
+
belongsToRoute,
|
|
968
|
+
orphan.shortCode // Pass parent orphan layout's shortCode for segment ID association
|
|
969
|
+
);
|
|
970
|
+
segments.push(...parallelSegments);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Step 4: Execute orphan handler and emit layout segment
|
|
974
|
+
const component =
|
|
975
|
+
typeof orphan.handler === "function"
|
|
976
|
+
? await orphan.handler(context)
|
|
977
|
+
: orphan.handler;
|
|
978
|
+
|
|
979
|
+
segments.push({
|
|
980
|
+
id: orphan.shortCode,
|
|
981
|
+
namespace: orphan.id,
|
|
982
|
+
type: "layout",
|
|
983
|
+
index: 0,
|
|
984
|
+
component,
|
|
985
|
+
params,
|
|
986
|
+
belongsToRoute,
|
|
987
|
+
layoutName: orphan.id,
|
|
988
|
+
loading: orphan.loading === false ? null : orphan.loading,
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
return segments;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Check if an intercept's when conditions are satisfied
|
|
996
|
+
* All when() functions must return true for the intercept to activate.
|
|
997
|
+
* If no when() conditions are defined, the intercept always activates.
|
|
998
|
+
*
|
|
999
|
+
* IMPORTANT: During action revalidation, when() is NOT evaluated.
|
|
1000
|
+
* The intercept was already activated during navigation, and we preserve
|
|
1001
|
+
* that state to avoid accidentally closing modals after actions.
|
|
1002
|
+
*/
|
|
1003
|
+
function evaluateInterceptWhen(
|
|
1004
|
+
intercept: InterceptEntry,
|
|
1005
|
+
selectorContext: InterceptSelectorContext | null,
|
|
1006
|
+
isAction: boolean
|
|
1007
|
+
): boolean {
|
|
1008
|
+
// During action revalidation, skip when() evaluation - preserve current state
|
|
1009
|
+
// The intercept was already activated during navigation
|
|
1010
|
+
if (isAction) {
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// If no when conditions, always intercept (backwards compatible)
|
|
1015
|
+
if (!intercept.when || intercept.when.length === 0) {
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// If no selector context provided, can't evaluate - skip intercept
|
|
1020
|
+
if (!selectorContext) {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// All when conditions must return true (AND logic)
|
|
1025
|
+
return intercept.when.every((fn) => fn(selectorContext));
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Find an intercept for the target route by walking up the entry chain
|
|
1030
|
+
* Returns the first (innermost) matching intercept along with the entry that defines it
|
|
1031
|
+
*
|
|
1032
|
+
* Intercepts are "lazy parallels" that only activate during soft navigation.
|
|
1033
|
+
* They render alternative content in a named slot (like @modal) instead of the
|
|
1034
|
+
* route's normal handler.
|
|
1035
|
+
*
|
|
1036
|
+
* @param targetRouteKey - The route key to find an intercept for (e.g., "card")
|
|
1037
|
+
* @param fromEntry - Starting entry to walk up from (usually the route entry)
|
|
1038
|
+
* @param selectorContext - Navigation context for evaluating when() conditions
|
|
1039
|
+
* @param isAction - Whether this is an action revalidation (skips when() evaluation)
|
|
1040
|
+
* @returns The matching intercept and its defining entry, or null if none found
|
|
1041
|
+
*/
|
|
1042
|
+
function findInterceptForRoute(
|
|
1043
|
+
targetRouteKey: string,
|
|
1044
|
+
fromEntry: EntryData | null,
|
|
1045
|
+
selectorContext: InterceptSelectorContext | null = null,
|
|
1046
|
+
isAction: boolean = false
|
|
1047
|
+
): { intercept: InterceptEntry; entry: EntryData } | null {
|
|
1048
|
+
let current: EntryData | null = fromEntry;
|
|
1049
|
+
|
|
1050
|
+
while (current) {
|
|
1051
|
+
// Check if this entry has intercepts defined
|
|
1052
|
+
if (current.intercept && current.intercept.length > 0) {
|
|
1053
|
+
// Find intercept matching the target route name and when conditions
|
|
1054
|
+
for (const intercept of current.intercept) {
|
|
1055
|
+
if (
|
|
1056
|
+
intercept.routeName === targetRouteKey &&
|
|
1057
|
+
evaluateInterceptWhen(intercept, selectorContext, isAction)
|
|
1058
|
+
) {
|
|
1059
|
+
return { intercept, entry: current };
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Also check sibling layouts for intercepts
|
|
1065
|
+
// Intercepts are defined as siblings in the route tree - e.g., an intercept
|
|
1066
|
+
// like (.)card/[cardId] is placed alongside the parent route's layouts
|
|
1067
|
+
if (current.layout && current.layout.length > 0) {
|
|
1068
|
+
for (const siblingLayout of current.layout) {
|
|
1069
|
+
if (siblingLayout.intercept && siblingLayout.intercept.length > 0) {
|
|
1070
|
+
for (const intercept of siblingLayout.intercept) {
|
|
1071
|
+
if (
|
|
1072
|
+
intercept.routeName === targetRouteKey &&
|
|
1073
|
+
evaluateInterceptWhen(intercept, selectorContext, isAction)
|
|
1074
|
+
) {
|
|
1075
|
+
return { intercept, entry: siblingLayout };
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
current = current.parent;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Resolve an intercept entry and emit segment with the slot name
|
|
1090
|
+
* Similar to parallel entry resolution but for intercept handlers.
|
|
1091
|
+
*
|
|
1092
|
+
* Intercepts can have their own middleware, loaders, revalidate, and loading.
|
|
1093
|
+
* The handler is rendered in the named slot (e.g., @modal).
|
|
1094
|
+
*
|
|
1095
|
+
* @param interceptEntry - The intercept definition
|
|
1096
|
+
* @param parentEntry - The entry that defines the intercept (for shortCode)
|
|
1097
|
+
* @param params - URL parameters
|
|
1098
|
+
* @param context - Handler context
|
|
1099
|
+
* @param belongsToRoute - Whether this intercept belongs to the matched route
|
|
1100
|
+
* @param revalidationContext - Optional revalidation context for partial updates
|
|
1101
|
+
*/
|
|
1102
|
+
async function resolveInterceptEntry(
|
|
1103
|
+
interceptEntry: InterceptEntry,
|
|
1104
|
+
parentEntry: EntryData,
|
|
1105
|
+
params: Record<string, string>,
|
|
1106
|
+
context: HandlerContext<any, TEnv>,
|
|
1107
|
+
belongsToRoute: boolean = true,
|
|
1108
|
+
revalidationContext?: {
|
|
1109
|
+
clientSegmentIds: Set<string>;
|
|
1110
|
+
prevParams: Record<string, string>;
|
|
1111
|
+
request: Request;
|
|
1112
|
+
prevUrl: URL;
|
|
1113
|
+
nextUrl: URL;
|
|
1114
|
+
routeKey: string;
|
|
1115
|
+
actionContext?: {
|
|
1116
|
+
actionId?: string;
|
|
1117
|
+
actionUrl?: URL;
|
|
1118
|
+
actionResult?: any;
|
|
1119
|
+
formData?: FormData;
|
|
1120
|
+
};
|
|
1121
|
+
stale?: boolean;
|
|
1122
|
+
}
|
|
1123
|
+
): Promise<ResolvedSegment[]> {
|
|
1124
|
+
const segments: ResolvedSegment[] = [];
|
|
1125
|
+
|
|
1126
|
+
// Step 1: Execute intercept middleware
|
|
1127
|
+
if (interceptEntry.middleware.length > 0) {
|
|
1128
|
+
// Get stubResponse from request context for header/cookie collection
|
|
1129
|
+
const requestCtx = getRequestContext();
|
|
1130
|
+
if (!requestCtx?.res) {
|
|
1131
|
+
throw new Error(
|
|
1132
|
+
"Request context with stubResponse is required for intercept middleware"
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
const middlewareResponse = await executeInterceptMiddleware(
|
|
1136
|
+
interceptEntry.middleware,
|
|
1137
|
+
context.request,
|
|
1138
|
+
context.env,
|
|
1139
|
+
params,
|
|
1140
|
+
context.var as Record<string, any>,
|
|
1141
|
+
requestCtx.res
|
|
1142
|
+
);
|
|
1143
|
+
if (middlewareResponse) throw middlewareResponse;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Step 2: Collect intercept loaders as promises (with revalidation check)
|
|
1147
|
+
// These will be attached directly to the intercept segment for streaming
|
|
1148
|
+
const loaderPromises: Promise<any>[] = [];
|
|
1149
|
+
const loaderIds: string[] = [];
|
|
1150
|
+
|
|
1151
|
+
for (let i = 0; i < interceptEntry.loader.length; i++) {
|
|
1152
|
+
const { loader, revalidate: loaderRevalidateFns } =
|
|
1153
|
+
interceptEntry.loader[i];
|
|
1154
|
+
const segmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}D${i}.${loader.$$id}`;
|
|
1155
|
+
|
|
1156
|
+
// Check revalidation if context provided (partial updates)
|
|
1157
|
+
if (revalidationContext) {
|
|
1158
|
+
const {
|
|
1159
|
+
clientSegmentIds,
|
|
1160
|
+
prevParams,
|
|
1161
|
+
request,
|
|
1162
|
+
prevUrl,
|
|
1163
|
+
nextUrl,
|
|
1164
|
+
routeKey,
|
|
1165
|
+
actionContext,
|
|
1166
|
+
stale,
|
|
1167
|
+
} = revalidationContext;
|
|
1168
|
+
|
|
1169
|
+
// Check if client has the parent intercept segment (loaders are embedded, not separate segments)
|
|
1170
|
+
const interceptSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
|
|
1171
|
+
if (clientSegmentIds.has(interceptSegmentId)) {
|
|
1172
|
+
// Create dummy segment for evaluation
|
|
1173
|
+
const dummySegment: ResolvedSegment = {
|
|
1174
|
+
id: segmentId,
|
|
1175
|
+
namespace: `intercept:${interceptEntry.routeName}`,
|
|
1176
|
+
type: "loader",
|
|
1177
|
+
index: i,
|
|
1178
|
+
component: null,
|
|
1179
|
+
params,
|
|
1180
|
+
loaderId: loader.$$id,
|
|
1181
|
+
belongsToRoute,
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
const shouldRevalidate = await evaluateRevalidation({
|
|
1185
|
+
segment: dummySegment,
|
|
1186
|
+
prevParams,
|
|
1187
|
+
getPrevSegment: null,
|
|
1188
|
+
request,
|
|
1189
|
+
prevUrl,
|
|
1190
|
+
nextUrl,
|
|
1191
|
+
revalidations: loaderRevalidateFns.map((fn, j) => ({
|
|
1192
|
+
name: `intercept-loader-revalidate${j}`,
|
|
1193
|
+
fn,
|
|
1194
|
+
})),
|
|
1195
|
+
routeKey,
|
|
1196
|
+
context,
|
|
1197
|
+
actionContext,
|
|
1198
|
+
stale,
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
if (!shouldRevalidate) {
|
|
1202
|
+
console.log(
|
|
1203
|
+
`[Router] Intercept loader ${loader.$$id} skipped (revalidation=false)`
|
|
1204
|
+
);
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
console.log(
|
|
1208
|
+
`[Router] Intercept loader ${loader.$$id} revalidating (stale=${stale})`
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
loaderIds.push(loader.$$id);
|
|
1214
|
+
loaderPromises.push(
|
|
1215
|
+
wrapLoaderPromise(
|
|
1216
|
+
context.use(loader),
|
|
1217
|
+
parentEntry,
|
|
1218
|
+
segmentId,
|
|
1219
|
+
context.pathname
|
|
1220
|
+
)
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Step 3: Execute intercept handler and prepare component
|
|
1225
|
+
// Get handler result - don't await if we have loading (enables streaming)
|
|
1226
|
+
const handlerResult =
|
|
1227
|
+
typeof interceptEntry.handler === "function"
|
|
1228
|
+
? interceptEntry.handler(context)
|
|
1229
|
+
: interceptEntry.handler;
|
|
1230
|
+
|
|
1231
|
+
// Step 4: Prepare layout element (if defined)
|
|
1232
|
+
// Layout will be applied in segment-system, not here
|
|
1233
|
+
let layoutElement: ReactNode | undefined;
|
|
1234
|
+
if (interceptEntry.layout) {
|
|
1235
|
+
layoutElement =
|
|
1236
|
+
typeof interceptEntry.layout === "function"
|
|
1237
|
+
? await interceptEntry.layout(context)
|
|
1238
|
+
: interceptEntry.layout;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Determine if we should await the handler result and loaders
|
|
1242
|
+
// If we have loading, DON'T await - let Suspense handle streaming
|
|
1243
|
+
let component: ReactNode | Promise<ReactNode>;
|
|
1244
|
+
let loaderDataPromise: Promise<any[]> | any[] | undefined;
|
|
1245
|
+
|
|
1246
|
+
if (interceptEntry.loading && loaderPromises.length > 0) {
|
|
1247
|
+
// Has loading skeleton - keep everything as Promises for streaming
|
|
1248
|
+
// Don't track intercept handlers - they're parallels and shouldn't block handle data
|
|
1249
|
+
component =
|
|
1250
|
+
handlerResult instanceof Promise
|
|
1251
|
+
? handlerResult
|
|
1252
|
+
: Promise.resolve(handlerResult);
|
|
1253
|
+
loaderDataPromise = Promise.all(loaderPromises);
|
|
1254
|
+
} else if (loaderPromises.length > 0) {
|
|
1255
|
+
// No loading skeleton - await loaders and component
|
|
1256
|
+
loaderDataPromise = await Promise.all(loaderPromises);
|
|
1257
|
+
component =
|
|
1258
|
+
handlerResult instanceof Promise ? await handlerResult : handlerResult;
|
|
1259
|
+
} else {
|
|
1260
|
+
// No loaders - don't track intercept handlers (they're parallels)
|
|
1261
|
+
component =
|
|
1262
|
+
interceptEntry.loading && handlerResult instanceof Promise
|
|
1263
|
+
? handlerResult
|
|
1264
|
+
: handlerResult instanceof Promise
|
|
1265
|
+
? await handlerResult
|
|
1266
|
+
: handlerResult;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const interceptSegment = {
|
|
1270
|
+
id: `${parentEntry.shortCode}.${interceptEntry.slotName}`,
|
|
1271
|
+
namespace: `intercept:${interceptEntry.routeName}`,
|
|
1272
|
+
type: "parallel" as const,
|
|
1273
|
+
index: 0,
|
|
1274
|
+
component,
|
|
1275
|
+
loading: interceptEntry.loading === false ? null : interceptEntry.loading,
|
|
1276
|
+
layout: layoutElement,
|
|
1277
|
+
params,
|
|
1278
|
+
slot: interceptEntry.slotName,
|
|
1279
|
+
belongsToRoute,
|
|
1280
|
+
parallelName: `intercept:${interceptEntry.routeName}.${interceptEntry.slotName}`,
|
|
1281
|
+
// Attach loader info directly to segment for streaming
|
|
1282
|
+
loaderDataPromise,
|
|
1283
|
+
loaderIds: loaderIds.length > 0 ? loaderIds : undefined,
|
|
1284
|
+
};
|
|
1285
|
+
segments.push(interceptSegment);
|
|
1286
|
+
|
|
1287
|
+
return segments;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Helper: Resolve only the loaders for a cached intercept segment.
|
|
1292
|
+
* Used on intercept cache hit to get fresh loader data while keeping cached component/layout.
|
|
1293
|
+
* Returns the fresh loaderDataPromise and loaderIds, or null if no loaders need resolution.
|
|
1294
|
+
*/
|
|
1295
|
+
async function resolveInterceptLoadersOnly(
|
|
1296
|
+
interceptEntry: InterceptEntry,
|
|
1297
|
+
parentEntry: EntryData,
|
|
1298
|
+
params: Record<string, string>,
|
|
1299
|
+
context: HandlerContext<any, TEnv>,
|
|
1300
|
+
belongsToRoute: boolean = true,
|
|
1301
|
+
revalidationContext: {
|
|
1302
|
+
clientSegmentIds: Set<string>;
|
|
1303
|
+
prevParams: Record<string, string>;
|
|
1304
|
+
request: Request;
|
|
1305
|
+
prevUrl: URL;
|
|
1306
|
+
nextUrl: URL;
|
|
1307
|
+
routeKey: string;
|
|
1308
|
+
actionContext?: {
|
|
1309
|
+
actionId?: string;
|
|
1310
|
+
actionUrl?: URL;
|
|
1311
|
+
actionResult?: any;
|
|
1312
|
+
formData?: FormData;
|
|
1313
|
+
};
|
|
1314
|
+
stale?: boolean;
|
|
1315
|
+
}
|
|
1316
|
+
): Promise<{
|
|
1317
|
+
loaderDataPromise: Promise<any[]> | any[];
|
|
1318
|
+
loaderIds: string[];
|
|
1319
|
+
} | null> {
|
|
1320
|
+
if (interceptEntry.loader.length === 0) {
|
|
1321
|
+
return null;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
const loaderPromises: Promise<any>[] = [];
|
|
1325
|
+
const loaderIds: string[] = [];
|
|
1326
|
+
|
|
1327
|
+
const {
|
|
1328
|
+
clientSegmentIds,
|
|
1329
|
+
prevParams,
|
|
1330
|
+
request,
|
|
1331
|
+
prevUrl,
|
|
1332
|
+
nextUrl,
|
|
1333
|
+
routeKey,
|
|
1334
|
+
actionContext,
|
|
1335
|
+
stale,
|
|
1336
|
+
} = revalidationContext;
|
|
1337
|
+
|
|
1338
|
+
for (let i = 0; i < interceptEntry.loader.length; i++) {
|
|
1339
|
+
const { loader, revalidate: loaderRevalidateFns } =
|
|
1340
|
+
interceptEntry.loader[i];
|
|
1341
|
+
const segmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}D${i}.${loader.$$id}`;
|
|
1342
|
+
|
|
1343
|
+
// Check if client has the parent intercept segment (loaders are embedded, not separate segments)
|
|
1344
|
+
const interceptSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
|
|
1345
|
+
if (clientSegmentIds.has(interceptSegmentId)) {
|
|
1346
|
+
// Create dummy segment for evaluation
|
|
1347
|
+
const dummySegment: ResolvedSegment = {
|
|
1348
|
+
id: segmentId,
|
|
1349
|
+
namespace: `intercept:${interceptEntry.routeName}`,
|
|
1350
|
+
type: "loader",
|
|
1351
|
+
index: i,
|
|
1352
|
+
component: null,
|
|
1353
|
+
params,
|
|
1354
|
+
loaderId: loader.$$id,
|
|
1355
|
+
belongsToRoute,
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
const shouldRevalidate = await evaluateRevalidation({
|
|
1359
|
+
segment: dummySegment,
|
|
1360
|
+
prevParams,
|
|
1361
|
+
getPrevSegment: null,
|
|
1362
|
+
request,
|
|
1363
|
+
prevUrl,
|
|
1364
|
+
nextUrl,
|
|
1365
|
+
revalidations: loaderRevalidateFns.map((fn, j) => ({
|
|
1366
|
+
name: `intercept-loader-revalidate${j}`,
|
|
1367
|
+
fn,
|
|
1368
|
+
})),
|
|
1369
|
+
routeKey,
|
|
1370
|
+
context,
|
|
1371
|
+
actionContext,
|
|
1372
|
+
stale,
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
if (!shouldRevalidate) {
|
|
1376
|
+
console.log(
|
|
1377
|
+
`[Router] Intercept loader ${loader.$$id} skipped (cache hit, revalidation=false)`
|
|
1378
|
+
);
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
console.log(
|
|
1382
|
+
`[Router] Intercept loader ${loader.$$id} revalidating on cache hit (stale=${stale})`
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
loaderIds.push(loader.$$id);
|
|
1387
|
+
loaderPromises.push(
|
|
1388
|
+
wrapLoaderPromise(
|
|
1389
|
+
context.use(loader),
|
|
1390
|
+
parentEntry,
|
|
1391
|
+
segmentId,
|
|
1392
|
+
context.pathname
|
|
1393
|
+
)
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
if (loaderPromises.length === 0) {
|
|
1398
|
+
return null;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// If intercept has loading skeleton, keep as Promise for streaming
|
|
1402
|
+
// Otherwise await immediately
|
|
1403
|
+
const loaderDataPromise =
|
|
1404
|
+
interceptEntry.loading !== undefined
|
|
1405
|
+
? Promise.all(loaderPromises)
|
|
1406
|
+
: await Promise.all(loaderPromises);
|
|
1407
|
+
|
|
1408
|
+
return { loaderDataPromise, loaderIds };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Helper: Resolve parallel EntryData with its loaders and slot handlers
|
|
1413
|
+
* Parallels now have their own loaders, revalidate functions, and loading components
|
|
1414
|
+
*
|
|
1415
|
+
* @param parentShortCode - The shortCode of the parent layout/route that owns this parallel.
|
|
1416
|
+
* Used for segment IDs so the segment tree can correctly associate parallels with their parent.
|
|
1417
|
+
*/
|
|
1418
|
+
async function resolveParallelEntry(
|
|
1419
|
+
parallelEntry: EntryData,
|
|
1420
|
+
params: Record<string, string>,
|
|
1421
|
+
context: HandlerContext<any, TEnv>,
|
|
1422
|
+
belongsToRoute: boolean,
|
|
1423
|
+
parentShortCode: string
|
|
1424
|
+
): Promise<ResolvedSegment[]> {
|
|
1425
|
+
invariant(
|
|
1426
|
+
parallelEntry.type === "parallel",
|
|
1427
|
+
`Expected parallel entry, got: ${parallelEntry.type}`
|
|
1428
|
+
);
|
|
1429
|
+
|
|
1430
|
+
const segments: ResolvedSegment[] = [];
|
|
1431
|
+
|
|
1432
|
+
// Step 1: Execute each slot handler first (they trigger loaders via ctx.use())
|
|
1433
|
+
// Handlers are NOT awaited if loading is defined - this keeps Promises pending for Suspense
|
|
1434
|
+
const slots = parallelEntry.handler as Record<
|
|
1435
|
+
`@${string}`,
|
|
1436
|
+
| ((ctx: HandlerContext<any, TEnv>) => ReactNode | Promise<ReactNode>)
|
|
1437
|
+
| ReactNode
|
|
1438
|
+
>;
|
|
1439
|
+
|
|
1440
|
+
for (const [slot, handler] of Object.entries(slots)) {
|
|
1441
|
+
// If loading is defined, don't await the handler (stream with Suspense)
|
|
1442
|
+
// Don't track parallel handlers - they shouldn't block handle data
|
|
1443
|
+
let component: ReactNode | Promise<ReactNode>;
|
|
1444
|
+
if (parallelEntry.loading) {
|
|
1445
|
+
const result =
|
|
1446
|
+
typeof handler === "function" ? handler(context) : handler;
|
|
1447
|
+
component = result;
|
|
1448
|
+
} else {
|
|
1449
|
+
component =
|
|
1450
|
+
typeof handler === "function" ? await handler(context) : handler;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Use parent's shortCode so segment tree correctly associates this parallel with its parent
|
|
1454
|
+
segments.push({
|
|
1455
|
+
id: `${parentShortCode}.${slot}`,
|
|
1456
|
+
namespace: parallelEntry.id,
|
|
1457
|
+
type: "parallel",
|
|
1458
|
+
index: 0,
|
|
1459
|
+
component,
|
|
1460
|
+
loading: parallelEntry.loading === false ? null : parallelEntry.loading,
|
|
1461
|
+
params,
|
|
1462
|
+
slot,
|
|
1463
|
+
belongsToRoute,
|
|
1464
|
+
parallelName: `${parallelEntry.id}.${slot}`,
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// Step 2: Resolve loaders AFTER handlers have run
|
|
1469
|
+
// If loading is defined, do NOT await loaders - this keeps handler Promises pending for Suspense
|
|
1470
|
+
// Loader data flows through component props (via ctx.use() in handler)
|
|
1471
|
+
// If no loading, await loaders to create segments for useLoader() support
|
|
1472
|
+
if (!parallelEntry.loading) {
|
|
1473
|
+
const loaderSegments = await resolveLoaders(
|
|
1474
|
+
parallelEntry,
|
|
1475
|
+
context,
|
|
1476
|
+
belongsToRoute,
|
|
1477
|
+
parentShortCode
|
|
1478
|
+
);
|
|
1479
|
+
segments.push(...loaderSegments);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
return segments;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Wrapper that adds error boundary handling to segment resolution
|
|
1487
|
+
* Catches errors during execution and returns error segments if an error boundary exists
|
|
1488
|
+
*
|
|
1489
|
+
* @param entry - The entry to resolve
|
|
1490
|
+
* @param routeKey - Route key for context
|
|
1491
|
+
* @param params - URL parameters
|
|
1492
|
+
* @param context - Handler context
|
|
1493
|
+
* @param loaderPromises - Shared loader promise map
|
|
1494
|
+
* @param resolveFn - The actual resolution function to call
|
|
1495
|
+
* @param errorContext - Additional context for onError callback
|
|
1496
|
+
* @returns Segments from successful resolution, or an error segment if error boundary caught
|
|
1497
|
+
* @throws If error occurs and no error boundary is defined
|
|
1498
|
+
*/
|
|
1499
|
+
async function resolveWithErrorHandling(
|
|
1500
|
+
entry: EntryData,
|
|
1501
|
+
routeKey: string,
|
|
1502
|
+
params: Record<string, string>,
|
|
1503
|
+
context: HandlerContext<any, TEnv>,
|
|
1504
|
+
loaderPromises: Map<string, Promise<any>>,
|
|
1505
|
+
resolveFn: () => Promise<ResolvedSegment[]>,
|
|
1506
|
+
errorContext?: {
|
|
1507
|
+
env?: TEnv;
|
|
1508
|
+
isPartial?: boolean;
|
|
1509
|
+
requestStartTime?: number;
|
|
1510
|
+
}
|
|
1511
|
+
): Promise<ResolvedSegment[]> {
|
|
1512
|
+
try {
|
|
1513
|
+
return await resolveFn();
|
|
1514
|
+
} catch (error) {
|
|
1515
|
+
// Don't catch Response objects (middleware short-circuit)
|
|
1516
|
+
if (error instanceof Response) {
|
|
1517
|
+
throw error;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// Handle DataNotFoundError separately - look for notFoundBoundary first
|
|
1521
|
+
if (error instanceof DataNotFoundError) {
|
|
1522
|
+
const notFoundFallback = findNearestNotFoundBoundary(entry);
|
|
1523
|
+
|
|
1524
|
+
if (notFoundFallback) {
|
|
1525
|
+
// Create notFound info
|
|
1526
|
+
const notFoundInfo = createNotFoundInfo(
|
|
1527
|
+
error,
|
|
1528
|
+
entry.shortCode,
|
|
1529
|
+
entry.type,
|
|
1530
|
+
context.pathname
|
|
1531
|
+
);
|
|
1532
|
+
|
|
1533
|
+
// Invoke onError with notFound context
|
|
1534
|
+
callOnError(error, "handler", {
|
|
1535
|
+
request: context.request,
|
|
1536
|
+
url: context.url,
|
|
1537
|
+
routeKey,
|
|
1538
|
+
params,
|
|
1539
|
+
segmentId: entry.shortCode,
|
|
1540
|
+
segmentType: entry.type as any,
|
|
1541
|
+
env: errorContext?.env,
|
|
1542
|
+
isPartial: errorContext?.isPartial,
|
|
1543
|
+
handledByBoundary: true,
|
|
1544
|
+
metadata: { notFound: true, message: notFoundInfo.message },
|
|
1545
|
+
requestStartTime: errorContext?.requestStartTime,
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
console.log(
|
|
1549
|
+
`[Router] NotFound caught by notFoundBoundary in ${entry.shortCode}:`,
|
|
1550
|
+
notFoundInfo.message
|
|
1551
|
+
);
|
|
1552
|
+
|
|
1553
|
+
// Set response status to 404 for notFound
|
|
1554
|
+
const reqCtx = getRequestContext();
|
|
1555
|
+
if (reqCtx) {
|
|
1556
|
+
reqCtx.res = new Response(null, { status: 404, headers: reqCtx.res.headers });
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// Create and return notFound segment
|
|
1560
|
+
const notFoundSegment = createNotFoundSegment(
|
|
1561
|
+
notFoundInfo,
|
|
1562
|
+
notFoundFallback,
|
|
1563
|
+
entry,
|
|
1564
|
+
params
|
|
1565
|
+
);
|
|
1566
|
+
return [notFoundSegment];
|
|
1567
|
+
}
|
|
1568
|
+
// If no notFoundBoundary, fall through to error boundary handling
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Find nearest error boundary
|
|
1572
|
+
const fallback = findNearestErrorBoundary(entry);
|
|
1573
|
+
|
|
1574
|
+
// Determine segment type for error info
|
|
1575
|
+
const segmentType: ErrorInfo["segmentType"] = entry.type;
|
|
1576
|
+
|
|
1577
|
+
// Create error info
|
|
1578
|
+
const errorInfo = createErrorInfo(error, entry.shortCode, segmentType);
|
|
1579
|
+
|
|
1580
|
+
// Use default fallback if no error boundary found
|
|
1581
|
+
const effectiveFallback = fallback ?? DefaultErrorFallback;
|
|
1582
|
+
|
|
1583
|
+
// Invoke onError callback
|
|
1584
|
+
callOnError(error, "handler", {
|
|
1585
|
+
request: context.request,
|
|
1586
|
+
url: context.url,
|
|
1587
|
+
routeKey,
|
|
1588
|
+
params,
|
|
1589
|
+
segmentId: entry.shortCode,
|
|
1590
|
+
segmentType: entry.type as any,
|
|
1591
|
+
env: errorContext?.env,
|
|
1592
|
+
isPartial: errorContext?.isPartial,
|
|
1593
|
+
handledByBoundary: !!fallback,
|
|
1594
|
+
requestStartTime: errorContext?.requestStartTime,
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
console.log(
|
|
1598
|
+
`[Router] Error caught by ${fallback ? "error boundary" : "default fallback"} in ${entry.shortCode}:`,
|
|
1599
|
+
errorInfo.message
|
|
1600
|
+
);
|
|
1601
|
+
|
|
1602
|
+
// Set response status to 500 for error
|
|
1603
|
+
{
|
|
1604
|
+
const reqCtx = getRequestContext();
|
|
1605
|
+
if (reqCtx) {
|
|
1606
|
+
reqCtx.res = new Response(null, { status: 500, headers: reqCtx.res.headers });
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Create and return error segment
|
|
1611
|
+
const errorSegment = createErrorSegment(
|
|
1612
|
+
errorInfo,
|
|
1613
|
+
effectiveFallback,
|
|
1614
|
+
entry,
|
|
1615
|
+
params
|
|
1616
|
+
);
|
|
1617
|
+
return [errorSegment];
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Resolve all segments for a route (used for single-cache-per-request pattern)
|
|
1623
|
+
* Loops through all entries and resolves them with error handling
|
|
1624
|
+
*/
|
|
1625
|
+
async function resolveAllSegments(
|
|
1626
|
+
entries: EntryData[],
|
|
1627
|
+
routeKey: string,
|
|
1628
|
+
params: Record<string, string>,
|
|
1629
|
+
context: HandlerContext<any, TEnv>,
|
|
1630
|
+
loaderPromises: Map<string, Promise<any>>
|
|
1631
|
+
): Promise<ResolvedSegment[]> {
|
|
1632
|
+
const allSegments: ResolvedSegment[] = [];
|
|
1633
|
+
|
|
1634
|
+
for (const entry of entries) {
|
|
1635
|
+
const resolvedSegments = await resolveWithErrorHandling(
|
|
1636
|
+
entry,
|
|
1637
|
+
routeKey,
|
|
1638
|
+
params,
|
|
1639
|
+
context,
|
|
1640
|
+
loaderPromises,
|
|
1641
|
+
() => resolveSegment(entry, routeKey, params, context, loaderPromises)
|
|
1642
|
+
);
|
|
1643
|
+
allSegments.push(...resolvedSegments);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
return allSegments;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Resolve only loader segments for all entries (used when serving cached non-loader segments)
|
|
1651
|
+
* Loaders are always fresh by default, so we resolve them even on cache hit
|
|
1652
|
+
*/
|
|
1653
|
+
async function resolveLoadersOnly(
|
|
1654
|
+
entries: EntryData[],
|
|
1655
|
+
context: HandlerContext<any, TEnv>
|
|
1656
|
+
): Promise<ResolvedSegment[]> {
|
|
1657
|
+
const loaderSegments: ResolvedSegment[] = [];
|
|
1658
|
+
|
|
1659
|
+
for (const entry of entries) {
|
|
1660
|
+
const belongsToRoute = entry.type === "route";
|
|
1661
|
+
const segments = await resolveLoaders(entry, context, belongsToRoute);
|
|
1662
|
+
loaderSegments.push(...segments);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
return loaderSegments;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* Resolve only loader segments for all entries with revalidation logic (for matchPartial cache hit)
|
|
1670
|
+
* Loaders are always fresh by default, so we resolve them even on cache hit
|
|
1671
|
+
*/
|
|
1672
|
+
async function resolveLoadersOnlyWithRevalidation(
|
|
1673
|
+
entries: EntryData[],
|
|
1674
|
+
context: HandlerContext<any, TEnv>,
|
|
1675
|
+
clientSegmentIds: Set<string>,
|
|
1676
|
+
prevParams: Record<string, string>,
|
|
1677
|
+
request: Request,
|
|
1678
|
+
prevUrl: URL,
|
|
1679
|
+
nextUrl: URL,
|
|
1680
|
+
routeKey: string,
|
|
1681
|
+
actionContext?: ActionContext
|
|
1682
|
+
): Promise<{ segments: ResolvedSegment[]; matchedIds: string[] }> {
|
|
1683
|
+
const allLoaderSegments: ResolvedSegment[] = [];
|
|
1684
|
+
const allMatchedIds: string[] = [];
|
|
1685
|
+
|
|
1686
|
+
for (const entry of entries) {
|
|
1687
|
+
const belongsToRoute = entry.type === "route";
|
|
1688
|
+
const { segments, matchedIds } = await resolveLoadersWithRevalidation(
|
|
1689
|
+
entry,
|
|
1690
|
+
context,
|
|
1691
|
+
belongsToRoute,
|
|
1692
|
+
clientSegmentIds,
|
|
1693
|
+
prevParams,
|
|
1694
|
+
request,
|
|
1695
|
+
prevUrl,
|
|
1696
|
+
nextUrl,
|
|
1697
|
+
routeKey,
|
|
1698
|
+
actionContext
|
|
1699
|
+
);
|
|
1700
|
+
allLoaderSegments.push(...segments);
|
|
1701
|
+
allMatchedIds.push(...matchedIds);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
return { segments: allLoaderSegments, matchedIds: allMatchedIds };
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
/**
|
|
1708
|
+
* Build a map of segment shortCode → entry with revalidate functions
|
|
1709
|
+
* Used to look up revalidation rules for cached segments
|
|
1710
|
+
*/
|
|
1711
|
+
function buildEntryRevalidateMap(
|
|
1712
|
+
entries: EntryData[]
|
|
1713
|
+
): Map<string, { entry: EntryData; revalidate: ShouldRevalidateFn<any, any>[] }> {
|
|
1714
|
+
const map = new Map<string, { entry: EntryData; revalidate: ShouldRevalidateFn<any, any>[] }>();
|
|
1715
|
+
|
|
1716
|
+
function processEntry(entry: EntryData, parentShortCode?: string) {
|
|
1717
|
+
// Map main entry
|
|
1718
|
+
map.set(entry.shortCode, { entry, revalidate: entry.revalidate });
|
|
1719
|
+
|
|
1720
|
+
// Process nested parallels - they use parallelEntry.shortCode.slotName as ID
|
|
1721
|
+
if (entry.type !== "parallel") {
|
|
1722
|
+
for (const parallelEntry of entry.parallel) {
|
|
1723
|
+
if (parallelEntry.type === "parallel") {
|
|
1724
|
+
// Parallel handlers are Record<slotName, handler>
|
|
1725
|
+
const slots = Object.keys(parallelEntry.handler) as `@${string}`[];
|
|
1726
|
+
for (const slot of slots) {
|
|
1727
|
+
// Segment ID uses parallelEntry.shortCode, not parent entry.shortCode
|
|
1728
|
+
const parallelId = `${parallelEntry.shortCode}.${slot}`;
|
|
1729
|
+
map.set(parallelId, { entry: parallelEntry, revalidate: parallelEntry.revalidate });
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// Recursively process nested layouts
|
|
1736
|
+
for (const layoutEntry of entry.layout) {
|
|
1737
|
+
processEntry(layoutEntry);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
for (const entry of entries) {
|
|
1742
|
+
processEntry(entry);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
return map;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* Resolve all segments for a route with revalidation logic (for matchPartial)
|
|
1750
|
+
* Used for single-cache-per-request pattern in partial/navigation requests
|
|
1751
|
+
*/
|
|
1752
|
+
async function resolveAllSegmentsWithRevalidation(
|
|
1753
|
+
entries: EntryData[],
|
|
1754
|
+
routeKey: string,
|
|
1755
|
+
params: Record<string, string>,
|
|
1756
|
+
context: HandlerContext<any, TEnv>,
|
|
1757
|
+
clientSegmentSet: Set<string>,
|
|
1758
|
+
prevParams: Record<string, string>,
|
|
1759
|
+
request: Request,
|
|
1760
|
+
prevUrl: URL,
|
|
1761
|
+
nextUrl: URL,
|
|
1762
|
+
loaderPromises: Map<string, Promise<any>>,
|
|
1763
|
+
actionContext: ActionContext | undefined,
|
|
1764
|
+
interceptResult: { intercept: InterceptEntry; entry: EntryData } | null,
|
|
1765
|
+
localRouteName: string,
|
|
1766
|
+
pathname: string
|
|
1767
|
+
): Promise<{ segments: ResolvedSegment[]; matchedIds: string[] }> {
|
|
1768
|
+
const allSegments: ResolvedSegment[] = [];
|
|
1769
|
+
const matchedIds: string[] = [];
|
|
1770
|
+
|
|
1771
|
+
for (const entry of entries) {
|
|
1772
|
+
// When intercepting, skip route entries - intercept replaces route handler
|
|
1773
|
+
if (entry.type === "route" && interceptResult) {
|
|
1774
|
+
console.log(
|
|
1775
|
+
`[Router.matchPartial] Intercepting "${localRouteName}" - skipping route handler`
|
|
1776
|
+
);
|
|
1777
|
+
matchedIds.push(entry.shortCode);
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// Resolve entry with revalidation logic
|
|
1782
|
+
const nonParallelEntry = entry as Exclude<
|
|
1783
|
+
EntryData,
|
|
1784
|
+
{ type: "parallel" }
|
|
1785
|
+
>;
|
|
1786
|
+
const resolved = await resolveWithRevalidationErrorHandling(
|
|
1787
|
+
nonParallelEntry,
|
|
1788
|
+
params,
|
|
1789
|
+
() =>
|
|
1790
|
+
resolveSegmentWithRevalidation(
|
|
1791
|
+
nonParallelEntry,
|
|
1792
|
+
routeKey,
|
|
1793
|
+
params,
|
|
1794
|
+
context,
|
|
1795
|
+
clientSegmentSet,
|
|
1796
|
+
prevParams,
|
|
1797
|
+
request,
|
|
1798
|
+
prevUrl,
|
|
1799
|
+
nextUrl,
|
|
1800
|
+
loaderPromises,
|
|
1801
|
+
actionContext,
|
|
1802
|
+
false // stale = false for fresh resolution
|
|
1803
|
+
),
|
|
1804
|
+
pathname
|
|
1805
|
+
);
|
|
1806
|
+
|
|
1807
|
+
allSegments.push(...resolved.segments);
|
|
1808
|
+
matchedIds.push(...resolved.matchedIds);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
return { segments: allSegments, matchedIds };
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* Wrapper for segment resolution with revalidation that adds error boundary handling
|
|
1816
|
+
* Similar to resolveWithErrorHandling but returns SegmentRevalidationResult
|
|
1817
|
+
*/
|
|
1818
|
+
async function resolveWithRevalidationErrorHandling(
|
|
1819
|
+
entry: EntryData,
|
|
1820
|
+
params: Record<string, string>,
|
|
1821
|
+
resolveFn: () => Promise<SegmentRevalidationResult>,
|
|
1822
|
+
pathname?: string,
|
|
1823
|
+
errorContext?: {
|
|
1824
|
+
request: Request;
|
|
1825
|
+
url: URL;
|
|
1826
|
+
routeKey?: string;
|
|
1827
|
+
env?: TEnv;
|
|
1828
|
+
isPartial?: boolean;
|
|
1829
|
+
requestStartTime?: number;
|
|
1830
|
+
}
|
|
1831
|
+
): Promise<SegmentRevalidationResult> {
|
|
1832
|
+
try {
|
|
1833
|
+
return await resolveFn();
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
// Don't catch Response objects (middleware short-circuit)
|
|
1836
|
+
if (error instanceof Response) {
|
|
1837
|
+
throw error;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// Handle DataNotFoundError separately - look for notFoundBoundary first
|
|
1841
|
+
if (error instanceof DataNotFoundError) {
|
|
1842
|
+
const notFoundFallback = findNearestNotFoundBoundary(entry);
|
|
1843
|
+
|
|
1844
|
+
if (notFoundFallback) {
|
|
1845
|
+
// Create notFound info
|
|
1846
|
+
const notFoundInfo = createNotFoundInfo(
|
|
1847
|
+
error,
|
|
1848
|
+
entry.shortCode,
|
|
1849
|
+
entry.type,
|
|
1850
|
+
pathname
|
|
1851
|
+
);
|
|
1852
|
+
|
|
1853
|
+
// Invoke onError with notFound context
|
|
1854
|
+
if (errorContext) {
|
|
1855
|
+
callOnError(error, "handler", {
|
|
1856
|
+
request: errorContext.request,
|
|
1857
|
+
url: errorContext.url,
|
|
1858
|
+
routeKey: errorContext.routeKey,
|
|
1859
|
+
params,
|
|
1860
|
+
segmentId: entry.shortCode,
|
|
1861
|
+
segmentType: entry.type as any,
|
|
1862
|
+
env: errorContext.env,
|
|
1863
|
+
isPartial: errorContext.isPartial,
|
|
1864
|
+
handledByBoundary: true,
|
|
1865
|
+
metadata: { notFound: true, message: notFoundInfo.message },
|
|
1866
|
+
requestStartTime: errorContext.requestStartTime,
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
console.log(
|
|
1871
|
+
`[Router] NotFound caught by notFoundBoundary in ${entry.shortCode}:`,
|
|
1872
|
+
notFoundInfo.message
|
|
1873
|
+
);
|
|
1874
|
+
|
|
1875
|
+
// Set response status to 404 for notFound
|
|
1876
|
+
const reqCtx = getRequestContext();
|
|
1877
|
+
if (reqCtx) {
|
|
1878
|
+
reqCtx.res = new Response(null, { status: 404, headers: reqCtx.res.headers });
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// Create notFound segment
|
|
1882
|
+
const notFoundSegment = createNotFoundSegment(
|
|
1883
|
+
notFoundInfo,
|
|
1884
|
+
notFoundFallback,
|
|
1885
|
+
entry,
|
|
1886
|
+
params
|
|
1887
|
+
);
|
|
1888
|
+
|
|
1889
|
+
// Return with the notFound segment and its ID as matched
|
|
1890
|
+
return {
|
|
1891
|
+
segments: [notFoundSegment],
|
|
1892
|
+
matchedIds: [notFoundSegment.id],
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
// If no notFoundBoundary, fall through to error boundary handling
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// Find nearest error boundary
|
|
1899
|
+
const fallback = findNearestErrorBoundary(entry);
|
|
1900
|
+
|
|
1901
|
+
// Determine segment type for error info
|
|
1902
|
+
const segmentType: ErrorInfo["segmentType"] = entry.type;
|
|
1903
|
+
|
|
1904
|
+
// Create error info
|
|
1905
|
+
const errorInfo = createErrorInfo(error, entry.shortCode, segmentType);
|
|
1906
|
+
|
|
1907
|
+
// Use default fallback if no error boundary found
|
|
1908
|
+
const effectiveFallback = fallback ?? DefaultErrorFallback;
|
|
1909
|
+
|
|
1910
|
+
// Invoke onError callback
|
|
1911
|
+
if (errorContext) {
|
|
1912
|
+
callOnError(error, "handler", {
|
|
1913
|
+
request: errorContext.request,
|
|
1914
|
+
url: errorContext.url,
|
|
1915
|
+
routeKey: errorContext.routeKey,
|
|
1916
|
+
params,
|
|
1917
|
+
segmentId: entry.shortCode,
|
|
1918
|
+
segmentType: entry.type as any,
|
|
1919
|
+
env: errorContext.env,
|
|
1920
|
+
isPartial: errorContext.isPartial,
|
|
1921
|
+
handledByBoundary: !!fallback,
|
|
1922
|
+
requestStartTime: errorContext.requestStartTime,
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
console.log(
|
|
1927
|
+
`[Router] Error caught by ${fallback ? "error boundary" : "default fallback"} in ${entry.shortCode}:`,
|
|
1928
|
+
errorInfo.message
|
|
1929
|
+
);
|
|
1930
|
+
|
|
1931
|
+
// Set response status to 500 for error
|
|
1932
|
+
{
|
|
1933
|
+
const reqCtx = getRequestContext();
|
|
1934
|
+
if (reqCtx) {
|
|
1935
|
+
reqCtx.res = new Response(null, { status: 500, headers: reqCtx.res.headers });
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// Create error segment
|
|
1940
|
+
const errorSegment = createErrorSegment(
|
|
1941
|
+
errorInfo,
|
|
1942
|
+
effectiveFallback,
|
|
1943
|
+
entry,
|
|
1944
|
+
params
|
|
1945
|
+
);
|
|
1946
|
+
|
|
1947
|
+
// Return with the error segment and its ID as matched
|
|
1948
|
+
return {
|
|
1949
|
+
segments: [errorSegment],
|
|
1950
|
+
matchedIds: [errorSegment.id],
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* Result of resolving segments with revalidation
|
|
1957
|
+
* Contains both segments to render and all matched segment IDs
|
|
1958
|
+
*/
|
|
1959
|
+
interface SegmentRevalidationResult {
|
|
1960
|
+
segments: ResolvedSegment[];
|
|
1961
|
+
matchedIds: string[];
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* Action context type for revalidation
|
|
1966
|
+
*/
|
|
1967
|
+
type ActionContext = {
|
|
1968
|
+
actionId?: string;
|
|
1969
|
+
actionUrl?: URL;
|
|
1970
|
+
actionResult?: any;
|
|
1971
|
+
formData?: FormData;
|
|
1972
|
+
};
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* Helper: Resolve parallel segments with revalidation
|
|
1976
|
+
* Parallels now have their own loaders, revalidate functions, and loading components
|
|
1977
|
+
*/
|
|
1978
|
+
async function resolveParallelSegmentsWithRevalidation(
|
|
1979
|
+
entry: EntryData,
|
|
1980
|
+
params: Record<string, string>,
|
|
1981
|
+
context: HandlerContext<any, TEnv>,
|
|
1982
|
+
belongsToRoute: boolean,
|
|
1983
|
+
clientSegmentIds: Set<string>,
|
|
1984
|
+
prevParams: Record<string, string>,
|
|
1985
|
+
request: Request,
|
|
1986
|
+
prevUrl: URL,
|
|
1987
|
+
nextUrl: URL,
|
|
1988
|
+
routeKey: string,
|
|
1989
|
+
actionContext?: ActionContext,
|
|
1990
|
+
stale?: boolean
|
|
1991
|
+
): Promise<SegmentRevalidationResult> {
|
|
1992
|
+
const segments: ResolvedSegment[] = [];
|
|
1993
|
+
const matchedIds: string[] = [];
|
|
1994
|
+
|
|
1995
|
+
for (const parallelEntry of entry.parallel) {
|
|
1996
|
+
invariant(
|
|
1997
|
+
parallelEntry.type === "parallel",
|
|
1998
|
+
`Expected parallel entry, got: ${parallelEntry.type}`
|
|
1999
|
+
);
|
|
2000
|
+
|
|
2001
|
+
// Step 1: Process each slot handler FIRST (they trigger loaders via ctx.use())
|
|
2002
|
+
const slots = parallelEntry.handler as Record<
|
|
2003
|
+
`@${string}`,
|
|
2004
|
+
| ((ctx: HandlerContext<any, TEnv>) => ReactNode | Promise<ReactNode>)
|
|
2005
|
+
| ReactNode
|
|
2006
|
+
>;
|
|
2007
|
+
|
|
2008
|
+
for (const [slot, handler] of Object.entries(slots)) {
|
|
2009
|
+
// Use parent entry's shortCode so segment tree correctly associates parallel with parent
|
|
2010
|
+
const parallelId = `${entry.shortCode}.${slot}`;
|
|
2011
|
+
|
|
2012
|
+
// Include in matchedIds if:
|
|
2013
|
+
// - Client sent empty segments (HMR/full refetch), OR
|
|
2014
|
+
// - Client already has this parallel segment, OR
|
|
2015
|
+
// - This is a route-scoped parallel (belongsToRoute=true) that should appear
|
|
2016
|
+
// Intercepts (like @modal) are handled separately via resolveInterceptEntry.
|
|
2017
|
+
const isFullRefetch = clientSegmentIds.size === 0;
|
|
2018
|
+
if (
|
|
2019
|
+
isFullRefetch ||
|
|
2020
|
+
clientSegmentIds.has(parallelId) ||
|
|
2021
|
+
belongsToRoute
|
|
2022
|
+
) {
|
|
2023
|
+
matchedIds.push(parallelId);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
const component = await revalidate(
|
|
2027
|
+
async () => {
|
|
2028
|
+
// If client sent empty segments (HMR/full refetch), always render
|
|
2029
|
+
if (isFullRefetch) return true;
|
|
2030
|
+
|
|
2031
|
+
// If client doesn't have this parallel:
|
|
2032
|
+
// - Route-scoped parallels (belongsToRoute=true): render them when navigating to the route
|
|
2033
|
+
// - Parent chain parallels (belongsToRoute=false): don't suddenly appear
|
|
2034
|
+
// Intercepts are handled separately via resolveInterceptEntry.
|
|
2035
|
+
if (!clientSegmentIds.has(parallelId)) return belongsToRoute;
|
|
2036
|
+
|
|
2037
|
+
const dummySegment: ResolvedSegment = {
|
|
2038
|
+
id: parallelId,
|
|
2039
|
+
namespace: parallelEntry.id,
|
|
2040
|
+
type: "parallel",
|
|
2041
|
+
index: 0,
|
|
2042
|
+
component: null as any,
|
|
2043
|
+
params,
|
|
2044
|
+
slot,
|
|
2045
|
+
belongsToRoute,
|
|
2046
|
+
parallelName: `${parallelEntry.id}.${slot}`,
|
|
2047
|
+
};
|
|
2048
|
+
|
|
2049
|
+
// Use parallel's own revalidate functions
|
|
2050
|
+
return await evaluateRevalidation({
|
|
2051
|
+
segment: dummySegment,
|
|
2052
|
+
prevParams,
|
|
2053
|
+
getPrevSegment: null,
|
|
2054
|
+
request,
|
|
2055
|
+
prevUrl,
|
|
2056
|
+
nextUrl,
|
|
2057
|
+
revalidations: parallelEntry.revalidate.map((fn, i) => ({
|
|
2058
|
+
name: `revalidate${i}`,
|
|
2059
|
+
fn,
|
|
2060
|
+
})),
|
|
2061
|
+
routeKey,
|
|
2062
|
+
context,
|
|
2063
|
+
actionContext,
|
|
2064
|
+
stale,
|
|
2065
|
+
});
|
|
2066
|
+
},
|
|
2067
|
+
async () => {
|
|
2068
|
+
// If loading is defined, don't await (stream with Suspense)
|
|
2069
|
+
// Don't track parallel handlers - they shouldn't block handle data
|
|
2070
|
+
if (parallelEntry.loading) {
|
|
2071
|
+
const result =
|
|
2072
|
+
typeof handler === "function" ? handler(context) : handler;
|
|
2073
|
+
return result;
|
|
2074
|
+
}
|
|
2075
|
+
return typeof handler === "function"
|
|
2076
|
+
? await handler(context)
|
|
2077
|
+
: handler;
|
|
2078
|
+
},
|
|
2079
|
+
() => null
|
|
2080
|
+
);
|
|
2081
|
+
|
|
2082
|
+
segments.push({
|
|
2083
|
+
id: parallelId,
|
|
2084
|
+
namespace: parallelEntry.id,
|
|
2085
|
+
type: "parallel",
|
|
2086
|
+
index: 0,
|
|
2087
|
+
component,
|
|
2088
|
+
loading:
|
|
2089
|
+
parallelEntry.loading === false ? null : parallelEntry.loading,
|
|
2090
|
+
params,
|
|
2091
|
+
slot,
|
|
2092
|
+
belongsToRoute,
|
|
2093
|
+
parallelName: `${parallelEntry.id}.${slot}`,
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
// Step 2: Resolve loaders AFTER handlers have run
|
|
2098
|
+
// If loading is defined, do NOT await loaders - keeps handler Promises pending for Suspense
|
|
2099
|
+
// Loader data flows through component props (via ctx.use() in handler)
|
|
2100
|
+
if (!parallelEntry.loading) {
|
|
2101
|
+
const loaderResult = await resolveLoadersWithRevalidation(
|
|
2102
|
+
parallelEntry,
|
|
2103
|
+
context,
|
|
2104
|
+
belongsToRoute,
|
|
2105
|
+
clientSegmentIds,
|
|
2106
|
+
prevParams,
|
|
2107
|
+
request,
|
|
2108
|
+
prevUrl,
|
|
2109
|
+
nextUrl,
|
|
2110
|
+
routeKey,
|
|
2111
|
+
actionContext,
|
|
2112
|
+
entry.shortCode, // Pass parent's shortCode for segment ID association
|
|
2113
|
+
stale
|
|
2114
|
+
);
|
|
2115
|
+
segments.push(...loaderResult.segments);
|
|
2116
|
+
matchedIds.push(...loaderResult.matchedIds);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
return { segments, matchedIds };
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* Helper: Resolve entry handler (layout, cache, or route) with revalidation
|
|
2125
|
+
* Extracted to reduce duplication between layout, cache, and route branches
|
|
2126
|
+
*/
|
|
2127
|
+
async function resolveEntryHandlerWithRevalidation(
|
|
2128
|
+
entry: Exclude<EntryData, { type: "parallel" }>,
|
|
2129
|
+
params: Record<string, string>,
|
|
2130
|
+
context: HandlerContext<any, TEnv>,
|
|
2131
|
+
belongsToRoute: boolean,
|
|
2132
|
+
clientSegmentIds: Set<string>,
|
|
2133
|
+
prevParams: Record<string, string>,
|
|
2134
|
+
request: Request,
|
|
2135
|
+
prevUrl: URL,
|
|
2136
|
+
nextUrl: URL,
|
|
2137
|
+
routeKey: string,
|
|
2138
|
+
actionContext?: ActionContext,
|
|
2139
|
+
stale?: boolean
|
|
2140
|
+
): Promise<{ segment: ResolvedSegment; matchedId: string }> {
|
|
2141
|
+
const matchedId = entry.shortCode;
|
|
2142
|
+
|
|
2143
|
+
const component = await revalidate(
|
|
2144
|
+
async () => {
|
|
2145
|
+
const hasSegment = clientSegmentIds.has(entry.shortCode);
|
|
2146
|
+
console.log(
|
|
2147
|
+
`[Router.resolveEntryHandler] ${entry.shortCode} (${entry.type}): client has=${hasSegment}, belongsToRoute=${belongsToRoute}`
|
|
2148
|
+
);
|
|
2149
|
+
if (!hasSegment) return true;
|
|
2150
|
+
|
|
2151
|
+
const dummySegment: ResolvedSegment = {
|
|
2152
|
+
id: entry.shortCode,
|
|
2153
|
+
namespace: entry.id,
|
|
2154
|
+
type:
|
|
2155
|
+
entry.type === "cache"
|
|
2156
|
+
? "layout"
|
|
2157
|
+
: (entry.type as "layout" | "route"),
|
|
2158
|
+
index: 0,
|
|
2159
|
+
component: null as any,
|
|
2160
|
+
params,
|
|
2161
|
+
belongsToRoute,
|
|
2162
|
+
...(entry.type === "layout" || entry.type === "cache"
|
|
2163
|
+
? { layoutName: entry.id }
|
|
2164
|
+
: {}),
|
|
2165
|
+
};
|
|
2166
|
+
|
|
2167
|
+
const shouldRevalidate = await evaluateRevalidation({
|
|
2168
|
+
segment: dummySegment,
|
|
2169
|
+
prevParams,
|
|
2170
|
+
getPrevSegment: null,
|
|
2171
|
+
request,
|
|
2172
|
+
prevUrl,
|
|
2173
|
+
nextUrl,
|
|
2174
|
+
revalidations: entry.revalidate.map((fn, i) => ({
|
|
2175
|
+
name: `revalidate${i}`,
|
|
2176
|
+
fn,
|
|
2177
|
+
})),
|
|
2178
|
+
routeKey,
|
|
2179
|
+
context,
|
|
2180
|
+
actionContext,
|
|
2181
|
+
stale,
|
|
2182
|
+
});
|
|
2183
|
+
console.log(
|
|
2184
|
+
`[Router.resolveEntryHandler] ${entry.shortCode}: evaluateRevalidation returned ${shouldRevalidate}`
|
|
2185
|
+
);
|
|
2186
|
+
return shouldRevalidate;
|
|
2187
|
+
},
|
|
2188
|
+
async () => {
|
|
2189
|
+
// Set current segment ID for handle data attribution
|
|
2190
|
+
context._currentSegmentId = entry.shortCode;
|
|
2191
|
+
if (entry.type === "layout" || entry.type === "cache") {
|
|
2192
|
+
return typeof entry.handler === "function"
|
|
2193
|
+
? await entry.handler(context)
|
|
2194
|
+
: entry.handler;
|
|
2195
|
+
}
|
|
2196
|
+
// entry.type === "route" - handler is always callable
|
|
2197
|
+
const routeEntry = entry as Extract<EntryData, { type: "route" }>;
|
|
2198
|
+
// For routes with loading: keep promise pending for navigation (not actions)
|
|
2199
|
+
// This allows client's use() to suspend and show loading skeleton
|
|
2200
|
+
if (!routeEntry.loading) {
|
|
2201
|
+
return await routeEntry.handler(context);
|
|
2202
|
+
}
|
|
2203
|
+
if (!actionContext) {
|
|
2204
|
+
// NOT awaited - keeps promise pending, but track for completion
|
|
2205
|
+
const result = routeEntry.handler(context);
|
|
2206
|
+
return {
|
|
2207
|
+
content: result instanceof Promise ? trackHandler(result) : result,
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
console.log(
|
|
2211
|
+
`[Router] Resolving action route with awaited value: ${entry.id}`
|
|
2212
|
+
);
|
|
2213
|
+
// For actions: await handler and return value directly (not wrapped in Promise)
|
|
2214
|
+
// This ensures component instanceof Promise is false in segment-system,
|
|
2215
|
+
// avoiding RouteContentWrapper/Suspense and maintaining consistent tree structure
|
|
2216
|
+
return {
|
|
2217
|
+
content: Promise.resolve(await routeEntry.handler(context)),
|
|
2218
|
+
};
|
|
2219
|
+
},
|
|
2220
|
+
() => null
|
|
2221
|
+
);
|
|
2222
|
+
|
|
2223
|
+
// Extract component from wrapper object if needed (used to prevent promise auto-resolution)
|
|
2224
|
+
const resolvedComponent =
|
|
2225
|
+
component && typeof component === "object" && "content" in component
|
|
2226
|
+
? (component as { content: ReactNode }).content
|
|
2227
|
+
: component;
|
|
2228
|
+
|
|
2229
|
+
const segment: ResolvedSegment = {
|
|
2230
|
+
id: entry.shortCode,
|
|
2231
|
+
namespace: entry.id,
|
|
2232
|
+
type:
|
|
2233
|
+
entry.type === "cache" ? "layout" : (entry.type as "layout" | "route"),
|
|
2234
|
+
index: 0,
|
|
2235
|
+
component: resolvedComponent,
|
|
2236
|
+
loading: entry.loading === false ? null : entry.loading,
|
|
2237
|
+
params,
|
|
2238
|
+
belongsToRoute,
|
|
2239
|
+
...(entry.type === "layout" || entry.type === "cache"
|
|
2240
|
+
? { layoutName: entry.id }
|
|
2241
|
+
: {}),
|
|
2242
|
+
};
|
|
2243
|
+
|
|
2244
|
+
return { segment, matchedId };
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
/**
|
|
2248
|
+
* Resolve segments with revalidation awareness (for partial rendering)
|
|
2249
|
+
* Same as resolveSegment but conditionally executes handlers based on revalidation
|
|
2250
|
+
* Returns both segments to render AND all matched segment IDs (including skipped ones)
|
|
2251
|
+
* Cache entries are handled like layouts (they emit segments)
|
|
2252
|
+
* Parallel entries are handled separately via resolveParallelSegmentsWithRevalidation
|
|
2253
|
+
*/
|
|
2254
|
+
async function resolveSegmentWithRevalidation(
|
|
2255
|
+
entry: Exclude<EntryData, { type: "parallel" }>,
|
|
2256
|
+
routeKey: string,
|
|
2257
|
+
params: Record<string, string>,
|
|
2258
|
+
context: HandlerContext<any, TEnv>,
|
|
2259
|
+
clientSegmentIds: Set<string>,
|
|
2260
|
+
prevParams: Record<string, string>,
|
|
2261
|
+
request: Request,
|
|
2262
|
+
prevUrl: URL,
|
|
2263
|
+
nextUrl: URL,
|
|
2264
|
+
loaderPromises: Map<string, Promise<any>>,
|
|
2265
|
+
actionContext?: ActionContext,
|
|
2266
|
+
stale?: boolean
|
|
2267
|
+
): Promise<SegmentRevalidationResult> {
|
|
2268
|
+
const segments: ResolvedSegment[] = [];
|
|
2269
|
+
const matchedIds: string[] = [];
|
|
2270
|
+
|
|
2271
|
+
const belongsToRoute = entry.type === "route";
|
|
2272
|
+
|
|
2273
|
+
// Note: Middleware is now collected and executed at the top level (coreRequestHandler)
|
|
2274
|
+
|
|
2275
|
+
// Step 1: Run loaders with revalidation
|
|
2276
|
+
const loaderResult = await resolveLoadersWithRevalidation(
|
|
2277
|
+
entry,
|
|
2278
|
+
context,
|
|
2279
|
+
belongsToRoute,
|
|
2280
|
+
clientSegmentIds,
|
|
2281
|
+
prevParams,
|
|
2282
|
+
request,
|
|
2283
|
+
prevUrl,
|
|
2284
|
+
nextUrl,
|
|
2285
|
+
routeKey,
|
|
2286
|
+
actionContext,
|
|
2287
|
+
undefined, // shortCodeOverride
|
|
2288
|
+
stale
|
|
2289
|
+
);
|
|
2290
|
+
segments.push(...loaderResult.segments);
|
|
2291
|
+
matchedIds.push(...loaderResult.matchedIds);
|
|
2292
|
+
|
|
2293
|
+
// Step 3: Process orphan layouts (for routes, these come before parallels)
|
|
2294
|
+
if (entry.type === "route") {
|
|
2295
|
+
for (const orphan of entry.layout) {
|
|
2296
|
+
const orphanResult = await resolveOrphanLayoutWithRevalidation(
|
|
2297
|
+
orphan,
|
|
2298
|
+
params,
|
|
2299
|
+
context,
|
|
2300
|
+
clientSegmentIds,
|
|
2301
|
+
prevParams,
|
|
2302
|
+
request,
|
|
2303
|
+
prevUrl,
|
|
2304
|
+
nextUrl,
|
|
2305
|
+
routeKey,
|
|
2306
|
+
loaderPromises,
|
|
2307
|
+
true, // Route's orphan layouts belong to the route
|
|
2308
|
+
actionContext,
|
|
2309
|
+
stale
|
|
2310
|
+
);
|
|
2311
|
+
segments.push(...orphanResult.segments);
|
|
2312
|
+
matchedIds.push(...orphanResult.matchedIds);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// Step 4: Process parallel segments
|
|
2317
|
+
const parallelResult = await resolveParallelSegmentsWithRevalidation(
|
|
2318
|
+
entry,
|
|
2319
|
+
params,
|
|
2320
|
+
context,
|
|
2321
|
+
belongsToRoute,
|
|
2322
|
+
clientSegmentIds,
|
|
2323
|
+
prevParams,
|
|
2324
|
+
request,
|
|
2325
|
+
prevUrl,
|
|
2326
|
+
nextUrl,
|
|
2327
|
+
routeKey,
|
|
2328
|
+
actionContext,
|
|
2329
|
+
stale
|
|
2330
|
+
);
|
|
2331
|
+
segments.push(...parallelResult.segments);
|
|
2332
|
+
matchedIds.push(...parallelResult.matchedIds);
|
|
2333
|
+
|
|
2334
|
+
// Step 5: Process orphan layouts (for layouts/cache, these come after parallels)
|
|
2335
|
+
if (entry.type === "layout" || entry.type === "cache") {
|
|
2336
|
+
for (const orphan of entry.layout) {
|
|
2337
|
+
const orphanResult = await resolveOrphanLayoutWithRevalidation(
|
|
2338
|
+
orphan,
|
|
2339
|
+
params,
|
|
2340
|
+
context,
|
|
2341
|
+
clientSegmentIds,
|
|
2342
|
+
prevParams,
|
|
2343
|
+
request,
|
|
2344
|
+
prevUrl,
|
|
2345
|
+
nextUrl,
|
|
2346
|
+
routeKey,
|
|
2347
|
+
loaderPromises,
|
|
2348
|
+
false, // Parent chain layouts don't belong to specific route
|
|
2349
|
+
actionContext,
|
|
2350
|
+
stale
|
|
2351
|
+
);
|
|
2352
|
+
segments.push(...orphanResult.segments);
|
|
2353
|
+
matchedIds.push(...orphanResult.matchedIds);
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
// Step 6: Execute main handler with revalidation
|
|
2358
|
+
const handlerResult = await resolveEntryHandlerWithRevalidation(
|
|
2359
|
+
entry,
|
|
2360
|
+
params,
|
|
2361
|
+
context,
|
|
2362
|
+
belongsToRoute,
|
|
2363
|
+
clientSegmentIds,
|
|
2364
|
+
prevParams,
|
|
2365
|
+
request,
|
|
2366
|
+
prevUrl,
|
|
2367
|
+
nextUrl,
|
|
2368
|
+
routeKey,
|
|
2369
|
+
actionContext,
|
|
2370
|
+
stale
|
|
2371
|
+
);
|
|
2372
|
+
segments.push(handlerResult.segment);
|
|
2373
|
+
matchedIds.push(handlerResult.matchedId);
|
|
2374
|
+
|
|
2375
|
+
return { segments, matchedIds };
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
/**
|
|
2379
|
+
* Helper: Resolve orphan layout with revalidation
|
|
2380
|
+
* Returns both segments to render AND all matched segment IDs (including skipped ones)
|
|
2381
|
+
*/
|
|
2382
|
+
async function resolveOrphanLayoutWithRevalidation(
|
|
2383
|
+
orphan: EntryData,
|
|
2384
|
+
params: Record<string, string>,
|
|
2385
|
+
context: HandlerContext<any, TEnv>,
|
|
2386
|
+
clientSegmentIds: Set<string>,
|
|
2387
|
+
prevParams: Record<string, string>,
|
|
2388
|
+
request: Request,
|
|
2389
|
+
prevUrl: URL,
|
|
2390
|
+
nextUrl: URL,
|
|
2391
|
+
routeKey: string,
|
|
2392
|
+
loaderPromises: Map<string, Promise<any>>,
|
|
2393
|
+
belongsToRoute: boolean,
|
|
2394
|
+
actionContext?: {
|
|
2395
|
+
actionId?: string;
|
|
2396
|
+
actionUrl?: URL;
|
|
2397
|
+
actionResult?: any;
|
|
2398
|
+
formData?: FormData;
|
|
2399
|
+
},
|
|
2400
|
+
stale?: boolean
|
|
2401
|
+
): Promise<SegmentRevalidationResult> {
|
|
2402
|
+
invariant(
|
|
2403
|
+
orphan.type === "layout" || orphan.type === "cache",
|
|
2404
|
+
`Expected orphan to be a layout or cache, got: ${orphan.type}`
|
|
2405
|
+
);
|
|
2406
|
+
|
|
2407
|
+
const segments: ResolvedSegment[] = [];
|
|
2408
|
+
const matchedIds: string[] = [];
|
|
2409
|
+
|
|
2410
|
+
// Note: Orphan middleware is now collected and executed at the top level (coreRequestHandler)
|
|
2411
|
+
|
|
2412
|
+
// Step 1: Run orphan loaders with revalidation
|
|
2413
|
+
const loaderResult = await resolveLoadersWithRevalidation(
|
|
2414
|
+
orphan,
|
|
2415
|
+
context,
|
|
2416
|
+
belongsToRoute,
|
|
2417
|
+
clientSegmentIds,
|
|
2418
|
+
prevParams,
|
|
2419
|
+
request,
|
|
2420
|
+
prevUrl,
|
|
2421
|
+
nextUrl,
|
|
2422
|
+
routeKey,
|
|
2423
|
+
actionContext,
|
|
2424
|
+
undefined, // shortCodeOverride
|
|
2425
|
+
stale
|
|
2426
|
+
);
|
|
2427
|
+
segments.push(...loaderResult.segments);
|
|
2428
|
+
matchedIds.push(...loaderResult.matchedIds);
|
|
2429
|
+
|
|
2430
|
+
// Step 3: Process orphan parallel segments with revalidation
|
|
2431
|
+
// Parallels now have their own loaders, revalidate functions, and loading components
|
|
2432
|
+
for (const parallelEntry of orphan.parallel) {
|
|
2433
|
+
invariant(
|
|
2434
|
+
parallelEntry.type === "parallel",
|
|
2435
|
+
`Expected parallel entry, got: ${parallelEntry.type}`
|
|
2436
|
+
);
|
|
2437
|
+
|
|
2438
|
+
// Step 3a: Resolve parallel's loaders with revalidation
|
|
2439
|
+
const loaderResult = await resolveLoadersWithRevalidation(
|
|
2440
|
+
parallelEntry,
|
|
2441
|
+
context,
|
|
2442
|
+
belongsToRoute,
|
|
2443
|
+
clientSegmentIds,
|
|
2444
|
+
prevParams,
|
|
2445
|
+
request,
|
|
2446
|
+
prevUrl,
|
|
2447
|
+
nextUrl,
|
|
2448
|
+
routeKey,
|
|
2449
|
+
actionContext,
|
|
2450
|
+
undefined, // shortCodeOverride
|
|
2451
|
+
stale
|
|
2452
|
+
);
|
|
2453
|
+
segments.push(...loaderResult.segments);
|
|
2454
|
+
matchedIds.push(...loaderResult.matchedIds);
|
|
2455
|
+
|
|
2456
|
+
// Step 3b: Process each slot in the parallel handler
|
|
2457
|
+
const slots = parallelEntry.handler as Record<
|
|
2458
|
+
`@${string}`,
|
|
2459
|
+
| ((ctx: HandlerContext<any, TEnv>) => ReactNode | Promise<ReactNode>)
|
|
2460
|
+
| ReactNode
|
|
2461
|
+
>;
|
|
2462
|
+
|
|
2463
|
+
for (const [slot, handler] of Object.entries(slots)) {
|
|
2464
|
+
const parallelId = `${parallelEntry.shortCode}.${slot}`;
|
|
2465
|
+
|
|
2466
|
+
// Always add to matchedIds
|
|
2467
|
+
matchedIds.push(parallelId);
|
|
2468
|
+
|
|
2469
|
+
const component = await revalidate(
|
|
2470
|
+
async () => {
|
|
2471
|
+
if (!clientSegmentIds.has(parallelId)) return true;
|
|
2472
|
+
|
|
2473
|
+
const dummySegment: ResolvedSegment = {
|
|
2474
|
+
id: parallelId,
|
|
2475
|
+
namespace: parallelEntry.id,
|
|
2476
|
+
type: "parallel",
|
|
2477
|
+
index: 0,
|
|
2478
|
+
component: null as any,
|
|
2479
|
+
params,
|
|
2480
|
+
slot,
|
|
2481
|
+
belongsToRoute,
|
|
2482
|
+
parallelName: `${parallelEntry.id}.${slot}`,
|
|
2483
|
+
};
|
|
2484
|
+
|
|
2485
|
+
// Use parallel's own revalidate functions
|
|
2486
|
+
return await evaluateRevalidation({
|
|
2487
|
+
segment: dummySegment,
|
|
2488
|
+
prevParams,
|
|
2489
|
+
getPrevSegment: null,
|
|
2490
|
+
request,
|
|
2491
|
+
prevUrl,
|
|
2492
|
+
nextUrl,
|
|
2493
|
+
revalidations: parallelEntry.revalidate.map((fn, i) => ({
|
|
2494
|
+
name: `revalidate${i}`,
|
|
2495
|
+
fn,
|
|
2496
|
+
})),
|
|
2497
|
+
routeKey,
|
|
2498
|
+
context,
|
|
2499
|
+
actionContext,
|
|
2500
|
+
stale,
|
|
2501
|
+
});
|
|
2502
|
+
},
|
|
2503
|
+
async () => {
|
|
2504
|
+
// If loading is defined, don't await (stream with Suspense)
|
|
2505
|
+
// Don't track parallel handlers - they shouldn't block handle data
|
|
2506
|
+
if (parallelEntry.loading) {
|
|
2507
|
+
const result =
|
|
2508
|
+
typeof handler === "function" ? handler(context) : handler;
|
|
2509
|
+
return result;
|
|
2510
|
+
}
|
|
2511
|
+
return typeof handler === "function"
|
|
2512
|
+
? await handler(context)
|
|
2513
|
+
: handler;
|
|
2514
|
+
},
|
|
2515
|
+
() => null
|
|
2516
|
+
);
|
|
2517
|
+
|
|
2518
|
+
segments.push({
|
|
2519
|
+
id: parallelId,
|
|
2520
|
+
namespace: parallelEntry.id,
|
|
2521
|
+
type: "parallel",
|
|
2522
|
+
index: 0,
|
|
2523
|
+
component,
|
|
2524
|
+
loading:
|
|
2525
|
+
parallelEntry.loading === false ? null : parallelEntry.loading,
|
|
2526
|
+
params,
|
|
2527
|
+
slot,
|
|
2528
|
+
belongsToRoute,
|
|
2529
|
+
parallelName: `${parallelEntry.id}.${slot}`,
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
// Step 4: Execute orphan handler with revalidation
|
|
2535
|
+
// Always add orphan layout ID to matchedIds
|
|
2536
|
+
matchedIds.push(orphan.shortCode);
|
|
2537
|
+
|
|
2538
|
+
const component = await revalidate(
|
|
2539
|
+
async () => {
|
|
2540
|
+
if (!clientSegmentIds.has(orphan.shortCode)) return true;
|
|
2541
|
+
|
|
2542
|
+
const dummySegment: ResolvedSegment = {
|
|
2543
|
+
id: orphan.shortCode,
|
|
2544
|
+
namespace: orphan.id,
|
|
2545
|
+
type: "layout",
|
|
2546
|
+
index: 0,
|
|
2547
|
+
component: null as any,
|
|
2548
|
+
params,
|
|
2549
|
+
belongsToRoute,
|
|
2550
|
+
layoutName: orphan.id,
|
|
2551
|
+
};
|
|
2552
|
+
|
|
2553
|
+
return await evaluateRevalidation({
|
|
2554
|
+
segment: dummySegment,
|
|
2555
|
+
prevParams,
|
|
2556
|
+
getPrevSegment: null,
|
|
2557
|
+
request,
|
|
2558
|
+
prevUrl,
|
|
2559
|
+
nextUrl,
|
|
2560
|
+
revalidations: orphan.revalidate.map((fn, i) => ({
|
|
2561
|
+
name: `revalidate${i}`,
|
|
2562
|
+
fn,
|
|
2563
|
+
})),
|
|
2564
|
+
routeKey,
|
|
2565
|
+
context,
|
|
2566
|
+
actionContext,
|
|
2567
|
+
stale,
|
|
2568
|
+
});
|
|
2569
|
+
},
|
|
2570
|
+
async () =>
|
|
2571
|
+
typeof orphan.handler === "function"
|
|
2572
|
+
? await orphan.handler(context)
|
|
2573
|
+
: orphan.handler,
|
|
2574
|
+
() => null
|
|
2575
|
+
);
|
|
2576
|
+
|
|
2577
|
+
segments.push({
|
|
2578
|
+
id: orphan.shortCode,
|
|
2579
|
+
namespace: orphan.id,
|
|
2580
|
+
type: "layout",
|
|
2581
|
+
index: 0,
|
|
2582
|
+
component,
|
|
2583
|
+
params,
|
|
2584
|
+
belongsToRoute,
|
|
2585
|
+
layoutName: orphan.id,
|
|
2586
|
+
loading: orphan.loading === false ? null : orphan.loading,
|
|
2587
|
+
});
|
|
2588
|
+
|
|
2589
|
+
return { segments, matchedIds };
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
/**
|
|
2593
|
+
* Match request and return segments (document/SSR requests)
|
|
2594
|
+
*
|
|
2595
|
+
* Uses generator middleware pipeline for clean separation of concerns:
|
|
2596
|
+
* - cache-lookup: Check cache first
|
|
2597
|
+
* - segment-resolution: Resolve segments on cache miss
|
|
2598
|
+
* - cache-store: Store results in cache
|
|
2599
|
+
* - background-revalidation: SWR revalidation
|
|
2600
|
+
*/
|
|
2601
|
+
async function match(request: Request, env: TEnv): Promise<MatchResult> {
|
|
2602
|
+
// Build RouterContext with all closure functions needed by middleware
|
|
2603
|
+
const routerCtx: RouterContext<TEnv> = {
|
|
2604
|
+
findMatch,
|
|
2605
|
+
loadManifest,
|
|
2606
|
+
traverseBack,
|
|
2607
|
+
createHandlerContext,
|
|
2608
|
+
setupLoaderAccess,
|
|
2609
|
+
setupLoaderAccessSilent,
|
|
2610
|
+
getContext,
|
|
2611
|
+
getMetricsStore,
|
|
2612
|
+
createCacheScope,
|
|
2613
|
+
findInterceptForRoute,
|
|
2614
|
+
resolveAllSegmentsWithRevalidation,
|
|
2615
|
+
resolveInterceptEntry,
|
|
2616
|
+
evaluateRevalidation,
|
|
2617
|
+
getRequestContext,
|
|
2618
|
+
resolveAllSegments,
|
|
2619
|
+
createHandleStore,
|
|
2620
|
+
buildEntryRevalidateMap,
|
|
2621
|
+
resolveLoadersOnlyWithRevalidation,
|
|
2622
|
+
resolveInterceptLoadersOnly,
|
|
2623
|
+
resolveLoadersOnly,
|
|
2624
|
+
};
|
|
2625
|
+
|
|
2626
|
+
return runWithRouterContext(routerCtx, async () => {
|
|
2627
|
+
const result = await createMatchContextForFull(request, env);
|
|
2628
|
+
|
|
2629
|
+
// Handle redirect case
|
|
2630
|
+
if ("type" in result && result.type === "redirect") {
|
|
2631
|
+
return {
|
|
2632
|
+
segments: [],
|
|
2633
|
+
matched: [],
|
|
2634
|
+
diff: [],
|
|
2635
|
+
params: {},
|
|
2636
|
+
redirect: result.redirectUrl,
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
const ctx = result as MatchContext<TEnv>;
|
|
2641
|
+
|
|
2642
|
+
try {
|
|
2643
|
+
const state = createPipelineState();
|
|
2644
|
+
const pipeline = createMatchPartialPipeline(ctx, state);
|
|
2645
|
+
return await collectMatchResult(pipeline, ctx, state);
|
|
2646
|
+
} catch (error) {
|
|
2647
|
+
if (error instanceof Response) throw error;
|
|
2648
|
+
// Report unhandled errors during full match pipeline
|
|
2649
|
+
callOnError(error, "routing", {
|
|
2650
|
+
request,
|
|
2651
|
+
url: ctx.url,
|
|
2652
|
+
env,
|
|
2653
|
+
isPartial: false,
|
|
2654
|
+
handledByBoundary: false,
|
|
2655
|
+
});
|
|
2656
|
+
throw sanitizeError(error);
|
|
2657
|
+
}
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
/**
|
|
2662
|
+
* Match an error to the nearest error boundary and return error segments
|
|
2663
|
+
*
|
|
2664
|
+
* This method is used when an action or other operation fails and we need
|
|
2665
|
+
* to render the error boundary UI. It finds the nearest errorBoundary in
|
|
2666
|
+
* the route tree and renders it with the error info.
|
|
2667
|
+
*
|
|
2668
|
+
* The returned segments include all segments up to and including the error
|
|
2669
|
+
* boundary, with the error boundary's fallback rendered in place of its
|
|
2670
|
+
* normal outlet content.
|
|
2671
|
+
*/
|
|
2672
|
+
async function matchError(
|
|
2673
|
+
request: Request,
|
|
2674
|
+
_context: TEnv,
|
|
2675
|
+
error: unknown,
|
|
2676
|
+
segmentType: ErrorInfo["segmentType"] = "route"
|
|
2677
|
+
): Promise<MatchResult | null> {
|
|
2678
|
+
const url = new URL(request.url);
|
|
2679
|
+
const pathname = url.pathname;
|
|
2680
|
+
|
|
2681
|
+
console.log(`[Router.matchError] Matching error for ${pathname}`);
|
|
2682
|
+
|
|
2683
|
+
// Find the route match for the current URL
|
|
2684
|
+
const matched = findMatch(pathname);
|
|
2685
|
+
if (!matched) {
|
|
2686
|
+
console.warn(`[Router.matchError] No route matched for ${pathname}`);
|
|
2687
|
+
return null;
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
// Load manifest to get the entry chain
|
|
2691
|
+
const manifestEntry = await loadManifest(
|
|
2692
|
+
matched.entry,
|
|
2693
|
+
matched.routeKey,
|
|
2694
|
+
pathname,
|
|
2695
|
+
undefined, // No metrics for error matching
|
|
2696
|
+
false // Not SSR
|
|
2697
|
+
);
|
|
2698
|
+
|
|
2699
|
+
// Find the nearest error boundary in the entry chain
|
|
2700
|
+
// If none found, use a default "Internal Server Error" fallback
|
|
2701
|
+
const fallback = findNearestErrorBoundary(manifestEntry);
|
|
2702
|
+
const useDefaultFallback = !fallback;
|
|
2703
|
+
|
|
2704
|
+
// Create error info
|
|
2705
|
+
const errorInfo = createErrorInfo(
|
|
2706
|
+
error,
|
|
2707
|
+
manifestEntry.shortCode || "unknown",
|
|
2708
|
+
segmentType
|
|
2709
|
+
);
|
|
2710
|
+
|
|
2711
|
+
// Find which entry has the error boundary
|
|
2712
|
+
// Also checks orphan layouts (siblings) since they can have error boundaries too
|
|
2713
|
+
let entryWithBoundary: EntryData | null = null;
|
|
2714
|
+
let current: EntryData | null = manifestEntry;
|
|
2715
|
+
while (current) {
|
|
2716
|
+
// Check if this entry has an error boundary
|
|
2717
|
+
if (current.errorBoundary && current.errorBoundary.length > 0) {
|
|
2718
|
+
entryWithBoundary = current;
|
|
2719
|
+
break;
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// Check orphan layouts/cache for error boundaries
|
|
2723
|
+
if (current.layout && current.layout.length > 0) {
|
|
2724
|
+
for (const orphan of current.layout) {
|
|
2725
|
+
if (orphan.errorBoundary && orphan.errorBoundary.length > 0) {
|
|
2726
|
+
entryWithBoundary = orphan;
|
|
2727
|
+
break;
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
if (entryWithBoundary) break;
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
current = current.parent;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
// Determine which entry has the error boundary and which entry should be replaced
|
|
2737
|
+
// The error content renders in the boundary's <Outlet />, not replacing the boundary itself
|
|
2738
|
+
let boundaryEntry: EntryData;
|
|
2739
|
+
let outletEntry: EntryData; // The entry that renders in boundaryEntry's outlet (gets replaced)
|
|
2740
|
+
|
|
2741
|
+
if (entryWithBoundary) {
|
|
2742
|
+
boundaryEntry = entryWithBoundary;
|
|
2743
|
+
|
|
2744
|
+
// Find the entry that renders in boundaryEntry's <Outlet />
|
|
2745
|
+
// Walk from manifestEntry toward boundaryEntry to find the direct outlet child
|
|
2746
|
+
outletEntry = manifestEntry;
|
|
2747
|
+
current = manifestEntry;
|
|
2748
|
+
|
|
2749
|
+
while (current) {
|
|
2750
|
+
// Case 1: current's direct parent is boundaryEntry
|
|
2751
|
+
if (current.parent === boundaryEntry) {
|
|
2752
|
+
outletEntry = current;
|
|
2753
|
+
break;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
// Case 2: boundaryEntry is an orphan layout of current's parent
|
|
2757
|
+
// In this case, current renders in the orphan's outlet
|
|
2758
|
+
if (current.parent && current.parent.layout) {
|
|
2759
|
+
if (current.parent.layout.includes(boundaryEntry)) {
|
|
2760
|
+
outletEntry = current;
|
|
2761
|
+
break;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
current = current.parent;
|
|
2766
|
+
}
|
|
2767
|
+
} else {
|
|
2768
|
+
// No user-defined error boundary - use root layout for the default fallback
|
|
2769
|
+
// Walk up to find the root entry (no parent)
|
|
2770
|
+
let rootEntry = manifestEntry;
|
|
2771
|
+
while (rootEntry.parent) {
|
|
2772
|
+
rootEntry = rootEntry.parent;
|
|
2773
|
+
}
|
|
2774
|
+
boundaryEntry = rootEntry;
|
|
2775
|
+
outletEntry = rootEntry; // For default, replace at root level
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// Build the matched IDs list: all entries from root to the error boundary (inclusive)
|
|
2779
|
+
// These segments will be fetched from client cache (parent layouts + their loaders)
|
|
2780
|
+
const matchedIds: string[] = [];
|
|
2781
|
+
|
|
2782
|
+
// Walk from error boundary up to root and collect parent IDs
|
|
2783
|
+
current = boundaryEntry;
|
|
2784
|
+
const stack: {
|
|
2785
|
+
shortCode: string;
|
|
2786
|
+
loaderEntries: LoaderEntry[];
|
|
2787
|
+
}[] = [];
|
|
2788
|
+
while (current) {
|
|
2789
|
+
if (current.shortCode) {
|
|
2790
|
+
stack.push({
|
|
2791
|
+
shortCode: current.shortCode,
|
|
2792
|
+
loaderEntries: current.loader || [],
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
current = current.parent;
|
|
2796
|
+
}
|
|
2797
|
+
// Reverse to get root-first order and build matchedIds including loaders
|
|
2798
|
+
for (const item of stack.reverse()) {
|
|
2799
|
+
matchedIds.push(item.shortCode);
|
|
2800
|
+
// Add loader segment IDs for this entry
|
|
2801
|
+
for (let i = 0; i < item.loaderEntries.length; i++) {
|
|
2802
|
+
const loaderId = item.loaderEntries[i].loader?.$$id || "unknown";
|
|
2803
|
+
matchedIds.push(`${item.shortCode}D${i}.${loaderId}`);
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
// Set response status to 500 for error
|
|
2808
|
+
const reqCtx = getRequestContext();
|
|
2809
|
+
if (reqCtx) {
|
|
2810
|
+
reqCtx.res = new Response(null, { status: 500, headers: reqCtx.res.headers });
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
// Create the error segment using user's fallback or default
|
|
2814
|
+
// The error segment uses the outlet entry's ID so it replaces the outlet content
|
|
2815
|
+
// while keeping the boundary layout (and its UI) rendered
|
|
2816
|
+
const effectiveFallback = fallback || DefaultErrorFallback;
|
|
2817
|
+
const errorSegment = createErrorSegment(
|
|
2818
|
+
errorInfo,
|
|
2819
|
+
effectiveFallback,
|
|
2820
|
+
outletEntry, // Use outletEntry so error content renders in the boundary's outlet
|
|
2821
|
+
matched.params
|
|
2822
|
+
);
|
|
2823
|
+
|
|
2824
|
+
if (useDefaultFallback) {
|
|
2825
|
+
console.log(
|
|
2826
|
+
`[Router.matchError] Using default error boundary (no user-defined boundary found)`
|
|
2827
|
+
);
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
console.log(
|
|
2831
|
+
`[Router.matchError] Boundary: ${boundaryEntry.shortCode}, outlet replaced: ${outletEntry.shortCode}`
|
|
2832
|
+
);
|
|
2833
|
+
|
|
2834
|
+
// Error segment replaces the outlet content, not the boundary layout itself
|
|
2835
|
+
// matched contains all IDs from root to boundary (for caching parent layouts)
|
|
2836
|
+
// diff contains the outlet entry ID that is being replaced with error content
|
|
2837
|
+
return {
|
|
2838
|
+
segments: [errorSegment],
|
|
2839
|
+
matched: matchedIds,
|
|
2840
|
+
diff: [errorSegment.id],
|
|
2841
|
+
params: matched.params,
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
/**
|
|
2846
|
+
* Create match context for full requests (document/SSR)
|
|
2847
|
+
* Simpler than partial - no revalidation, intercepts, or client state tracking
|
|
2848
|
+
*
|
|
2849
|
+
* @returns MatchContext with isFullMatch: true
|
|
2850
|
+
* @throws RouteNotFoundError if no route matches
|
|
2851
|
+
*/
|
|
2852
|
+
async function createMatchContextForFull(
|
|
2853
|
+
request: Request,
|
|
2854
|
+
env: TEnv
|
|
2855
|
+
): Promise<MatchContext<TEnv> | { type: "redirect"; redirectUrl: string }> {
|
|
2856
|
+
const url = new URL(request.url);
|
|
2857
|
+
const pathname = url.pathname;
|
|
2858
|
+
|
|
2859
|
+
// Initialize metrics store for this request
|
|
2860
|
+
const metricsStore = getMetricsStore();
|
|
2861
|
+
|
|
2862
|
+
// Track route matching
|
|
2863
|
+
const routeMatchStart = metricsStore ? performance.now() : 0;
|
|
2864
|
+
const matched = findMatch(pathname);
|
|
2865
|
+
if (metricsStore) {
|
|
2866
|
+
metricsStore.metrics.push({
|
|
2867
|
+
label: "route-matching",
|
|
2868
|
+
duration: performance.now() - routeMatchStart,
|
|
2869
|
+
startTime: routeMatchStart - metricsStore.requestStart,
|
|
2870
|
+
});
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
if (!matched) {
|
|
2874
|
+
throw new RouteNotFoundError(`No route matched for ${pathname}`, {
|
|
2875
|
+
cause: { pathname, method: request.method },
|
|
2876
|
+
});
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
// Handle trailing slash redirect (pattern defines canonical form)
|
|
2880
|
+
if (matched.redirectTo) {
|
|
2881
|
+
return {
|
|
2882
|
+
type: "redirect",
|
|
2883
|
+
redirectUrl: matched.redirectTo + url.search,
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
// Load manifest with isSSR=true for document requests
|
|
2888
|
+
const manifestStart = metricsStore ? performance.now() : 0;
|
|
2889
|
+
const manifestEntry = await loadManifest(
|
|
2890
|
+
matched.entry,
|
|
2891
|
+
matched.routeKey,
|
|
2892
|
+
pathname,
|
|
2893
|
+
metricsStore,
|
|
2894
|
+
true // isSSR
|
|
2895
|
+
);
|
|
2896
|
+
if (metricsStore) {
|
|
2897
|
+
metricsStore.metrics.push({
|
|
2898
|
+
label: "manifest-loading",
|
|
2899
|
+
duration: performance.now() - manifestStart,
|
|
2900
|
+
startTime: manifestStart - metricsStore.requestStart,
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// Collect route-level middleware
|
|
2905
|
+
const routeMiddleware = collectRouteMiddleware(
|
|
2906
|
+
traverseBack(manifestEntry),
|
|
2907
|
+
matched.params
|
|
2908
|
+
);
|
|
2909
|
+
|
|
2910
|
+
// Extract bindings from context
|
|
2911
|
+
const bindings = (env as any)?.Bindings ?? env;
|
|
2912
|
+
|
|
2913
|
+
const handlerContext = createHandlerContext(
|
|
2914
|
+
matched.params,
|
|
2915
|
+
request,
|
|
2916
|
+
url.searchParams,
|
|
2917
|
+
pathname,
|
|
2918
|
+
url,
|
|
2919
|
+
bindings
|
|
2920
|
+
);
|
|
2921
|
+
|
|
2922
|
+
// Create request-scoped loader promises map
|
|
2923
|
+
const loaderPromises = new Map<string, Promise<any>>();
|
|
2924
|
+
setupLoaderAccess(handlerContext, loaderPromises);
|
|
2925
|
+
|
|
2926
|
+
// Get store for metrics context
|
|
2927
|
+
const Store = getContext().getOrCreateStore(matched.routeKey);
|
|
2928
|
+
// Add run helper for cleaner middleware code
|
|
2929
|
+
Store.run = <T>(fn: () => T | Promise<T>) =>
|
|
2930
|
+
getContext().runWithStore(
|
|
2931
|
+
Store,
|
|
2932
|
+
Store.namespace || "#router",
|
|
2933
|
+
Store.parent,
|
|
2934
|
+
fn
|
|
2935
|
+
);
|
|
2936
|
+
if (metricsStore) {
|
|
2937
|
+
Store.metrics = metricsStore;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
// Collect entries and build cache scope
|
|
2941
|
+
const entries = [...traverseBack(manifestEntry)];
|
|
2942
|
+
let cacheScope: CacheScope | null = null;
|
|
2943
|
+
for (const entry of entries) {
|
|
2944
|
+
if (entry.cache) {
|
|
2945
|
+
cacheScope = createCacheScope(entry.cache, cacheScope);
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
// Full match context - no intercepts, no client state, no revalidation
|
|
2950
|
+
return {
|
|
2951
|
+
request,
|
|
2952
|
+
url,
|
|
2953
|
+
pathname,
|
|
2954
|
+
env,
|
|
2955
|
+
bindings,
|
|
2956
|
+
clientSegmentIds: [],
|
|
2957
|
+
clientSegmentSet: new Set(),
|
|
2958
|
+
stale: false,
|
|
2959
|
+
prevUrl: url, // Same as current for full match
|
|
2960
|
+
prevParams: {},
|
|
2961
|
+
prevMatch: null,
|
|
2962
|
+
matched,
|
|
2963
|
+
manifestEntry,
|
|
2964
|
+
entries,
|
|
2965
|
+
routeKey: matched.routeKey,
|
|
2966
|
+
localRouteName: matched.routeKey.includes(".")
|
|
2967
|
+
? matched.routeKey.split(".").pop()!
|
|
2968
|
+
: matched.routeKey,
|
|
2969
|
+
handlerContext,
|
|
2970
|
+
loaderPromises,
|
|
2971
|
+
metricsStore,
|
|
2972
|
+
Store,
|
|
2973
|
+
interceptContextMatch: null,
|
|
2974
|
+
interceptSelectorContext: {
|
|
2975
|
+
from: url,
|
|
2976
|
+
to: url,
|
|
2977
|
+
params: matched.params,
|
|
2978
|
+
request,
|
|
2979
|
+
env,
|
|
2980
|
+
segments: { path: [], ids: [] },
|
|
2981
|
+
},
|
|
2982
|
+
isSameRouteNavigation: false,
|
|
2983
|
+
interceptResult: null,
|
|
2984
|
+
cacheScope,
|
|
2985
|
+
isIntercept: false,
|
|
2986
|
+
actionContext: undefined,
|
|
2987
|
+
isAction: false,
|
|
2988
|
+
routeMiddleware,
|
|
2989
|
+
isFullMatch: true,
|
|
2990
|
+
};
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
/**
|
|
2994
|
+
* Create match context for partial requests (navigation/actions)
|
|
2995
|
+
* Extracts all setup logic from matchPartial into a reusable context builder
|
|
2996
|
+
*
|
|
2997
|
+
* @returns MatchContext if setup successful, null if should fall back to full render
|
|
2998
|
+
* @throws RouteNotFoundError if no route matches
|
|
2999
|
+
*/
|
|
3000
|
+
async function createMatchContextForPartial(
|
|
3001
|
+
request: Request,
|
|
3002
|
+
env: TEnv,
|
|
3003
|
+
actionContext?: ActionContext
|
|
3004
|
+
): Promise<MatchContext<TEnv> | null> {
|
|
3005
|
+
const url = new URL(request.url);
|
|
3006
|
+
const pathname = url.pathname;
|
|
3007
|
+
|
|
3008
|
+
// Track request start time for duration in onError (local to this request)
|
|
3009
|
+
const requestStartTime = performance.now();
|
|
3010
|
+
|
|
3011
|
+
// Initialize metrics store for this request
|
|
3012
|
+
const metricsStore = getMetricsStore();
|
|
3013
|
+
|
|
3014
|
+
// Extract client state from query params and header
|
|
3015
|
+
const clientSegmentIds =
|
|
3016
|
+
url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
|
|
3017
|
+
const stale = url.searchParams.get("_rsc_stale") === "true";
|
|
3018
|
+
const previousUrl =
|
|
3019
|
+
request.headers.get("X-RSC-Router-Client-Path") ||
|
|
3020
|
+
request.headers.get("Referer");
|
|
3021
|
+
const interceptSourceUrl = request.headers.get(
|
|
3022
|
+
"X-RSC-Router-Intercept-Source"
|
|
3023
|
+
);
|
|
3024
|
+
|
|
3025
|
+
if (!previousUrl) {
|
|
3026
|
+
return null; // Fall back to full render
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
const prevUrl = new URL(previousUrl, url.origin);
|
|
3030
|
+
const interceptContextUrl = interceptSourceUrl
|
|
3031
|
+
? new URL(interceptSourceUrl, url.origin)
|
|
3032
|
+
: prevUrl;
|
|
3033
|
+
|
|
3034
|
+
// Track route matching
|
|
3035
|
+
const routeMatchStart = metricsStore ? performance.now() : 0;
|
|
3036
|
+
const prevMatch = findMatch(prevUrl.pathname);
|
|
3037
|
+
const prevParams = prevMatch?.params || {};
|
|
3038
|
+
const interceptContextMatch = interceptSourceUrl
|
|
3039
|
+
? findMatch(interceptContextUrl.pathname)
|
|
3040
|
+
: prevMatch;
|
|
3041
|
+
|
|
3042
|
+
const matched = findMatch(pathname);
|
|
3043
|
+
|
|
3044
|
+
if (metricsStore) {
|
|
3045
|
+
metricsStore.metrics.push({
|
|
3046
|
+
label: "route-matching",
|
|
3047
|
+
duration: performance.now() - routeMatchStart,
|
|
3048
|
+
startTime: routeMatchStart - metricsStore.requestStart,
|
|
3049
|
+
});
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
if (!matched) {
|
|
3053
|
+
throw new RouteNotFoundError(`No route matched for ${pathname}`, {
|
|
3054
|
+
cause: { pathname, method: request.method, previousUrl },
|
|
3055
|
+
});
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
if (matched.redirectTo) {
|
|
3059
|
+
return null; // Fall back to full match for redirects
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
// Check if routes are from different route groups
|
|
3063
|
+
if (prevMatch && prevMatch.entry !== matched.entry) {
|
|
3064
|
+
console.log(
|
|
3065
|
+
`[Router.matchPartial] Route group changed: ${prevMatch.routeKey} → ${matched.routeKey}, falling back to full render`
|
|
3066
|
+
);
|
|
3067
|
+
return null;
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
// Load manifest
|
|
3071
|
+
const manifestStart = metricsStore ? performance.now() : 0;
|
|
3072
|
+
const manifestEntry = await loadManifest(
|
|
3073
|
+
matched.entry,
|
|
3074
|
+
matched.routeKey,
|
|
3075
|
+
pathname,
|
|
3076
|
+
metricsStore,
|
|
3077
|
+
false
|
|
3078
|
+
);
|
|
3079
|
+
if (metricsStore) {
|
|
3080
|
+
metricsStore.metrics.push({
|
|
3081
|
+
label: "manifest-loading",
|
|
3082
|
+
duration: performance.now() - manifestStart,
|
|
3083
|
+
startTime: manifestStart - metricsStore.requestStart,
|
|
3084
|
+
});
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
// Collect route middleware
|
|
3088
|
+
const routeMiddleware = collectRouteMiddleware(
|
|
3089
|
+
traverseBack(manifestEntry),
|
|
3090
|
+
matched.params
|
|
3091
|
+
);
|
|
3092
|
+
|
|
3093
|
+
// Create handler context
|
|
3094
|
+
const bindings = (env as any)?.Bindings ?? env;
|
|
3095
|
+
const handlerContext = createHandlerContext(
|
|
3096
|
+
matched.params,
|
|
3097
|
+
request,
|
|
3098
|
+
url.searchParams,
|
|
3099
|
+
pathname,
|
|
3100
|
+
url,
|
|
3101
|
+
bindings
|
|
3102
|
+
);
|
|
3103
|
+
|
|
3104
|
+
const clientSegmentSet = new Set(clientSegmentIds);
|
|
3105
|
+
console.log(
|
|
3106
|
+
`[Router.matchPartial] Client segments:`,
|
|
3107
|
+
Array.from(clientSegmentSet)
|
|
3108
|
+
);
|
|
3109
|
+
|
|
3110
|
+
// Set up loader promises
|
|
3111
|
+
const loaderPromises = new Map<string, Promise<any>>();
|
|
3112
|
+
setupLoaderAccess(handlerContext, loaderPromises);
|
|
3113
|
+
|
|
3114
|
+
// Get store for metrics context
|
|
3115
|
+
const Store = getContext().getOrCreateStore(matched.routeKey);
|
|
3116
|
+
// Add run helper for cleaner middleware code
|
|
3117
|
+
Store.run = <T>(fn: () => T | Promise<T>) =>
|
|
3118
|
+
getContext().runWithStore(
|
|
3119
|
+
Store,
|
|
3120
|
+
Store.namespace || "#router",
|
|
3121
|
+
Store.parent,
|
|
3122
|
+
fn
|
|
3123
|
+
);
|
|
3124
|
+
if (metricsStore) {
|
|
3125
|
+
Store.metrics = metricsStore;
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
// Intercept detection
|
|
3129
|
+
const isSameRouteNavigation = !!(
|
|
3130
|
+
interceptContextMatch &&
|
|
3131
|
+
interceptContextMatch.routeKey === matched.routeKey
|
|
3132
|
+
);
|
|
3133
|
+
|
|
3134
|
+
if (interceptSourceUrl) {
|
|
3135
|
+
console.log(`[Router.matchPartial] Intercept context detected:`);
|
|
3136
|
+
console.log(` - Current URL: ${pathname}`);
|
|
3137
|
+
console.log(` - Intercept source: ${interceptSourceUrl}`);
|
|
3138
|
+
console.log(` - Context match: ${interceptContextMatch?.routeKey}`);
|
|
3139
|
+
console.log(` - Current route: ${matched.routeKey}`);
|
|
3140
|
+
console.log(` - Same route navigation: ${isSameRouteNavigation}`);
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
const localRouteName = matched.routeKey.includes(".")
|
|
3144
|
+
? matched.routeKey.split(".").pop()!
|
|
3145
|
+
: matched.routeKey;
|
|
3146
|
+
|
|
3147
|
+
// Build intercept selector context
|
|
3148
|
+
const filteredSegmentIds = clientSegmentIds.filter((id) => {
|
|
3149
|
+
if (id.includes(".@")) return false;
|
|
3150
|
+
if (/D\d+\./.test(id)) return false;
|
|
3151
|
+
return true;
|
|
3152
|
+
});
|
|
3153
|
+
const interceptSelectorContext: InterceptSelectorContext = {
|
|
3154
|
+
from: prevUrl,
|
|
3155
|
+
to: url,
|
|
3156
|
+
params: matched.params,
|
|
3157
|
+
request,
|
|
3158
|
+
env,
|
|
3159
|
+
segments: {
|
|
3160
|
+
path: prevUrl.pathname.split("/").filter(Boolean),
|
|
3161
|
+
ids: filteredSegmentIds,
|
|
3162
|
+
},
|
|
3163
|
+
};
|
|
3164
|
+
const isAction = !!actionContext;
|
|
3165
|
+
|
|
3166
|
+
// Find intercept
|
|
3167
|
+
const clientHasInterceptSegments = [...clientSegmentSet].some((id) =>
|
|
3168
|
+
id.includes(".@")
|
|
3169
|
+
);
|
|
3170
|
+
const skipInterceptForAction = isAction && !clientHasInterceptSegments;
|
|
3171
|
+
const interceptResult =
|
|
3172
|
+
isSameRouteNavigation || skipInterceptForAction
|
|
3173
|
+
? null
|
|
3174
|
+
: findInterceptForRoute(
|
|
3175
|
+
matched.routeKey,
|
|
3176
|
+
manifestEntry.parent,
|
|
3177
|
+
interceptSelectorContext,
|
|
3178
|
+
isAction
|
|
3179
|
+
) ||
|
|
3180
|
+
(localRouteName !== matched.routeKey
|
|
3181
|
+
? findInterceptForRoute(
|
|
3182
|
+
localRouteName,
|
|
3183
|
+
manifestEntry.parent,
|
|
3184
|
+
interceptSelectorContext,
|
|
3185
|
+
isAction
|
|
3186
|
+
)
|
|
3187
|
+
: null);
|
|
3188
|
+
|
|
3189
|
+
// When leaving intercept, force route segment to render
|
|
3190
|
+
if (isSameRouteNavigation && manifestEntry.type === "route") {
|
|
3191
|
+
console.log(
|
|
3192
|
+
`[Router.matchPartial] Leaving intercept - forcing route segment render: ${manifestEntry.shortCode}`
|
|
3193
|
+
);
|
|
3194
|
+
clientSegmentSet.delete(manifestEntry.shortCode);
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
// Collect entries and build cache scope
|
|
3198
|
+
const entries = [...traverseBack(manifestEntry)];
|
|
3199
|
+
let cacheScope: CacheScope | null = null;
|
|
3200
|
+
for (const entry of entries) {
|
|
3201
|
+
if (entry.cache) {
|
|
3202
|
+
cacheScope = createCacheScope(entry.cache, cacheScope);
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
const isIntercept = !!interceptResult;
|
|
3207
|
+
|
|
3208
|
+
return {
|
|
3209
|
+
request,
|
|
3210
|
+
url,
|
|
3211
|
+
pathname,
|
|
3212
|
+
env,
|
|
3213
|
+
bindings,
|
|
3214
|
+
clientSegmentIds,
|
|
3215
|
+
clientSegmentSet,
|
|
3216
|
+
stale,
|
|
3217
|
+
prevUrl,
|
|
3218
|
+
prevParams,
|
|
3219
|
+
prevMatch,
|
|
3220
|
+
matched,
|
|
3221
|
+
manifestEntry,
|
|
3222
|
+
entries,
|
|
3223
|
+
routeKey: matched.routeKey,
|
|
3224
|
+
localRouteName,
|
|
3225
|
+
handlerContext,
|
|
3226
|
+
loaderPromises,
|
|
3227
|
+
metricsStore,
|
|
3228
|
+
Store,
|
|
3229
|
+
interceptContextMatch,
|
|
3230
|
+
interceptSelectorContext,
|
|
3231
|
+
isSameRouteNavigation,
|
|
3232
|
+
interceptResult,
|
|
3233
|
+
cacheScope,
|
|
3234
|
+
isIntercept,
|
|
3235
|
+
actionContext,
|
|
3236
|
+
isAction,
|
|
3237
|
+
routeMiddleware,
|
|
3238
|
+
isFullMatch: false,
|
|
3239
|
+
};
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
/**
|
|
3243
|
+
* Match partial request with revalidation
|
|
3244
|
+
*
|
|
3245
|
+
* Uses generator middleware pipeline for clean separation of concerns:
|
|
3246
|
+
* - cache-lookup: Check cache first
|
|
3247
|
+
* - segment-resolution: Resolve segments on cache miss
|
|
3248
|
+
* - intercept-resolution: Handle intercept routes
|
|
3249
|
+
* - cache-store: Store results in cache
|
|
3250
|
+
* - background-revalidation: SWR revalidation
|
|
3251
|
+
*/
|
|
3252
|
+
async function matchPartial(
|
|
3253
|
+
request: Request,
|
|
3254
|
+
context: TEnv,
|
|
3255
|
+
actionContext?: ActionContext
|
|
3256
|
+
): Promise<MatchResult | null> {
|
|
3257
|
+
// Build RouterContext with all closure functions needed by middleware
|
|
3258
|
+
const routerCtx: RouterContext<TEnv> = {
|
|
3259
|
+
findMatch,
|
|
3260
|
+
loadManifest,
|
|
3261
|
+
traverseBack,
|
|
3262
|
+
createHandlerContext,
|
|
3263
|
+
setupLoaderAccess,
|
|
3264
|
+
setupLoaderAccessSilent,
|
|
3265
|
+
getContext,
|
|
3266
|
+
getMetricsStore,
|
|
3267
|
+
createCacheScope,
|
|
3268
|
+
findInterceptForRoute,
|
|
3269
|
+
resolveAllSegmentsWithRevalidation,
|
|
3270
|
+
resolveInterceptEntry,
|
|
3271
|
+
evaluateRevalidation,
|
|
3272
|
+
getRequestContext,
|
|
3273
|
+
resolveAllSegments,
|
|
3274
|
+
createHandleStore,
|
|
3275
|
+
buildEntryRevalidateMap,
|
|
3276
|
+
resolveLoadersOnlyWithRevalidation,
|
|
3277
|
+
resolveInterceptLoadersOnly,
|
|
3278
|
+
};
|
|
3279
|
+
|
|
3280
|
+
return runWithRouterContext(routerCtx, async () => {
|
|
3281
|
+
const ctx = await createMatchContextForPartial(
|
|
3282
|
+
request,
|
|
3283
|
+
context,
|
|
3284
|
+
actionContext
|
|
3285
|
+
);
|
|
3286
|
+
if (!ctx) return null;
|
|
3287
|
+
|
|
3288
|
+
try {
|
|
3289
|
+
const state = createPipelineState();
|
|
3290
|
+
const pipeline = createMatchPartialPipeline(ctx, state);
|
|
3291
|
+
return await collectMatchResult(pipeline, ctx, state);
|
|
3292
|
+
} catch (error) {
|
|
3293
|
+
if (error instanceof Response) throw error;
|
|
3294
|
+
// Report unhandled errors during partial match pipeline
|
|
3295
|
+
callOnError(error, actionContext ? "action" : "revalidation", {
|
|
3296
|
+
request,
|
|
3297
|
+
url: ctx.url,
|
|
3298
|
+
env: context,
|
|
3299
|
+
actionId: actionContext?.actionId,
|
|
3300
|
+
isPartial: true,
|
|
3301
|
+
handledByBoundary: false,
|
|
3302
|
+
});
|
|
3303
|
+
throw sanitizeError(error);
|
|
3304
|
+
}
|
|
3305
|
+
});
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
/**
|
|
3309
|
+
* Preview match - returns route middleware without segment resolution
|
|
3310
|
+
* Used by RSC handler to execute route middleware before full matching
|
|
3311
|
+
*/
|
|
3312
|
+
async function previewMatch(
|
|
3313
|
+
request: Request,
|
|
3314
|
+
context: TEnv
|
|
3315
|
+
): Promise<{
|
|
3316
|
+
routeMiddleware?: Array<{
|
|
3317
|
+
handler: import("./router/middleware.js").MiddlewareFn;
|
|
3318
|
+
params: Record<string, string>;
|
|
3319
|
+
}>;
|
|
3320
|
+
} | null> {
|
|
3321
|
+
const url = new URL(request.url);
|
|
3322
|
+
const pathname = url.pathname;
|
|
3323
|
+
|
|
3324
|
+
// Quick route matching
|
|
3325
|
+
const matched = findMatch(pathname);
|
|
3326
|
+
if (!matched) {
|
|
3327
|
+
return null;
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
// Skip redirect check - will be handled in full match
|
|
3331
|
+
if (matched.redirectTo) {
|
|
3332
|
+
return { routeMiddleware: undefined };
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
// Load manifest (without segment resolution)
|
|
3336
|
+
const manifestEntry = await loadManifest(
|
|
3337
|
+
matched.entry,
|
|
3338
|
+
matched.routeKey,
|
|
3339
|
+
pathname,
|
|
3340
|
+
undefined, // No metrics store for preview
|
|
3341
|
+
false // isSSR - doesn't matter for preview
|
|
3342
|
+
);
|
|
3343
|
+
|
|
3344
|
+
// Collect route-level middleware from entry tree
|
|
3345
|
+
// Includes middleware from orphan layouts (inline layouts within routes)
|
|
3346
|
+
const routeMiddleware = collectRouteMiddleware(
|
|
3347
|
+
traverseBack(manifestEntry),
|
|
3348
|
+
matched.params
|
|
3349
|
+
);
|
|
3350
|
+
|
|
3351
|
+
return {
|
|
3352
|
+
routeMiddleware: routeMiddleware.length > 0 ? routeMiddleware : undefined,
|
|
3353
|
+
};
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
/**
|
|
3357
|
+
* Create route builder with accumulated route types
|
|
3358
|
+
* The TNewRoutes type parameter captures the new routes being added
|
|
3359
|
+
*/
|
|
3360
|
+
function createRouteBuilder<TNewRoutes extends Record<string, string>>(
|
|
3361
|
+
prefix: string,
|
|
3362
|
+
routes: TNewRoutes
|
|
3363
|
+
): RouteBuilder<RouteDefinition, TEnv, TNewRoutes> {
|
|
3364
|
+
const currentMountIndex = mountIndex++;
|
|
3365
|
+
|
|
3366
|
+
// Merge routes into the href map with prefixes
|
|
3367
|
+
// This enables type-safe router.href() calls
|
|
3368
|
+
const routeEntries = routes as Record<string, string>;
|
|
3369
|
+
for (const [key, pattern] of Object.entries(routeEntries)) {
|
|
3370
|
+
// Build prefixed key: "shop" + "cart" -> "shop.cart"
|
|
3371
|
+
const prefixedKey = prefix ? `${prefix.slice(1)}.${key}` : key;
|
|
3372
|
+
// Build prefixed pattern: "/shop" + "/cart" -> "/shop/cart"
|
|
3373
|
+
const prefixedPattern =
|
|
3374
|
+
prefix && pattern !== "/"
|
|
3375
|
+
? `${prefix}${pattern}`
|
|
3376
|
+
: prefix && pattern === "/"
|
|
3377
|
+
? prefix
|
|
3378
|
+
: pattern;
|
|
3379
|
+
mergedRouteMap[prefixedKey] = prefixedPattern;
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
// Auto-register route map for runtime href() usage
|
|
3383
|
+
registerRouteMap(mergedRouteMap);
|
|
3384
|
+
|
|
3385
|
+
// Extract trailing slash config if present (attached by route())
|
|
3386
|
+
const trailingSlashConfig = (routes as any).__trailingSlash as
|
|
3387
|
+
| Record<string, TrailingSlashMode>
|
|
3388
|
+
| undefined;
|
|
3389
|
+
|
|
3390
|
+
// Create builder object so .use() can return it
|
|
3391
|
+
const builder: RouteBuilder<RouteDefinition, TEnv, TNewRoutes> = {
|
|
3392
|
+
use(
|
|
3393
|
+
patternOrMiddleware: string | MiddlewareFn<TEnv>,
|
|
3394
|
+
middleware?: MiddlewareFn<TEnv>
|
|
3395
|
+
) {
|
|
3396
|
+
// Mount-scoped middleware - prefix is the mount prefix
|
|
3397
|
+
addMiddleware(patternOrMiddleware, middleware, prefix || null);
|
|
3398
|
+
return builder;
|
|
3399
|
+
},
|
|
3400
|
+
|
|
3401
|
+
map(
|
|
3402
|
+
handler: () =>
|
|
3403
|
+
| Array<AllUseItems>
|
|
3404
|
+
| Promise<{ default: () => Array<AllUseItems> }>
|
|
3405
|
+
| Promise<() => Array<AllUseItems>>
|
|
3406
|
+
) {
|
|
3407
|
+
routesEntries.push({
|
|
3408
|
+
prefix,
|
|
3409
|
+
routes: routes as ResolvedRouteMap<any>,
|
|
3410
|
+
trailingSlash: trailingSlashConfig,
|
|
3411
|
+
handler,
|
|
3412
|
+
mountIndex: currentMountIndex,
|
|
3413
|
+
});
|
|
3414
|
+
// Return router with accumulated types
|
|
3415
|
+
// At runtime this is the same object, but TypeScript tracks the accumulated route types
|
|
3416
|
+
return router as any;
|
|
3417
|
+
},
|
|
3418
|
+
|
|
3419
|
+
// Expose accumulated route map for typeof extraction
|
|
3420
|
+
get routeMap() {
|
|
3421
|
+
return mergedRouteMap as TNewRoutes;
|
|
3422
|
+
},
|
|
3423
|
+
};
|
|
3424
|
+
|
|
3425
|
+
return builder;
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
/**
|
|
3429
|
+
* Router instance
|
|
3430
|
+
* The type system tracks accumulated routes through the builder chain
|
|
3431
|
+
* Initial TRoutes is {} (empty) to avoid poisoning accumulated types with Record<string, string>
|
|
3432
|
+
*/
|
|
3433
|
+
const router: RSCRouter<TEnv, {}> = {
|
|
3434
|
+
routes(
|
|
3435
|
+
prefixOrRoutes: string | Record<string, string>,
|
|
3436
|
+
maybeRoutes?: Record<string, string>
|
|
3437
|
+
): any {
|
|
3438
|
+
// If second argument exists, first is prefix
|
|
3439
|
+
if (maybeRoutes !== undefined) {
|
|
3440
|
+
return createRouteBuilder(prefixOrRoutes as string, maybeRoutes);
|
|
3441
|
+
}
|
|
3442
|
+
// Otherwise, first argument is routes with empty prefix
|
|
3443
|
+
return createRouteBuilder("", prefixOrRoutes as Record<string, string>);
|
|
3444
|
+
},
|
|
3445
|
+
|
|
3446
|
+
use(
|
|
3447
|
+
patternOrMiddleware: string | MiddlewareFn<TEnv>,
|
|
3448
|
+
middleware?: MiddlewareFn<TEnv>
|
|
3449
|
+
): any {
|
|
3450
|
+
// Global middleware - no mount prefix
|
|
3451
|
+
addMiddleware(patternOrMiddleware, middleware, null);
|
|
3452
|
+
return router;
|
|
3453
|
+
},
|
|
3454
|
+
|
|
3455
|
+
// Type-safe URL builder using merged route map
|
|
3456
|
+
// Types are tracked through the builder chain via TRoutes parameter
|
|
3457
|
+
href: createHref(mergedRouteMap),
|
|
3458
|
+
|
|
3459
|
+
// Expose accumulated route map for typeof extraction
|
|
3460
|
+
// Returns {} initially, but builder chain accumulates specific route types
|
|
3461
|
+
get routeMap() {
|
|
3462
|
+
return mergedRouteMap as {};
|
|
3463
|
+
},
|
|
3464
|
+
|
|
3465
|
+
// Expose rootLayout for renderSegments
|
|
3466
|
+
rootLayout,
|
|
3467
|
+
|
|
3468
|
+
// Expose onError callback for error handling
|
|
3469
|
+
onError,
|
|
3470
|
+
|
|
3471
|
+
// Expose cache configuration for RSC handler
|
|
3472
|
+
cache,
|
|
3473
|
+
|
|
3474
|
+
// Expose global middleware for RSC handler
|
|
3475
|
+
middleware: globalMiddleware,
|
|
3476
|
+
|
|
3477
|
+
match,
|
|
3478
|
+
matchPartial,
|
|
3479
|
+
matchError,
|
|
3480
|
+
previewMatch,
|
|
3481
|
+
};
|
|
3482
|
+
|
|
3483
|
+
return router;
|
|
3484
|
+
}
|