@rangojs/router 0.0.0-experimental.10
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/CLAUDE.md +43 -0
- package/README.md +19 -0
- package/dist/bin/rango.js +227 -0
- package/dist/vite/index.js +3039 -0
- package/package.json +171 -0
- package/skills/caching/SKILL.md +191 -0
- package/skills/debug-manifest/SKILL.md +108 -0
- package/skills/document-cache/SKILL.md +180 -0
- package/skills/fonts/SKILL.md +165 -0
- package/skills/hooks/SKILL.md +442 -0
- package/skills/intercept/SKILL.md +190 -0
- package/skills/layout/SKILL.md +213 -0
- package/skills/links/SKILL.md +180 -0
- package/skills/loader/SKILL.md +246 -0
- package/skills/middleware/SKILL.md +202 -0
- package/skills/mime-routes/SKILL.md +124 -0
- package/skills/parallel/SKILL.md +228 -0
- package/skills/prerender/SKILL.md +283 -0
- package/skills/rango/SKILL.md +54 -0
- package/skills/response-routes/SKILL.md +358 -0
- package/skills/route/SKILL.md +173 -0
- package/skills/router-setup/SKILL.md +346 -0
- package/skills/tailwind/SKILL.md +129 -0
- package/skills/theme/SKILL.md +78 -0
- package/skills/typesafety/SKILL.md +394 -0
- package/src/__internal.ts +175 -0
- package/src/bin/rango.ts +24 -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 +913 -0
- package/src/browser/navigation-client.ts +165 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +600 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +346 -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/mount-context.ts +32 -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 +203 -0
- package/src/browser/react/use-href.tsx +40 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-mount.ts +31 -0
- package/src/browser/react/use-navigation.ts +140 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +164 -0
- package/src/browser/rsc-router.tsx +352 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/segment-structure-assert.ts +67 -0
- package/src/browser/server-action-bridge.ts +762 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +478 -0
- package/src/build/generate-manifest.ts +377 -0
- package/src/build/generate-route-types.ts +828 -0
- package/src/build/index.ts +36 -0
- package/src/build/route-trie.ts +239 -0
- package/src/cache/cache-scope.ts +563 -0
- package/src/cache/cf/cf-cache-store.ts +428 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/document-cache.ts +340 -0
- package/src/cache/index.ts +58 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +392 -0
- package/src/client.rsc.tsx +83 -0
- package/src/client.tsx +643 -0
- package/src/component-utils.ts +76 -0
- package/src/components/DefaultDocument.tsx +23 -0
- package/src/debug.ts +233 -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 +295 -0
- package/src/handle.ts +130 -0
- package/src/handles/MetaTags.tsx +193 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/host/cookie-handler.ts +159 -0
- package/src/host/errors.ts +97 -0
- package/src/host/index.ts +56 -0
- package/src/host/pattern-matcher.ts +214 -0
- package/src/host/router.ts +330 -0
- package/src/host/testing.ts +79 -0
- package/src/host/types.ts +138 -0
- package/src/host/utils.ts +25 -0
- package/src/href-client.ts +202 -0
- package/src/href-context.ts +33 -0
- package/src/index.rsc.ts +121 -0
- package/src/index.ts +165 -0
- package/src/loader.rsc.ts +207 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/prerender/param-hash.ts +35 -0
- package/src/prerender/store.ts +40 -0
- package/src/prerender.ts +156 -0
- package/src/reverse.ts +267 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +193 -0
- package/src/route-definition.ts +1431 -0
- package/src/route-map-builder.ts +242 -0
- package/src/route-types.ts +220 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +158 -0
- package/src/router/intercept-resolution.ts +387 -0
- package/src/router/loader-resolution.ts +327 -0
- package/src/router/manifest.ts +216 -0
- package/src/router/match-api.ts +621 -0
- package/src/router/match-context.ts +264 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +382 -0
- package/src/router/match-middleware/cache-store.ts +276 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +281 -0
- package/src/router/match-middleware/segment-resolution.ts +184 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +213 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.ts +791 -0
- package/src/router/pattern-matching.ts +407 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +301 -0
- package/src/router/segment-resolution.ts +1315 -0
- package/src/router/trie-matching.ts +172 -0
- package/src/router/types.ts +163 -0
- package/src/router.gen.ts +6 -0
- package/src/router.ts +2423 -0
- package/src/rsc/handler.ts +1443 -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 +236 -0
- package/src/segment-system.tsx +442 -0
- package/src/server/context.ts +466 -0
- package/src/server/handle-store.ts +229 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +171 -0
- package/src/ssr/index.tsx +296 -0
- package/src/theme/ThemeProvider.tsx +291 -0
- package/src/theme/ThemeScript.tsx +61 -0
- package/src/theme/constants.ts +59 -0
- package/src/theme/index.ts +58 -0
- package/src/theme/theme-context.ts +70 -0
- package/src/theme/theme-script.ts +152 -0
- package/src/theme/types.ts +182 -0
- package/src/theme/use-theme.ts +44 -0
- package/src/types.ts +1757 -0
- package/src/urls.gen.ts +8 -0
- package/src/urls.ts +1282 -0
- package/src/use-loader.tsx +346 -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 +426 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/expose-prerender-handler-id.ts +429 -0
- package/src/vite/index.ts +2068 -0
- package/src/vite/package-resolution.ts +125 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +114 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intercept Resolution
|
|
3
|
+
*
|
|
4
|
+
* Extracted from createRouter closure. Contains intercept detection and resolution
|
|
5
|
+
* functions for soft navigation (modals).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import type {
|
|
10
|
+
EntryData,
|
|
11
|
+
InterceptEntry,
|
|
12
|
+
InterceptSelectorContext,
|
|
13
|
+
} from "../server/context";
|
|
14
|
+
import type { HandlerContext, ResolvedSegment } from "../types";
|
|
15
|
+
import { evaluateRevalidation } from "./revalidation.js";
|
|
16
|
+
import { getRequestContext } from "../server/request-context.js";
|
|
17
|
+
import { executeInterceptMiddleware } from "./middleware.js";
|
|
18
|
+
import { handleHandlerResult } from "./segment-resolution.js";
|
|
19
|
+
import type { SegmentResolutionDeps } from "./types.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if an intercept's when conditions are satisfied.
|
|
23
|
+
* All when() functions must return true for the intercept to activate.
|
|
24
|
+
* If no when() conditions are defined, the intercept always activates.
|
|
25
|
+
*
|
|
26
|
+
* During action revalidation, when() is NOT evaluated.
|
|
27
|
+
*/
|
|
28
|
+
export function evaluateInterceptWhen(
|
|
29
|
+
intercept: InterceptEntry,
|
|
30
|
+
selectorContext: InterceptSelectorContext | null,
|
|
31
|
+
isAction: boolean,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (isAction) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!intercept.when || intercept.when.length === 0) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!selectorContext) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return intercept.when.every((fn) => fn(selectorContext));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Find an intercept for the target route by walking up the entry chain.
|
|
50
|
+
* Returns the first (innermost) matching intercept along with the entry that defines it.
|
|
51
|
+
*/
|
|
52
|
+
export function findInterceptForRoute(
|
|
53
|
+
targetRouteKey: string,
|
|
54
|
+
fromEntry: EntryData | null,
|
|
55
|
+
selectorContext: InterceptSelectorContext | null = null,
|
|
56
|
+
isAction: boolean = false,
|
|
57
|
+
): { intercept: InterceptEntry; entry: EntryData } | null {
|
|
58
|
+
let current: EntryData | null = fromEntry;
|
|
59
|
+
|
|
60
|
+
while (current) {
|
|
61
|
+
if (current.intercept && current.intercept.length > 0) {
|
|
62
|
+
for (const intercept of current.intercept) {
|
|
63
|
+
if (
|
|
64
|
+
intercept.routeName === targetRouteKey &&
|
|
65
|
+
evaluateInterceptWhen(intercept, selectorContext, isAction)
|
|
66
|
+
) {
|
|
67
|
+
return { intercept, entry: current };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (current.layout && current.layout.length > 0) {
|
|
73
|
+
for (const siblingLayout of current.layout) {
|
|
74
|
+
if (siblingLayout.intercept && siblingLayout.intercept.length > 0) {
|
|
75
|
+
for (const intercept of siblingLayout.intercept) {
|
|
76
|
+
if (
|
|
77
|
+
intercept.routeName === targetRouteKey &&
|
|
78
|
+
evaluateInterceptWhen(intercept, selectorContext, isAction)
|
|
79
|
+
) {
|
|
80
|
+
return { intercept, entry: siblingLayout };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
current = current.parent;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve an intercept entry and emit segment with the slot name.
|
|
95
|
+
*/
|
|
96
|
+
export async function resolveInterceptEntry<TEnv>(
|
|
97
|
+
interceptEntry: InterceptEntry,
|
|
98
|
+
parentEntry: EntryData,
|
|
99
|
+
params: Record<string, string>,
|
|
100
|
+
context: HandlerContext<any, TEnv>,
|
|
101
|
+
belongsToRoute: boolean,
|
|
102
|
+
deps: SegmentResolutionDeps<TEnv>,
|
|
103
|
+
revalidationContext?: {
|
|
104
|
+
clientSegmentIds: Set<string>;
|
|
105
|
+
prevParams: Record<string, string>;
|
|
106
|
+
request: Request;
|
|
107
|
+
prevUrl: URL;
|
|
108
|
+
nextUrl: URL;
|
|
109
|
+
routeKey: string;
|
|
110
|
+
actionContext?: {
|
|
111
|
+
actionId?: string;
|
|
112
|
+
actionUrl?: URL;
|
|
113
|
+
actionResult?: any;
|
|
114
|
+
formData?: FormData;
|
|
115
|
+
};
|
|
116
|
+
stale?: boolean;
|
|
117
|
+
},
|
|
118
|
+
): Promise<ResolvedSegment[]> {
|
|
119
|
+
const segments: ResolvedSegment[] = [];
|
|
120
|
+
|
|
121
|
+
if (interceptEntry.middleware.length > 0) {
|
|
122
|
+
const requestCtx = getRequestContext();
|
|
123
|
+
if (!requestCtx?.res) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
"Request context with stubResponse is required for intercept middleware",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const middlewareResponse = await executeInterceptMiddleware(
|
|
129
|
+
interceptEntry.middleware,
|
|
130
|
+
context.request,
|
|
131
|
+
context.env,
|
|
132
|
+
params,
|
|
133
|
+
context.var as Record<string, any>,
|
|
134
|
+
requestCtx.res,
|
|
135
|
+
);
|
|
136
|
+
if (middlewareResponse) throw middlewareResponse;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const loaderPromises: Promise<any>[] = [];
|
|
140
|
+
const loaderIds: string[] = [];
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < interceptEntry.loader.length; i++) {
|
|
143
|
+
const { loader, revalidate: loaderRevalidateFns } =
|
|
144
|
+
interceptEntry.loader[i];
|
|
145
|
+
const segmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}D${i}.${loader.$$id}`;
|
|
146
|
+
|
|
147
|
+
if (revalidationContext) {
|
|
148
|
+
const {
|
|
149
|
+
clientSegmentIds,
|
|
150
|
+
prevParams,
|
|
151
|
+
request,
|
|
152
|
+
prevUrl,
|
|
153
|
+
nextUrl,
|
|
154
|
+
routeKey,
|
|
155
|
+
actionContext,
|
|
156
|
+
stale,
|
|
157
|
+
} = revalidationContext;
|
|
158
|
+
|
|
159
|
+
const interceptSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
|
|
160
|
+
if (clientSegmentIds.has(interceptSegmentId)) {
|
|
161
|
+
const dummySegment: ResolvedSegment = {
|
|
162
|
+
id: segmentId,
|
|
163
|
+
namespace: `intercept:${interceptEntry.routeName}`,
|
|
164
|
+
type: "loader",
|
|
165
|
+
index: i,
|
|
166
|
+
component: null,
|
|
167
|
+
params,
|
|
168
|
+
loaderId: loader.$$id,
|
|
169
|
+
belongsToRoute,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const shouldRevalidate = await evaluateRevalidation({
|
|
173
|
+
segment: dummySegment,
|
|
174
|
+
prevParams,
|
|
175
|
+
getPrevSegment: null,
|
|
176
|
+
request,
|
|
177
|
+
prevUrl,
|
|
178
|
+
nextUrl,
|
|
179
|
+
revalidations: loaderRevalidateFns.map((fn, j) => ({
|
|
180
|
+
name: `intercept-loader-revalidate${j}`,
|
|
181
|
+
fn,
|
|
182
|
+
})),
|
|
183
|
+
routeKey,
|
|
184
|
+
context,
|
|
185
|
+
actionContext,
|
|
186
|
+
stale,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (!shouldRevalidate) {
|
|
190
|
+
console.log(
|
|
191
|
+
`[Router] Intercept loader ${loader.$$id} skipped (revalidation=false)`,
|
|
192
|
+
);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
console.log(
|
|
196
|
+
`[Router] Intercept loader ${loader.$$id} revalidating (stale=${stale})`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
loaderIds.push(loader.$$id);
|
|
202
|
+
loaderPromises.push(
|
|
203
|
+
deps.wrapLoaderPromise(
|
|
204
|
+
context.use(loader),
|
|
205
|
+
parentEntry,
|
|
206
|
+
segmentId,
|
|
207
|
+
context.pathname,
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const handlerResult =
|
|
213
|
+
typeof interceptEntry.handler === "function"
|
|
214
|
+
? handleHandlerResult(interceptEntry.handler(context))
|
|
215
|
+
: interceptEntry.handler;
|
|
216
|
+
|
|
217
|
+
let layoutElement: ReactNode | undefined;
|
|
218
|
+
if (interceptEntry.layout) {
|
|
219
|
+
if (typeof interceptEntry.layout === "function") {
|
|
220
|
+
const layoutResult = await interceptEntry.layout(context);
|
|
221
|
+
if (layoutResult instanceof Response) {
|
|
222
|
+
throw layoutResult;
|
|
223
|
+
}
|
|
224
|
+
layoutElement = layoutResult;
|
|
225
|
+
} else {
|
|
226
|
+
layoutElement = interceptEntry.layout;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let component: ReactNode;
|
|
231
|
+
let loaderDataPromise: Promise<any[]> | any[] | undefined;
|
|
232
|
+
|
|
233
|
+
if (interceptEntry.loading && loaderPromises.length > 0) {
|
|
234
|
+
component =
|
|
235
|
+
handlerResult instanceof Promise
|
|
236
|
+
? handlerResult
|
|
237
|
+
: (Promise.resolve(handlerResult) as ReactNode);
|
|
238
|
+
loaderDataPromise = Promise.all(loaderPromises);
|
|
239
|
+
} else if (loaderPromises.length > 0) {
|
|
240
|
+
loaderDataPromise = await Promise.all(loaderPromises);
|
|
241
|
+
component =
|
|
242
|
+
handlerResult instanceof Promise ? await handlerResult : handlerResult;
|
|
243
|
+
} else {
|
|
244
|
+
component =
|
|
245
|
+
interceptEntry.loading && handlerResult instanceof Promise
|
|
246
|
+
? handlerResult
|
|
247
|
+
: handlerResult instanceof Promise
|
|
248
|
+
? await handlerResult
|
|
249
|
+
: handlerResult;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const interceptSegment = {
|
|
253
|
+
id: `${parentEntry.shortCode}.${interceptEntry.slotName}`,
|
|
254
|
+
namespace: `intercept:${interceptEntry.routeName}`,
|
|
255
|
+
type: "parallel" as const,
|
|
256
|
+
index: 0,
|
|
257
|
+
component,
|
|
258
|
+
loading: interceptEntry.loading === false ? null : interceptEntry.loading,
|
|
259
|
+
layout: layoutElement,
|
|
260
|
+
params,
|
|
261
|
+
slot: interceptEntry.slotName,
|
|
262
|
+
belongsToRoute,
|
|
263
|
+
parallelName: `intercept:${interceptEntry.routeName}.${interceptEntry.slotName}`,
|
|
264
|
+
loaderDataPromise,
|
|
265
|
+
loaderIds: loaderIds.length > 0 ? loaderIds : undefined,
|
|
266
|
+
};
|
|
267
|
+
segments.push(interceptSegment);
|
|
268
|
+
|
|
269
|
+
return segments;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Resolve only the loaders for a cached intercept segment.
|
|
274
|
+
* Used on intercept cache hit to get fresh loader data while keeping cached component/layout.
|
|
275
|
+
*/
|
|
276
|
+
export async function resolveInterceptLoadersOnly<TEnv>(
|
|
277
|
+
interceptEntry: InterceptEntry,
|
|
278
|
+
parentEntry: EntryData,
|
|
279
|
+
params: Record<string, string>,
|
|
280
|
+
context: HandlerContext<any, TEnv>,
|
|
281
|
+
belongsToRoute: boolean,
|
|
282
|
+
deps: SegmentResolutionDeps<TEnv>,
|
|
283
|
+
revalidationContext: {
|
|
284
|
+
clientSegmentIds: Set<string>;
|
|
285
|
+
prevParams: Record<string, string>;
|
|
286
|
+
request: Request;
|
|
287
|
+
prevUrl: URL;
|
|
288
|
+
nextUrl: URL;
|
|
289
|
+
routeKey: string;
|
|
290
|
+
actionContext?: {
|
|
291
|
+
actionId?: string;
|
|
292
|
+
actionUrl?: URL;
|
|
293
|
+
actionResult?: any;
|
|
294
|
+
formData?: FormData;
|
|
295
|
+
};
|
|
296
|
+
stale?: boolean;
|
|
297
|
+
},
|
|
298
|
+
): Promise<{
|
|
299
|
+
loaderDataPromise: Promise<any[]> | any[];
|
|
300
|
+
loaderIds: string[];
|
|
301
|
+
} | null> {
|
|
302
|
+
if (interceptEntry.loader.length === 0) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const loaderPromises: Promise<any>[] = [];
|
|
307
|
+
const loaderIds: string[] = [];
|
|
308
|
+
|
|
309
|
+
const {
|
|
310
|
+
clientSegmentIds,
|
|
311
|
+
prevParams,
|
|
312
|
+
request,
|
|
313
|
+
prevUrl,
|
|
314
|
+
nextUrl,
|
|
315
|
+
routeKey,
|
|
316
|
+
actionContext,
|
|
317
|
+
stale,
|
|
318
|
+
} = revalidationContext;
|
|
319
|
+
|
|
320
|
+
for (let i = 0; i < interceptEntry.loader.length; i++) {
|
|
321
|
+
const { loader, revalidate: loaderRevalidateFns } =
|
|
322
|
+
interceptEntry.loader[i];
|
|
323
|
+
const segmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}D${i}.${loader.$$id}`;
|
|
324
|
+
|
|
325
|
+
const interceptSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
|
|
326
|
+
if (clientSegmentIds.has(interceptSegmentId)) {
|
|
327
|
+
const dummySegment: ResolvedSegment = {
|
|
328
|
+
id: segmentId,
|
|
329
|
+
namespace: `intercept:${interceptEntry.routeName}`,
|
|
330
|
+
type: "loader",
|
|
331
|
+
index: i,
|
|
332
|
+
component: null,
|
|
333
|
+
params,
|
|
334
|
+
loaderId: loader.$$id,
|
|
335
|
+
belongsToRoute,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const shouldRevalidate = await evaluateRevalidation({
|
|
339
|
+
segment: dummySegment,
|
|
340
|
+
prevParams,
|
|
341
|
+
getPrevSegment: null,
|
|
342
|
+
request,
|
|
343
|
+
prevUrl,
|
|
344
|
+
nextUrl,
|
|
345
|
+
revalidations: loaderRevalidateFns.map((fn, j) => ({
|
|
346
|
+
name: `intercept-loader-revalidate${j}`,
|
|
347
|
+
fn,
|
|
348
|
+
})),
|
|
349
|
+
routeKey,
|
|
350
|
+
context,
|
|
351
|
+
actionContext,
|
|
352
|
+
stale,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (!shouldRevalidate) {
|
|
356
|
+
console.log(
|
|
357
|
+
`[Router] Intercept loader ${loader.$$id} skipped (cache hit, revalidation=false)`,
|
|
358
|
+
);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
console.log(
|
|
362
|
+
`[Router] Intercept loader ${loader.$$id} revalidating on cache hit (stale=${stale})`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
loaderIds.push(loader.$$id);
|
|
367
|
+
loaderPromises.push(
|
|
368
|
+
deps.wrapLoaderPromise(
|
|
369
|
+
context.use(loader),
|
|
370
|
+
parentEntry,
|
|
371
|
+
segmentId,
|
|
372
|
+
context.pathname,
|
|
373
|
+
),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (loaderPromises.length === 0) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const loaderDataPromise =
|
|
382
|
+
interceptEntry.loading !== undefined
|
|
383
|
+
? Promise.all(loaderPromises)
|
|
384
|
+
: await Promise.all(loaderPromises);
|
|
385
|
+
|
|
386
|
+
return { loaderDataPromise, loaderIds };
|
|
387
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router Loader Resolution
|
|
3
|
+
*
|
|
4
|
+
* Loader execution, memoization, and error handling utilities.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { track } from "../server/context";
|
|
9
|
+
import type { EntryData } from "../server/context";
|
|
10
|
+
import type {
|
|
11
|
+
ResolvedSegment,
|
|
12
|
+
HandlerContext,
|
|
13
|
+
InternalHandlerContext,
|
|
14
|
+
LoaderDefinition,
|
|
15
|
+
LoaderContext,
|
|
16
|
+
LoaderDataResult,
|
|
17
|
+
ErrorBoundaryHandler,
|
|
18
|
+
ErrorBoundaryFallbackProps,
|
|
19
|
+
ErrorInfo,
|
|
20
|
+
} from "../types";
|
|
21
|
+
import type { LoaderRevalidationResult, ActionContext } from "./types";
|
|
22
|
+
import { isHandle, type Handle } from "../handle.js";
|
|
23
|
+
import type { HandleStore } from "../server/handle-store.js";
|
|
24
|
+
import { getFetchableLoader } from "../loader.rsc.js";
|
|
25
|
+
import { getRequestContext } from "../server/request-context.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Internal callback signature for loader error notifications.
|
|
29
|
+
* This is a simplified callback for internal use in wrapLoaderWithErrorHandling.
|
|
30
|
+
* The caller (wrapLoaderPromise in router.ts) bridges this to the full OnErrorCallback.
|
|
31
|
+
*/
|
|
32
|
+
export type LoaderErrorCallback = (
|
|
33
|
+
error: unknown,
|
|
34
|
+
context: {
|
|
35
|
+
segmentId: string;
|
|
36
|
+
loaderName: string;
|
|
37
|
+
handledByBoundary: boolean;
|
|
38
|
+
}
|
|
39
|
+
) => void;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wrap a loader promise with error handling for deferred client-side resolution.
|
|
43
|
+
* Catches errors and converts them to LoaderDataResult objects that include
|
|
44
|
+
* error info and pre-rendered fallback UI when an error boundary is available.
|
|
45
|
+
*
|
|
46
|
+
* @param onError - Optional callback invoked when loader errors occur.
|
|
47
|
+
* This has a simplified signature for internal use - the caller (typically
|
|
48
|
+
* wrapLoaderPromise in router.ts) is responsible for bridging to the full
|
|
49
|
+
* OnErrorCallback with complete request context (request, url, env, etc.).
|
|
50
|
+
*/
|
|
51
|
+
export function wrapLoaderWithErrorHandling<T>(
|
|
52
|
+
promise: Promise<T>,
|
|
53
|
+
entry: EntryData,
|
|
54
|
+
segmentId: string,
|
|
55
|
+
pathname: string,
|
|
56
|
+
findNearestErrorBoundary: (
|
|
57
|
+
entry: EntryData | null
|
|
58
|
+
) => ReactNode | ErrorBoundaryHandler | null,
|
|
59
|
+
createErrorInfo: (
|
|
60
|
+
error: unknown,
|
|
61
|
+
segmentId: string,
|
|
62
|
+
segmentType: ErrorInfo["segmentType"]
|
|
63
|
+
) => ErrorInfo,
|
|
64
|
+
onError?: LoaderErrorCallback
|
|
65
|
+
): Promise<LoaderDataResult<T>> {
|
|
66
|
+
// Extract loader name from segmentId (format: "M1L0D0.loaderName")
|
|
67
|
+
const loaderName = segmentId.split(".").pop() || "unknown";
|
|
68
|
+
|
|
69
|
+
return Promise.resolve(promise)
|
|
70
|
+
.then(
|
|
71
|
+
(data): LoaderDataResult<T> => ({
|
|
72
|
+
__loaderResult: true,
|
|
73
|
+
ok: true,
|
|
74
|
+
data,
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
.catch((error): LoaderDataResult<T> => {
|
|
78
|
+
// Find nearest error boundary
|
|
79
|
+
const fallback = findNearestErrorBoundary(entry);
|
|
80
|
+
|
|
81
|
+
// Create error info
|
|
82
|
+
const errorInfo = createErrorInfo(error, segmentId, "loader");
|
|
83
|
+
|
|
84
|
+
// Invoke onError callback if provided
|
|
85
|
+
onError?.(error, {
|
|
86
|
+
segmentId,
|
|
87
|
+
loaderName,
|
|
88
|
+
handledByBoundary: !!fallback,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (!fallback) {
|
|
92
|
+
// No error boundary - return error result without fallback
|
|
93
|
+
// Client will throw this error
|
|
94
|
+
return {
|
|
95
|
+
__loaderResult: true,
|
|
96
|
+
ok: false,
|
|
97
|
+
error: errorInfo,
|
|
98
|
+
fallback: null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Render fallback on server
|
|
103
|
+
let renderedFallback: ReactNode;
|
|
104
|
+
if (typeof fallback === "function") {
|
|
105
|
+
// ErrorBoundaryHandler - call with error info
|
|
106
|
+
const props: ErrorBoundaryFallbackProps = {
|
|
107
|
+
error: errorInfo,
|
|
108
|
+
};
|
|
109
|
+
renderedFallback = fallback(props);
|
|
110
|
+
} else {
|
|
111
|
+
renderedFallback = fallback;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(
|
|
115
|
+
`[Router] Loader error wrapped with boundary fallback in ${segmentId}:`,
|
|
116
|
+
errorInfo.message
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
__loaderResult: true,
|
|
121
|
+
ok: false,
|
|
122
|
+
error: errorInfo,
|
|
123
|
+
fallback: renderedFallback,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Set up the use() method on handler context to access loaders and handles.
|
|
130
|
+
*
|
|
131
|
+
* For loaders: Lazily runs loaders, memoizes results per request.
|
|
132
|
+
* For handles: Returns a push function bound to the current segment.
|
|
133
|
+
*/
|
|
134
|
+
export function setupLoaderAccess<TEnv>(
|
|
135
|
+
ctx: HandlerContext<any, TEnv>,
|
|
136
|
+
loaderPromises: Map<string, Promise<any>>
|
|
137
|
+
): void {
|
|
138
|
+
// Get HandleStore from request context
|
|
139
|
+
const getHandleStore = (): HandleStore | undefined => {
|
|
140
|
+
return getRequestContext()?._handleStore;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// The use() function handles both loaders and handles
|
|
144
|
+
ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
|
|
145
|
+
// Handle case: return a push function
|
|
146
|
+
if (isHandle(item)) {
|
|
147
|
+
const handle = item;
|
|
148
|
+
const store = getHandleStore();
|
|
149
|
+
const segmentId = (ctx as InternalHandlerContext)._currentSegmentId;
|
|
150
|
+
|
|
151
|
+
if (!segmentId) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Handle "${handle.$$id}" used outside of handler context. ` +
|
|
154
|
+
`Handles must be used within route/layout handlers.`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Return a push function bound to this handle and segment
|
|
159
|
+
// Accepts: value, Promise, or async callback (executed immediately)
|
|
160
|
+
// Promises are pushed directly - RSC will serialize and stream them
|
|
161
|
+
return (dataOrFn: unknown | Promise<unknown> | (() => Promise<unknown>)) => {
|
|
162
|
+
if (!store) return;
|
|
163
|
+
|
|
164
|
+
// If it's a function, call it immediately to get the promise
|
|
165
|
+
const valueOrPromise = typeof dataOrFn === "function"
|
|
166
|
+
? (dataOrFn as () => Promise<unknown>)()
|
|
167
|
+
: dataOrFn;
|
|
168
|
+
|
|
169
|
+
// Push directly - promises will be serialized by RSC and streamed
|
|
170
|
+
store.push(handle.$$id, segmentId, valueOrPromise);
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Loader case: existing behavior
|
|
175
|
+
const loader = item as LoaderDefinition<any, any>;
|
|
176
|
+
|
|
177
|
+
// Return cached promise if already started
|
|
178
|
+
if (loaderPromises.has(loader.$$id)) {
|
|
179
|
+
return loaderPromises.get(loader.$$id);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Get loader function - either from loader object or fetchable registry
|
|
183
|
+
// Fetchable loaders store fn in registry (not on object) to avoid client bundling issues
|
|
184
|
+
let loaderFn = loader.fn;
|
|
185
|
+
if (!loaderFn) {
|
|
186
|
+
const fetchable = getFetchableLoader(loader.$$id);
|
|
187
|
+
if (fetchable) {
|
|
188
|
+
loaderFn = fetchable.fn;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Ensure loader has a function
|
|
193
|
+
if (!loaderFn) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Loader "${loader.$$id}" has no function. This usually means the loader was defined without "use server" and the function was not included in the build.`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Create loader context with recursive use() support
|
|
200
|
+
const loaderCtx: LoaderContext<Record<string, string | undefined>, TEnv> = {
|
|
201
|
+
params: ctx.params,
|
|
202
|
+
request: ctx.request,
|
|
203
|
+
searchParams: ctx.searchParams,
|
|
204
|
+
pathname: ctx.pathname,
|
|
205
|
+
url: ctx.url,
|
|
206
|
+
env: ctx.env,
|
|
207
|
+
var: ctx.var,
|
|
208
|
+
get: ctx.get,
|
|
209
|
+
use: <TDep, TDepParams = any>(
|
|
210
|
+
dep: LoaderDefinition<TDep, TDepParams>
|
|
211
|
+
): Promise<TDep> => {
|
|
212
|
+
// Recursive call - will start dep loader if not already started
|
|
213
|
+
return ctx.use(dep);
|
|
214
|
+
},
|
|
215
|
+
// Default to GET for loaders called through route handlers
|
|
216
|
+
method: "GET",
|
|
217
|
+
body: undefined,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Start loader execution with tracking
|
|
221
|
+
const doneLoader = track(`loader:${loader.$$id}`);
|
|
222
|
+
const promise = Promise.resolve(
|
|
223
|
+
loaderFn(loaderCtx as LoaderContext<any, TEnv>)
|
|
224
|
+
).finally(() => {
|
|
225
|
+
doneLoader();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Memoize for subsequent calls
|
|
229
|
+
loaderPromises.set(loader.$$id, promise);
|
|
230
|
+
|
|
231
|
+
return promise;
|
|
232
|
+
}) as typeof ctx.use;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Set up ctx.use() for proactive caching (silent mode).
|
|
237
|
+
* Handles are silently ignored (no push to HandleStore).
|
|
238
|
+
* Loaders work normally but with fresh memoization.
|
|
239
|
+
*
|
|
240
|
+
* This prevents duplicate handle data (breadcrumbs, meta) from being
|
|
241
|
+
* pushed to the response stream during background proactive caching.
|
|
242
|
+
*/
|
|
243
|
+
export function setupLoaderAccessSilent<TEnv>(
|
|
244
|
+
ctx: HandlerContext<any, TEnv>,
|
|
245
|
+
loaderPromises: Map<string, Promise<any>>
|
|
246
|
+
): void {
|
|
247
|
+
ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
|
|
248
|
+
// Handle case: return a no-op push function
|
|
249
|
+
if (isHandle(item)) {
|
|
250
|
+
// Silent mode - return a function that does nothing
|
|
251
|
+
return (_dataOrFn: unknown) => {
|
|
252
|
+
// Intentionally empty - don't push handle data during proactive caching
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Loader case: same as setupLoaderAccess
|
|
257
|
+
const loader = item as LoaderDefinition<any, any>;
|
|
258
|
+
|
|
259
|
+
// Return cached promise if already started
|
|
260
|
+
if (loaderPromises.has(loader.$$id)) {
|
|
261
|
+
return loaderPromises.get(loader.$$id);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Get loader function
|
|
265
|
+
let loaderFn = loader.fn;
|
|
266
|
+
if (!loaderFn) {
|
|
267
|
+
const fetchable = getFetchableLoader(loader.$$id);
|
|
268
|
+
if (fetchable) {
|
|
269
|
+
loaderFn = fetchable.fn;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!loaderFn) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`Loader "${loader.$$id}" has no function. This usually means the loader was defined without "use server" and the function was not included in the build.`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Create loader context with recursive use() support
|
|
280
|
+
const loaderCtx: LoaderContext<Record<string, string | undefined>, TEnv> = {
|
|
281
|
+
params: ctx.params,
|
|
282
|
+
request: ctx.request,
|
|
283
|
+
searchParams: ctx.searchParams,
|
|
284
|
+
pathname: ctx.pathname,
|
|
285
|
+
url: ctx.url,
|
|
286
|
+
env: ctx.env,
|
|
287
|
+
var: ctx.var,
|
|
288
|
+
get: ctx.get,
|
|
289
|
+
use: <TDep, TDepParams = any>(
|
|
290
|
+
dep: LoaderDefinition<TDep, TDepParams>
|
|
291
|
+
): Promise<TDep> => {
|
|
292
|
+
return ctx.use(dep);
|
|
293
|
+
},
|
|
294
|
+
method: "GET",
|
|
295
|
+
body: undefined,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// Start loader execution with tracking
|
|
299
|
+
const doneLoader = track(`loader:${loader.$$id}`);
|
|
300
|
+
const promise = Promise.resolve(
|
|
301
|
+
loaderFn(loaderCtx as LoaderContext<any, TEnv>)
|
|
302
|
+
).finally(() => {
|
|
303
|
+
doneLoader();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
loaderPromises.set(loader.$$id, promise);
|
|
307
|
+
return promise;
|
|
308
|
+
}) as typeof ctx.use;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Conditional execution based on revalidation
|
|
313
|
+
* Evaluates revalidation logic lazily, then executes appropriate callback
|
|
314
|
+
*
|
|
315
|
+
* @param shouldRevalidate - Async function that determines if revalidation is needed
|
|
316
|
+
* @param onRevalidate - Callback executed if revalidation returns true
|
|
317
|
+
* @param onSkip - Callback executed if revalidation returns false
|
|
318
|
+
* @returns Result from either onRevalidate or onSkip
|
|
319
|
+
*/
|
|
320
|
+
export async function revalidate<T>(
|
|
321
|
+
shouldRevalidate: () => Promise<boolean>,
|
|
322
|
+
onRevalidate: () => Promise<T>,
|
|
323
|
+
onSkip: () => T
|
|
324
|
+
): Promise<T> {
|
|
325
|
+
const needsRevalidation = await shouldRevalidate();
|
|
326
|
+
return needsRevalidation ? await onRevalidate() : onSkip();
|
|
327
|
+
}
|