@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.
Files changed (49) hide show
  1. package/dist/bin/rango.js +41 -37
  2. package/dist/vite/index.js +144 -191
  3. package/package.json +3 -1
  4. package/src/browser/action-coordinator.ts +53 -36
  5. package/src/browser/event-controller.ts +42 -66
  6. package/src/browser/navigation-bridge.ts +4 -0
  7. package/src/browser/navigation-client.ts +12 -15
  8. package/src/browser/navigation-store.ts +7 -8
  9. package/src/browser/navigation-transaction.ts +7 -21
  10. package/src/browser/partial-update.ts +8 -16
  11. package/src/browser/react/NavigationProvider.tsx +29 -40
  12. package/src/browser/react/use-params.ts +3 -4
  13. package/src/browser/response-adapter.ts +25 -0
  14. package/src/browser/rsc-router.tsx +16 -2
  15. package/src/browser/server-action-bridge.ts +23 -30
  16. package/src/browser/types.ts +2 -0
  17. package/src/build/generate-manifest.ts +29 -31
  18. package/src/build/generate-route-types.ts +2 -0
  19. package/src/build/route-types/router-processing.ts +37 -9
  20. package/src/build/runtime-discovery.ts +9 -20
  21. package/src/decode-loader-results.ts +36 -0
  22. package/src/errors.ts +11 -0
  23. package/src/response-utils.ts +9 -0
  24. package/src/route-content-wrapper.tsx +6 -28
  25. package/src/router/content-negotiation.ts +15 -2
  26. package/src/router/intercept-resolution.ts +4 -18
  27. package/src/router/match-result.ts +32 -30
  28. package/src/router/middleware.ts +46 -78
  29. package/src/router/preview-match.ts +3 -1
  30. package/src/router/request-classification.ts +4 -28
  31. package/src/rsc/handler.ts +20 -65
  32. package/src/rsc/helpers.ts +3 -2
  33. package/src/rsc/origin-guard.ts +28 -10
  34. package/src/rsc/response-route-handler.ts +32 -52
  35. package/src/rsc/rsc-rendering.ts +27 -53
  36. package/src/rsc/runtime-warnings.ts +9 -10
  37. package/src/rsc/server-action.ts +13 -37
  38. package/src/rsc/ssr-setup.ts +16 -0
  39. package/src/segment-system.tsx +5 -39
  40. package/src/vite/discovery/discover-routers.ts +10 -22
  41. package/src/vite/discovery/route-types-writer.ts +38 -82
  42. package/src/vite/plugins/cjs-to-esm.ts +3 -7
  43. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  44. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  45. package/src/vite/plugins/expose-internal-ids.ts +34 -62
  46. package/src/vite/plugins/version-injector.ts +2 -12
  47. package/src/vite/router-discovery.ts +71 -26
  48. package/src/vite/utils/shared-utils.ts +13 -1
  49. 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 reload = extractRscHeaderUrl(response, "X-RSC-Reload");
226
- if (reload === "blocked") {
227
- resolveStreamComplete();
228
- return emptyResponse();
229
- }
230
- if (reload) {
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
- if (onNavigate) {
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
- if (onNavigate) {
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
 
@@ -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
- for (const include of trackedIncludes) {
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
- // Build prefix tree from tracked includes (shared visited set for cycle detection).
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
- for (const include of trackedIncludes) {
364
- const node = buildPrefixTreeNode(
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,
@@ -35,5 +35,7 @@ export {
35
35
  formatNestedRouterConflictError,
36
36
  findRouterFiles,
37
37
  writeCombinedRouteTypes,
38
+ genFileTsPath,
39
+ resolveSearchSchemas,
38
40
  } from "./route-types/router-processing.js";
39
41
  export { findUrlsVariableNames } from "./route-types/per-module-writer.js";
@@ -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?: { preserveIfLarger?: boolean },
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 routerBasename = pathBasename(routerFilePath).replace(
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 { dirname, join, basename, resolve } from "node:path";
1
+ import { resolve } from "node:path";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import {
4
4
  generateRouteTypesSource,
5
- buildCombinedRouteMapForRouterFile,
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
- // Search schema fallback: runtime manifest may omit search schema metadata
179
- // in some module-runner flows. Fall back to static source parsing.
180
- if (!routeSearchSchemas || Object.keys(routeSearchSchemas).length === 0) {
181
- const staticParsed = buildCombinedRouteMapForRouterFile(sourceFile);
182
- if (Object.keys(staticParsed.searchSchemas).length > 0) {
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 routerDir = dirname(sourceFile);
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
@@ -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 { isLoaderDataResult } from "./types.js";
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
- // Build loaderData record from resolved values
163
- const loaderData: Record<string, any> = {};
164
- let loaderErrorFallback: ReactNode = null;
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
- {loaderErrorFallback ?? children}
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
- /** Always true negotiation occurred */
139
- negotiated: true;
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
- if (current.intercept && current.intercept.length > 0) {
70
- for (const intercept of current.intercept) {
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: current };
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
- // First pass: collect loaderIds of original (non-inherited) segments
143
- // and whether their parent entry uses loading()
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 loadersWithLoading = new Set<string>();
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 && !s._inherited) {
148
- originalLoaders.add(s.loaderId);
149
- // If the segment has a sibling with loading, the parent uses loading()
150
- // We detect this by checking if any non-loader segment in the same
151
- // namespace has loading defined
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
- // Check if any layout/route segment has loading — if a loader's namespace
155
- // matches a segment with loading, the inherited copy is needed
156
- for (const s of segments) {
157
- if (s.type !== "loader" && s.loading !== undefined && s.loading !== false) {
158
- // Find loaders in this namespace
159
- for (const l of segments) {
160
- if (l.type === "loader" && l.namespace === s.namespace && l.loaderId) {
161
- loadersWithLoading.add(l.loaderId);
162
- }
163
- }
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
- let dedupCount = 0;
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
- dedupCount++;
182
+ removedIds.add(s.id);
179
183
  continue;
180
184
  }
181
185
  result.push(s);
182
186
  }
183
187
 
184
- if (dedupCount > 0) {
185
- debugLog(logPrefix, `deduped ${dedupCount} inherited loader segment(s)`);
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