@rangojs/router 0.0.0-experimental.111 → 0.0.0-experimental.112
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/dist/bin/rango.js +41 -37
- package/dist/vite/index.js +144 -191
- package/package.json +3 -1
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/event-controller.ts +42 -66
- package/src/browser/navigation-bridge.ts +4 -0
- package/src/browser/navigation-client.ts +12 -15
- package/src/browser/navigation-store.ts +7 -8
- package/src/browser/navigation-transaction.ts +7 -21
- package/src/browser/partial-update.ts +8 -16
- package/src/browser/react/NavigationProvider.tsx +29 -40
- package/src/browser/react/use-params.ts +3 -4
- package/src/browser/response-adapter.ts +25 -0
- package/src/browser/rsc-router.tsx +16 -2
- package/src/browser/server-action-bridge.ts +23 -30
- package/src/browser/types.ts +2 -0
- package/src/build/generate-manifest.ts +29 -31
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/route-types/router-processing.ts +37 -9
- package/src/build/runtime-discovery.ts +9 -20
- package/src/decode-loader-results.ts +36 -0
- package/src/errors.ts +11 -0
- package/src/response-utils.ts +9 -0
- package/src/route-content-wrapper.tsx +6 -28
- package/src/router/content-negotiation.ts +15 -2
- package/src/router/intercept-resolution.ts +4 -18
- package/src/router/match-result.ts +32 -30
- package/src/router/middleware.ts +46 -78
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +4 -28
- package/src/rsc/handler.ts +20 -65
- package/src/rsc/helpers.ts +3 -2
- package/src/rsc/origin-guard.ts +28 -10
- package/src/rsc/response-route-handler.ts +32 -52
- package/src/rsc/rsc-rendering.ts +27 -53
- package/src/rsc/runtime-warnings.ts +9 -10
- package/src/rsc/server-action.ts +13 -37
- package/src/rsc/ssr-setup.ts +16 -0
- package/src/segment-system.tsx +5 -39
- package/src/vite/discovery/discover-routers.ts +10 -22
- package/src/vite/discovery/route-types-writer.ts +38 -82
- package/src/vite/plugins/cjs-to-esm.ts +3 -7
- package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
- package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
- package/src/vite/plugins/expose-internal-ids.ts +34 -62
- package/src/vite/plugins/version-injector.ts +2 -12
- package/src/vite/router-discovery.ts +71 -26
- package/src/vite/utils/shared-utils.ts +13 -1
- package/src/browser/action-response-classifier.ts +0 -99
|
@@ -25,6 +25,7 @@ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
|
|
|
25
25
|
import {
|
|
26
26
|
extractRscHeaderUrl,
|
|
27
27
|
emptyResponse,
|
|
28
|
+
handleReloadHeader,
|
|
28
29
|
teeWithCompletion,
|
|
29
30
|
} from "./response-adapter.js";
|
|
30
31
|
import { mergeLocationState } from "./history-state.js";
|
|
@@ -77,6 +78,20 @@ export function createServerActionBridge(
|
|
|
77
78
|
onNavigate,
|
|
78
79
|
} = config;
|
|
79
80
|
|
|
81
|
+
// SPA-navigate when onNavigate is set, else hard-reload. state is omitted (not
|
|
82
|
+
// passed as undefined) to match the header path's prior call shape.
|
|
83
|
+
async function dispatchRedirect(url: string, state?: unknown): Promise<void> {
|
|
84
|
+
if (onNavigate) {
|
|
85
|
+
await onNavigate(url, {
|
|
86
|
+
...(state !== undefined ? { state } : {}),
|
|
87
|
+
replace: true,
|
|
88
|
+
_skipCache: true,
|
|
89
|
+
});
|
|
90
|
+
} else {
|
|
91
|
+
window.location.href = url;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
let isRegistered = false;
|
|
81
96
|
|
|
82
97
|
const fetchPartialUpdate = createPartialUpdater({
|
|
@@ -222,18 +237,12 @@ export function createServerActionBridge(
|
|
|
222
237
|
handle.signal.removeEventListener("abort", onHandleAbort);
|
|
223
238
|
|
|
224
239
|
// Check for version mismatch - server wants us to reload
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
if (
|
|
231
|
-
log("version mismatch on action, reloading", {
|
|
232
|
-
reloadUrl: reload.url,
|
|
233
|
-
});
|
|
234
|
-
window.location.href = reload.url;
|
|
235
|
-
return new Promise<Response>(() => {});
|
|
236
|
-
}
|
|
240
|
+
const reloadResult = handleReloadHeader(response, {
|
|
241
|
+
onBlocked: resolveStreamComplete,
|
|
242
|
+
onReload: (url) =>
|
|
243
|
+
log("version mismatch on action, reloading", { reloadUrl: url }),
|
|
244
|
+
});
|
|
245
|
+
if (reloadResult) return reloadResult;
|
|
237
246
|
|
|
238
247
|
// Simple redirect from action (no state, no RSC payload).
|
|
239
248
|
// Short-circuits before createFromFetch — no Flight deserialization needed.
|
|
@@ -243,14 +252,7 @@ export function createServerActionBridge(
|
|
|
243
252
|
if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
|
|
244
253
|
log("action simple redirect", { url: redirect.url });
|
|
245
254
|
handle.complete(undefined);
|
|
246
|
-
|
|
247
|
-
await onNavigate(redirect.url, {
|
|
248
|
-
replace: true,
|
|
249
|
-
_skipCache: true,
|
|
250
|
-
});
|
|
251
|
-
} else {
|
|
252
|
-
window.location.href = redirect.url;
|
|
253
|
-
}
|
|
255
|
+
await dispatchRedirect(redirect.url);
|
|
254
256
|
return new Promise<Response>(() => {});
|
|
255
257
|
}
|
|
256
258
|
if (redirect === "blocked") {
|
|
@@ -339,18 +341,9 @@ export function createServerActionBridge(
|
|
|
339
341
|
handle.complete(returnValue?.data);
|
|
340
342
|
return returnValue?.data;
|
|
341
343
|
}
|
|
342
|
-
const redirectState = metadata.locationState;
|
|
343
344
|
log("action redirect", { url: redirectUrl });
|
|
344
345
|
handle.complete(returnValue?.data);
|
|
345
|
-
|
|
346
|
-
await onNavigate(redirectUrl, {
|
|
347
|
-
state: redirectState,
|
|
348
|
-
replace: true,
|
|
349
|
-
_skipCache: true,
|
|
350
|
-
});
|
|
351
|
-
} else {
|
|
352
|
-
window.location.href = redirectUrl;
|
|
353
|
-
}
|
|
346
|
+
await dispatchRedirect(redirectUrl, metadata.locationState);
|
|
354
347
|
return returnValue?.data;
|
|
355
348
|
}
|
|
356
349
|
|
package/src/browser/types.ts
CHANGED
|
@@ -552,6 +552,8 @@ export interface NavigationBridge {
|
|
|
552
552
|
refresh(): Promise<void>;
|
|
553
553
|
handlePopstate(): Promise<void>;
|
|
554
554
|
registerLinkInterception(): () => void;
|
|
555
|
+
/** Current RSC version (live, reflects the latest updateVersion). */
|
|
556
|
+
getVersion(): string | undefined;
|
|
555
557
|
/** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
|
|
556
558
|
updateVersion(newVersion: string): void;
|
|
557
559
|
/**
|
|
@@ -57,6 +57,26 @@ export interface GeneratedManifest {
|
|
|
57
57
|
* Build prefix tree node by running the patterns with proper context.
|
|
58
58
|
* Uses a visited set to detect circular includes and prevent infinite recursion.
|
|
59
59
|
*/
|
|
60
|
+
// Merge tracked nested includes into `target`. Multiple includes can share a
|
|
61
|
+
// fullPrefix (e.g. include("/", a), include("/", b)) — concat their routes and
|
|
62
|
+
// Object.assign children rather than overwrite.
|
|
63
|
+
function mergeIncludeNodes(
|
|
64
|
+
target: Record<string, PrefixTreeNode>,
|
|
65
|
+
includes: TrackedInclude[],
|
|
66
|
+
buildChild: (include: TrackedInclude) => PrefixTreeNode,
|
|
67
|
+
): void {
|
|
68
|
+
for (const include of includes) {
|
|
69
|
+
const node = buildChild(include);
|
|
70
|
+
const existing = target[include.fullPrefix];
|
|
71
|
+
if (existing) {
|
|
72
|
+
existing.routes.push(...node.routes);
|
|
73
|
+
Object.assign(existing.children, node.children);
|
|
74
|
+
} else {
|
|
75
|
+
target[include.fullPrefix] = node;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
function buildPrefixTreeNode(
|
|
61
81
|
urlPrefix: string,
|
|
62
82
|
namePrefix: string | undefined,
|
|
@@ -166,13 +186,9 @@ function buildPrefixTreeNode(
|
|
|
166
186
|
}
|
|
167
187
|
}
|
|
168
188
|
|
|
169
|
-
// Build children from tracked nested includes.
|
|
170
|
-
// Multiple includes can share the same fullPrefix (e.g., include("/", patternsA),
|
|
171
|
-
// include("/", patternsB)). Merge their routes instead of overwriting.
|
|
172
189
|
const children: Record<string, PrefixTreeNode> = {};
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const childNode = buildPrefixTreeNode(
|
|
190
|
+
mergeIncludeNodes(children, trackedIncludes, (include) =>
|
|
191
|
+
buildPrefixTreeNode(
|
|
176
192
|
include.fullPrefix,
|
|
177
193
|
include.namePrefix,
|
|
178
194
|
include.patterns as UrlPatterns<any>,
|
|
@@ -186,16 +202,8 @@ function buildPrefixTreeNode(
|
|
|
186
202
|
passthroughRoutes,
|
|
187
203
|
responseTypeRoutes,
|
|
188
204
|
routeSearchSchemas,
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
const existing = children[include.fullPrefix];
|
|
192
|
-
if (existing) {
|
|
193
|
-
existing.routes.push(...childNode.routes);
|
|
194
|
-
Object.assign(existing.children, childNode.children);
|
|
195
|
-
} else {
|
|
196
|
-
children[include.fullPrefix] = childNode;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
205
|
+
),
|
|
206
|
+
);
|
|
199
207
|
|
|
200
208
|
// Remove from visited so sibling branches can reuse the same patterns
|
|
201
209
|
// without false circular-include detection. Only ancestors in the current
|
|
@@ -356,12 +364,10 @@ export function generateManifestFull<TEnv>(
|
|
|
356
364
|
}
|
|
357
365
|
}
|
|
358
366
|
|
|
359
|
-
//
|
|
360
|
-
// Multiple includes can share the same fullPrefix (e.g., include("/", patternsA),
|
|
361
|
-
// include("/", patternsB)). Merge their routes instead of overwriting.
|
|
367
|
+
// Shared visited set for cycle detection across all root-level includes.
|
|
362
368
|
const visited = new Set<unknown>();
|
|
363
|
-
|
|
364
|
-
|
|
369
|
+
mergeIncludeNodes(prefixTree, trackedIncludes, (include) =>
|
|
370
|
+
buildPrefixTreeNode(
|
|
365
371
|
include.fullPrefix,
|
|
366
372
|
include.namePrefix,
|
|
367
373
|
include.patterns as UrlPatterns<any>,
|
|
@@ -375,16 +381,8 @@ export function generateManifestFull<TEnv>(
|
|
|
375
381
|
passthroughRoutes,
|
|
376
382
|
responseTypeRoutes,
|
|
377
383
|
routeSearchSchemas,
|
|
378
|
-
)
|
|
379
|
-
|
|
380
|
-
const existing = prefixTree[include.fullPrefix];
|
|
381
|
-
if (existing) {
|
|
382
|
-
existing.routes.push(...node.routes);
|
|
383
|
-
Object.assign(existing.children, node.children);
|
|
384
|
-
} else {
|
|
385
|
-
prefixTree[include.fullPrefix] = node;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
384
|
+
),
|
|
385
|
+
);
|
|
388
386
|
|
|
389
387
|
return {
|
|
390
388
|
prefixTree,
|
|
@@ -339,6 +339,36 @@ function applyBasenameToRoutes(
|
|
|
339
339
|
return { routes: prefixed, searchSchemas: result.searchSchemas };
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
// Filesystem path of the generated route-types file for a router source file.
|
|
343
|
+
// Native separators — matches the self-gen-tracking Map key the watcher compares.
|
|
344
|
+
export function genFileTsPath(sourceFile: string): string {
|
|
345
|
+
const base = pathBasename(sourceFile).replace(/\.(tsx?|jsx?)$/, "");
|
|
346
|
+
return join(dirname(sourceFile), `${base}.named-routes.gen.ts`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Search schemas for the gen file: prefer the runtime manifest's; when it omits
|
|
350
|
+
// them (some module-runner flows) fall back to static parsing filtered to the
|
|
351
|
+
// public route-name set. Returns the runtime value unchanged otherwise.
|
|
352
|
+
export function resolveSearchSchemas(
|
|
353
|
+
publicRouteNames: string[],
|
|
354
|
+
runtimeSchemas: Record<string, Record<string, string>> | undefined,
|
|
355
|
+
sourceFile: string,
|
|
356
|
+
): Record<string, Record<string, string>> | undefined {
|
|
357
|
+
if (runtimeSchemas && Object.keys(runtimeSchemas).length > 0) {
|
|
358
|
+
return runtimeSchemas;
|
|
359
|
+
}
|
|
360
|
+
const staticParsed = buildCombinedRouteMapForRouterFile(sourceFile);
|
|
361
|
+
if (Object.keys(staticParsed.searchSchemas).length === 0) {
|
|
362
|
+
return runtimeSchemas;
|
|
363
|
+
}
|
|
364
|
+
const filtered: Record<string, Record<string, string>> = {};
|
|
365
|
+
for (const name of publicRouteNames) {
|
|
366
|
+
const schema = staticParsed.searchSchemas[name];
|
|
367
|
+
if (schema) filtered[name] = schema;
|
|
368
|
+
}
|
|
369
|
+
return Object.keys(filtered).length > 0 ? filtered : runtimeSchemas;
|
|
370
|
+
}
|
|
371
|
+
|
|
342
372
|
/**
|
|
343
373
|
* Resolve routes and search schemas from a router source file by following the
|
|
344
374
|
* variable passed to `.routes(...)` or `urls: ...` in createRouter options,
|
|
@@ -528,7 +558,10 @@ export function findRouterFiles(root: string, filter?: ScanFilter): string[] {
|
|
|
528
558
|
export function writeCombinedRouteTypes(
|
|
529
559
|
root: string,
|
|
530
560
|
knownRouterFiles?: string[],
|
|
531
|
-
opts?: {
|
|
561
|
+
opts?: {
|
|
562
|
+
preserveIfLarger?: boolean;
|
|
563
|
+
onWrite?: (outPath: string, content: string) => void;
|
|
564
|
+
},
|
|
532
565
|
): void {
|
|
533
566
|
// Delete old combined named-routes.gen.ts if it exists (stale from older versions)
|
|
534
567
|
try {
|
|
@@ -566,14 +599,7 @@ export function writeCombinedRouteTypes(
|
|
|
566
599
|
if (!extractUrlsFromRouter(routerSource)) continue;
|
|
567
600
|
}
|
|
568
601
|
|
|
569
|
-
const
|
|
570
|
-
/\.(tsx?|jsx?)$/,
|
|
571
|
-
"",
|
|
572
|
-
);
|
|
573
|
-
const outPath = join(
|
|
574
|
-
dirname(routerFilePath),
|
|
575
|
-
`${routerBasename}.named-routes.gen.ts`,
|
|
576
|
-
);
|
|
602
|
+
const outPath = genFileTsPath(routerFilePath);
|
|
577
603
|
const existing = existsSync(outPath)
|
|
578
604
|
? readFileSync(outPath, "utf-8")
|
|
579
605
|
: null;
|
|
@@ -584,6 +610,7 @@ export function writeCombinedRouteTypes(
|
|
|
584
610
|
if (Object.keys(result.routes).length === 0) {
|
|
585
611
|
if (!existing) {
|
|
586
612
|
const emptySource = generateRouteTypesSource({});
|
|
613
|
+
opts?.onWrite?.(outPath, emptySource);
|
|
587
614
|
writeFileSync(outPath, emptySource);
|
|
588
615
|
}
|
|
589
616
|
continue;
|
|
@@ -609,6 +636,7 @@ export function writeCombinedRouteTypes(
|
|
|
609
636
|
continue;
|
|
610
637
|
}
|
|
611
638
|
}
|
|
639
|
+
opts?.onWrite?.(outPath, source);
|
|
612
640
|
writeFileSync(outPath, source);
|
|
613
641
|
console.log(
|
|
614
642
|
`[rango] Generated route types (${Object.keys(result.routes).length} routes) -> ${outPath}`,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import {
|
|
4
4
|
generateRouteTypesSource,
|
|
5
|
-
|
|
5
|
+
genFileTsPath,
|
|
6
|
+
resolveSearchSchemas,
|
|
6
7
|
} from "./generate-route-types.ts";
|
|
7
8
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
8
9
|
|
|
@@ -175,25 +176,13 @@ export async function discoverAndWriteRouteTypes(
|
|
|
175
176
|
);
|
|
176
177
|
}
|
|
177
178
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const filtered: Record<string, Record<string, string>> = {};
|
|
184
|
-
for (const name of Object.keys(routeManifest)) {
|
|
185
|
-
const schema = staticParsed.searchSchemas[name];
|
|
186
|
-
if (schema) filtered[name] = schema;
|
|
187
|
-
}
|
|
188
|
-
if (Object.keys(filtered).length > 0) {
|
|
189
|
-
routeSearchSchemas = filtered;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
179
|
+
routeSearchSchemas = resolveSearchSchemas(
|
|
180
|
+
Object.keys(routeManifest),
|
|
181
|
+
routeSearchSchemas,
|
|
182
|
+
sourceFile,
|
|
183
|
+
);
|
|
193
184
|
|
|
194
|
-
const
|
|
195
|
-
const routerBasename = basename(sourceFile).replace(/\.(tsx?|jsx?)$/, "");
|
|
196
|
-
const outPath = join(routerDir, `${routerBasename}.named-routes.gen.ts`);
|
|
185
|
+
const outPath = genFileTsPath(sourceFile);
|
|
197
186
|
|
|
198
187
|
const source = generateRouteTypesSource(
|
|
199
188
|
routeManifest,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { isLoaderDataResult } from "./types.js";
|
|
3
|
+
|
|
4
|
+
// Shared by segment-system (server) and LoaderResolver (client) so the
|
|
5
|
+
// legacy/ok/error-fallback/throw decode of resolved loader values lives once.
|
|
6
|
+
// Last failing loader wins errorFallback; an error without a fallback throws.
|
|
7
|
+
export function decodeLoaderResults(
|
|
8
|
+
resolvedData: any[],
|
|
9
|
+
loaderIds: string[],
|
|
10
|
+
): { loaderData: Record<string, any>; errorFallback: ReactNode } {
|
|
11
|
+
const loaderData: Record<string, any> = {};
|
|
12
|
+
let errorFallback: ReactNode = null;
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < loaderIds.length; i++) {
|
|
15
|
+
const id = loaderIds[i];
|
|
16
|
+
const result = resolvedData[i];
|
|
17
|
+
|
|
18
|
+
if (!isLoaderDataResult(result)) {
|
|
19
|
+
loaderData[id] = result;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (result.ok) {
|
|
24
|
+
loaderData[id] = result.data;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (result.fallback) {
|
|
29
|
+
errorFallback = result.fallback;
|
|
30
|
+
} else {
|
|
31
|
+
throw new Error(result.error.message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { loaderData, errorFallback };
|
|
36
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -27,6 +27,17 @@ export class RouteNotFoundError extends Error {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// name fallback covers cross-realm errors (Vite dev dupes, RSC serialization)
|
|
31
|
+
// where instanceof fails.
|
|
32
|
+
export function isRouteNotFoundError(
|
|
33
|
+
error: unknown,
|
|
34
|
+
): error is RouteNotFoundError {
|
|
35
|
+
return (
|
|
36
|
+
error instanceof RouteNotFoundError ||
|
|
37
|
+
(error instanceof Error && error.name === "RouteNotFoundError")
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
/**
|
|
31
42
|
* Thrown when data is not found (e.g., product with ID doesn't exist)
|
|
32
43
|
* Use this in handlers/loaders to trigger the nearest notFoundBoundary
|
package/src/response-utils.ts
CHANGED
|
@@ -26,3 +26,12 @@ export function isWebSocketUpgradeResponse(response: Response): boolean {
|
|
|
26
26
|
(response as unknown as { webSocket?: unknown }).webSocket != null
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
// Location truthiness (not presence) so an empty `Location: ""` is not a redirect.
|
|
31
|
+
export function isRedirectResponse(response: Response): boolean {
|
|
32
|
+
return (
|
|
33
|
+
response.status >= 300 &&
|
|
34
|
+
response.status < 400 &&
|
|
35
|
+
Boolean(response.headers.get("Location"))
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -4,7 +4,7 @@ import { Suspense, use, useId } from "react";
|
|
|
4
4
|
import { invariant } from "./errors";
|
|
5
5
|
import { OutletProvider } from "./outlet-provider.js";
|
|
6
6
|
import type { ResolvedSegment } from "./types.js";
|
|
7
|
-
import {
|
|
7
|
+
import { decodeLoaderResults } from "./decode-loader-results.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Stable async wrapper component for route content
|
|
@@ -26,10 +26,6 @@ export function RouteContentWrapper({
|
|
|
26
26
|
fallback?: ReactNode;
|
|
27
27
|
segmentId?: string;
|
|
28
28
|
}): ReactNode {
|
|
29
|
-
if (!content) {
|
|
30
|
-
// Already resolved
|
|
31
|
-
return content as ReactNode;
|
|
32
|
-
}
|
|
33
29
|
return (
|
|
34
30
|
<Suspense
|
|
35
31
|
fallback={fallback ?? null}
|
|
@@ -159,28 +155,10 @@ function LoaderResolver({
|
|
|
159
155
|
? use(loaderDataPromise)
|
|
160
156
|
: loaderDataPromise;
|
|
161
157
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
loaderIds.forEach((id, i) => {
|
|
167
|
-
const result = resolvedData[i];
|
|
168
|
-
|
|
169
|
-
if (isLoaderDataResult(result)) {
|
|
170
|
-
if (result.ok) {
|
|
171
|
-
loaderData[id] = result.data;
|
|
172
|
-
} else {
|
|
173
|
-
if (result.fallback) {
|
|
174
|
-
loaderErrorFallback = result.fallback;
|
|
175
|
-
} else {
|
|
176
|
-
throw new Error(result.error.message);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
} else {
|
|
180
|
-
// Legacy format - direct data
|
|
181
|
-
loaderData[id] = result;
|
|
182
|
-
}
|
|
183
|
-
});
|
|
158
|
+
const { loaderData, errorFallback } = decodeLoaderResults(
|
|
159
|
+
resolvedData,
|
|
160
|
+
loaderIds,
|
|
161
|
+
);
|
|
184
162
|
|
|
185
163
|
return (
|
|
186
164
|
<OutletProvider
|
|
@@ -190,7 +168,7 @@ function LoaderResolver({
|
|
|
190
168
|
parallel={parallel}
|
|
191
169
|
loaderData={Object.keys(loaderData).length > 0 ? loaderData : undefined}
|
|
192
170
|
>
|
|
193
|
-
{
|
|
171
|
+
{errorFallback ?? children}
|
|
194
172
|
</OutletProvider>
|
|
195
173
|
);
|
|
196
174
|
}
|
|
@@ -135,8 +135,8 @@ export interface NegotiationResult {
|
|
|
135
135
|
manifestEntry: EntryData;
|
|
136
136
|
/** Route middleware for the winning variant */
|
|
137
137
|
routeMiddleware: CollectedMiddleware[];
|
|
138
|
-
/**
|
|
139
|
-
negotiated:
|
|
138
|
+
/** True when negotiation selected a variant; false for a plain response route. */
|
|
139
|
+
negotiated: boolean;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/**
|
|
@@ -155,6 +155,19 @@ export async function negotiateRoute(
|
|
|
155
155
|
): Promise<NegotiationResult | null> {
|
|
156
156
|
const { matched, manifestEntry, routeMiddleware, responseType } = snapshot;
|
|
157
157
|
if (!matched.negotiateVariants || matched.negotiateVariants.length === 0) {
|
|
158
|
+
// No variants: a plain response route still yields a result (negotiated:false)
|
|
159
|
+
// so callers don't re-derive it; RSC routes (no responseType/handler) -> null.
|
|
160
|
+
const handler =
|
|
161
|
+
manifestEntry.type === "route" ? manifestEntry.handler : undefined;
|
|
162
|
+
if (responseType && handler) {
|
|
163
|
+
return {
|
|
164
|
+
responseType,
|
|
165
|
+
handler: handler as Function,
|
|
166
|
+
manifestEntry,
|
|
167
|
+
routeMiddleware,
|
|
168
|
+
negotiated: false,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
158
171
|
return null;
|
|
159
172
|
}
|
|
160
173
|
|
|
@@ -66,28 +66,14 @@ export function findInterceptForRoute(
|
|
|
66
66
|
let current: EntryData | null = fromEntry;
|
|
67
67
|
|
|
68
68
|
while (current) {
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// current first, then its sibling layouts — same order as before.
|
|
70
|
+
for (const source of [current, ...current.layout]) {
|
|
71
|
+
for (const intercept of source.intercept) {
|
|
71
72
|
if (
|
|
72
73
|
intercept.routeName === targetRouteKey &&
|
|
73
74
|
evaluateInterceptWhen(intercept, selectorContext, isAction)
|
|
74
75
|
) {
|
|
75
|
-
return { intercept, entry:
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (current.layout && current.layout.length > 0) {
|
|
81
|
-
for (const siblingLayout of current.layout) {
|
|
82
|
-
if (siblingLayout.intercept && siblingLayout.intercept.length > 0) {
|
|
83
|
-
for (const intercept of siblingLayout.intercept) {
|
|
84
|
-
if (
|
|
85
|
-
intercept.routeName === targetRouteKey &&
|
|
86
|
-
evaluateInterceptWhen(intercept, selectorContext, isAction)
|
|
87
|
-
) {
|
|
88
|
-
return { intercept, entry: siblingLayout };
|
|
89
|
-
}
|
|
90
|
-
}
|
|
76
|
+
return { intercept, entry: source };
|
|
91
77
|
}
|
|
92
78
|
}
|
|
93
79
|
}
|
|
@@ -138,34 +138,38 @@ export async function collectSegments(
|
|
|
138
138
|
function deduplicateLoaderSegments(
|
|
139
139
|
segments: ResolvedSegment[],
|
|
140
140
|
logPrefix: string,
|
|
141
|
-
): ResolvedSegment[] {
|
|
142
|
-
//
|
|
143
|
-
// and
|
|
141
|
+
): { segments: ResolvedSegment[]; removedIds: Set<string> } {
|
|
142
|
+
// Single pass: original (non-inherited) loaderIds, all loaderIds grouped by
|
|
143
|
+
// namespace, and namespaces of segments that declare loading().
|
|
144
144
|
const originalLoaders = new Set<string>();
|
|
145
|
-
const
|
|
145
|
+
const loaderIdsByNamespace = new Map<string, string[]>();
|
|
146
|
+
const namespacesWithLoading = new Set<string>();
|
|
146
147
|
for (const s of segments) {
|
|
147
|
-
if (s.type === "loader" && s.loaderId
|
|
148
|
-
originalLoaders.add(s.loaderId);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
if (s.type === "loader" && s.loaderId) {
|
|
149
|
+
if (!s._inherited) originalLoaders.add(s.loaderId);
|
|
150
|
+
const ids = loaderIdsByNamespace.get(s.namespace);
|
|
151
|
+
if (ids) ids.push(s.loaderId);
|
|
152
|
+
else loaderIdsByNamespace.set(s.namespace, [s.loaderId]);
|
|
153
|
+
} else if (
|
|
154
|
+
s.type !== "loader" &&
|
|
155
|
+
s.loading !== undefined &&
|
|
156
|
+
s.loading !== false
|
|
157
|
+
) {
|
|
158
|
+
namespacesWithLoading.add(s.namespace);
|
|
152
159
|
}
|
|
153
160
|
}
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
loadersWithLoading.add(l.loaderId);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
161
|
+
|
|
162
|
+
// An inherited loader is needed when it shares a namespace with a
|
|
163
|
+
// loading-bearing segment (its data sits behind that LoaderBoundary).
|
|
164
|
+
const loadersWithLoading = new Set<string>();
|
|
165
|
+
for (const ns of namespacesWithLoading) {
|
|
166
|
+
for (const id of loaderIdsByNamespace.get(ns) ?? []) {
|
|
167
|
+
loadersWithLoading.add(id);
|
|
164
168
|
}
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
const result: ResolvedSegment[] = [];
|
|
168
|
-
|
|
172
|
+
const removedIds = new Set<string>();
|
|
169
173
|
|
|
170
174
|
for (const s of segments) {
|
|
171
175
|
if (
|
|
@@ -175,17 +179,20 @@ function deduplicateLoaderSegments(
|
|
|
175
179
|
originalLoaders.has(s.loaderId) &&
|
|
176
180
|
!loadersWithLoading.has(s.loaderId)
|
|
177
181
|
) {
|
|
178
|
-
|
|
182
|
+
removedIds.add(s.id);
|
|
179
183
|
continue;
|
|
180
184
|
}
|
|
181
185
|
result.push(s);
|
|
182
186
|
}
|
|
183
187
|
|
|
184
|
-
if (
|
|
185
|
-
debugLog(
|
|
188
|
+
if (removedIds.size > 0) {
|
|
189
|
+
debugLog(
|
|
190
|
+
logPrefix,
|
|
191
|
+
`deduped ${removedIds.size} inherited loader segment(s)`,
|
|
192
|
+
);
|
|
186
193
|
}
|
|
187
194
|
|
|
188
|
-
return result;
|
|
195
|
+
return { segments: result, removedIds };
|
|
189
196
|
}
|
|
190
197
|
|
|
191
198
|
/**
|
|
@@ -244,7 +251,7 @@ export function buildMatchResult<TEnv>(
|
|
|
244
251
|
);
|
|
245
252
|
}
|
|
246
253
|
|
|
247
|
-
const dedupedSegments = deduplicateLoaderSegments(
|
|
254
|
+
const { segments: dedupedSegments, removedIds } = deduplicateLoaderSegments(
|
|
248
255
|
segmentsToRender,
|
|
249
256
|
logPrefix,
|
|
250
257
|
);
|
|
@@ -262,11 +269,6 @@ export function buildMatchResult<TEnv>(
|
|
|
262
269
|
|
|
263
270
|
// Remove deduped loader IDs from matched so the client doesn't treat
|
|
264
271
|
// them as missing segments and trigger a fallback refetch.
|
|
265
|
-
const removedIds = new Set(
|
|
266
|
-
segmentsToRender
|
|
267
|
-
.filter((s) => !dedupedSegments.includes(s))
|
|
268
|
-
.map((s) => s.id),
|
|
269
|
-
);
|
|
270
272
|
const matchedIds =
|
|
271
273
|
removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
|
|
272
274
|
|