Skip to content

Commit d44fc11

Browse files
committed
refactor(react-router): extract helper functions from render callback
1 parent 6f184e5 commit d44fc11

File tree

1 file changed

+135
-88
lines changed

1 file changed

+135
-88
lines changed

packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx

Lines changed: 135 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,114 @@ const NAVIGATE_REDIRECT_DELAY_MS = 100;
2929
*/
3030
const VIEW_CLEANUP_DELAY_MS = 200;
3131

32+
type RouteParams = Record<string, string | undefined>;
33+
34+
type RouteContextMatch = {
35+
params: RouteParams;
36+
pathname: string;
37+
pathnameBase: string;
38+
route: {
39+
id: string;
40+
path?: string;
41+
element: React.ReactNode;
42+
index: boolean;
43+
caseSensitive?: boolean;
44+
hasErrorBoundary: boolean;
45+
};
46+
};
47+
48+
/**
49+
* Computes the absolute pathnameBase for a route element based on its type.
50+
* Handles relative paths, index routes, and splat routes differently.
51+
*/
52+
const computeAbsolutePathnameBase = (
53+
routeElement: React.ReactElement,
54+
routeMatch: PathMatch<string> | undefined,
55+
parentPathnameBase: string,
56+
routeInfoPathname: string
57+
): string => {
58+
const routePath = routeElement.props.path;
59+
const isRelativePath = routePath && !routePath.startsWith('/');
60+
const isIndexRoute = !!routeElement.props.index;
61+
const isSplatOnlyRoute = routePath === '*' || routePath === '/*';
62+
63+
if (isSplatOnlyRoute) {
64+
// Splat routes should NOT contribute their matched portion to pathnameBase
65+
// This aligns with React Router v7's v7_relativeSplatPath behavior
66+
return parentPathnameBase;
67+
}
68+
69+
if (isRelativePath && routeMatch?.pathnameBase) {
70+
const relativeBase = routeMatch.pathnameBase.startsWith('/')
71+
? routeMatch.pathnameBase.slice(1)
72+
: routeMatch.pathnameBase;
73+
return parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`;
74+
}
75+
76+
if (isIndexRoute) {
77+
return parentPathnameBase;
78+
}
79+
80+
return routeMatch?.pathnameBase || routeInfoPathname;
81+
};
82+
83+
/**
84+
* Gets fallback params from view items in other outlets when parent context is empty.
85+
* This handles cases where React context propagation doesn't work as expected.
86+
*/
87+
const getFallbackParamsFromViewItems = (
88+
allViewItems: ViewItem[],
89+
currentOutletId: string,
90+
currentPathname: string
91+
): RouteParams => {
92+
const params: RouteParams = {};
93+
94+
for (const otherViewItem of allViewItems) {
95+
if (otherViewItem.outletId === currentOutletId) continue;
96+
97+
const otherMatch = otherViewItem.routeData?.match;
98+
if (otherMatch?.params && Object.keys(otherMatch.params).length > 0) {
99+
const matchedPathname = otherMatch.pathnameBase || otherMatch.pathname;
100+
if (matchedPathname && currentPathname.startsWith(matchedPathname)) {
101+
Object.assign(params, otherMatch.params);
102+
}
103+
}
104+
}
105+
106+
return params;
107+
};
108+
109+
/**
110+
* Builds the matches array for RouteContext.
111+
*/
112+
const buildContextMatches = (
113+
parentMatches: RouteContextMatch[],
114+
combinedParams: RouteParams,
115+
routeMatch: PathMatch<string> | undefined,
116+
routeInfoPathname: string,
117+
absolutePathnameBase: string,
118+
viewItem: ViewItem,
119+
routeElement: React.ReactElement,
120+
componentElement: React.ReactNode
121+
): RouteContextMatch[] => {
122+
return [
123+
...parentMatches,
124+
{
125+
params: combinedParams,
126+
pathname: routeMatch?.pathname || routeInfoPathname,
127+
pathnameBase: absolutePathnameBase,
128+
route: {
129+
id: viewItem.id,
130+
path: routeElement.props.path,
131+
element: componentElement,
132+
index: !!routeElement.props.index,
133+
caseSensitive: routeElement.props.caseSensitive,
134+
hasErrorBoundary: false,
135+
},
136+
},
137+
];
138+
};
139+
32140
const createDefaultMatch = (
33141
fullPathname: string,
34142
routeProps: { path?: string; caseSensitive?: boolean; end?: boolean; index?: boolean }
@@ -345,103 +453,42 @@ export class ReactRouterViewStack extends ViewStacks {
345453
return (
346454
<RouteContext.Consumer key={`view-context-${viewItem.id}`}>
347455
{(parentContext) => {
348-
const parentMatches = parentContext?.matches ?? [];
349-
let accumulatedParentParams = parentMatches.reduce<Record<string, string | string[] | undefined>>(
350-
(acc, match) => {
351-
return { ...acc, ...match.params };
352-
},
353-
{}
354-
);
456+
const parentMatches = (parentContext?.matches ?? []) as RouteContextMatch[];
355457

356-
// If parentMatches is empty, try to extract params from view items in other outlets.
357-
// This handles cases where React context propagation doesn't work as expected
358-
// for nested router outlets.
458+
// Accumulate params from parent matches, with fallback to other outlets
459+
let accumulatedParentParams = parentMatches.reduce<RouteParams>((acc, m) => ({ ...acc, ...m.params }), {});
359460
if (parentMatches.length === 0 && Object.keys(accumulatedParentParams).length === 0) {
360-
const allViewItems = this.getAllViewItems();
361-
for (const otherViewItem of allViewItems) {
362-
// Skip view items from the same outlet
363-
if (otherViewItem.outletId === viewItem.outletId) continue;
364-
365-
// Check if this view item's route could match the current pathname
366-
const otherMatch = otherViewItem.routeData?.match;
367-
if (otherMatch && otherMatch.params && Object.keys(otherMatch.params).length > 0) {
368-
// Check if the current pathname starts with this view item's matched pathname
369-
const matchedPathname = otherMatch.pathnameBase || otherMatch.pathname;
370-
if (matchedPathname && routeInfo.pathname.startsWith(matchedPathname)) {
371-
accumulatedParentParams = { ...accumulatedParentParams, ...otherMatch.params };
372-
}
373-
}
374-
}
461+
accumulatedParentParams = getFallbackParamsFromViewItems(
462+
this.getAllViewItems(),
463+
viewItem.outletId,
464+
routeInfo.pathname
465+
);
375466
}
376467

377-
const combinedParams = {
378-
...accumulatedParentParams,
379-
...(routeMatch?.params ?? {}),
380-
};
381-
382-
// For relative route paths, we need to compute an absolute pathnameBase
383-
// by combining the parent's pathnameBase with the matched portion
384-
const routePath = routeElement.props.path;
385-
const isRelativePath = routePath && !routePath.startsWith('/');
386-
const isIndexRoute = !!routeElement.props.index;
387-
const isSplatOnlyRoute = routePath === '*' || routePath === '/*';
388-
389-
// Get parent's pathnameBase for relative path resolution
468+
const combinedParams = { ...accumulatedParentParams, ...(routeMatch?.params ?? {}) };
390469
const parentPathnameBase =
391470
parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/';
471+
const absolutePathnameBase = computeAbsolutePathnameBase(
472+
routeElement,
473+
routeMatch,
474+
parentPathnameBase,
475+
routeInfo.pathname
476+
);
392477

393-
// Start with the match's pathnameBase, falling back to routeInfo.pathname
394-
// BUT: splat-only routes should use parent's base (v7_relativeSplatPath behavior)
395-
let absolutePathnameBase: string;
396-
397-
if (isSplatOnlyRoute) {
398-
// Splat routes should NOT contribute their matched portion to pathnameBase
399-
// This aligns with React Router v7's v7_relativeSplatPath behavior
400-
// Without this, relative links inside splat routes get double path segments
401-
absolutePathnameBase = parentPathnameBase;
402-
} else if (isRelativePath && routeMatch?.pathnameBase) {
403-
// For relative paths with a pathnameBase, combine with parent
404-
const relativeBase = routeMatch.pathnameBase.startsWith('/')
405-
? routeMatch.pathnameBase.slice(1)
406-
: routeMatch.pathnameBase;
407-
408-
absolutePathnameBase =
409-
parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`;
410-
} else if (isIndexRoute) {
411-
// Index routes should use the parent's base as their base
412-
absolutePathnameBase = parentPathnameBase;
413-
} else {
414-
// Default: use the match's pathnameBase or the current pathname
415-
absolutePathnameBase = routeMatch?.pathnameBase || routeInfo.pathname;
416-
}
417-
418-
const contextMatches = [
419-
...parentMatches,
420-
{
421-
params: combinedParams,
422-
pathname: routeMatch?.pathname || routeInfo.pathname,
423-
pathnameBase: absolutePathnameBase,
424-
route: {
425-
id: viewItem.id,
426-
path: routeElement.props.path,
427-
element: componentElement,
428-
index: !!routeElement.props.index,
429-
caseSensitive: routeElement.props.caseSensitive,
430-
hasErrorBoundary: false,
431-
},
432-
},
433-
];
478+
const contextMatches = buildContextMatches(
479+
parentMatches,
480+
combinedParams,
481+
routeMatch,
482+
routeInfo.pathname,
483+
absolutePathnameBase,
484+
viewItem,
485+
routeElement,
486+
componentElement
487+
);
434488

435489
const routeContextValue = parentContext
436-
? {
437-
...parentContext,
438-
matches: contextMatches,
439-
}
440-
: {
441-
outlet: null,
442-
matches: contextMatches,
443-
isDataRoute: false,
444-
};
490+
? { ...parentContext, matches: contextMatches }
491+
: { outlet: null, matches: contextMatches, isDataRoute: false };
445492

446493
return (
447494
<ViewLifeCycleManager

0 commit comments

Comments
 (0)