Skip to content

Commit dbfc6d5

Browse files
committed
Fix the most specific matching route when using pathless routes.
1 parent e89d57c commit dbfc6d5

3 files changed

Lines changed: 256 additions & 81 deletions

File tree

packages/ra-router-tanstack/src/tanStackRouterProvider.spec.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
QueryParameters,
2626
PathlessLayoutRoutes,
2727
NestedResourcesPrecedence,
28+
PathlessLayoutRoutesPriority,
2829
} from './tanStackRouterProvider.stories';
2930
import { tanStackRouterProvider } from './tanStackRouterProvider';
3031

@@ -1479,6 +1480,30 @@ describe('tanStackRouterProvider', () => {
14791480
expect(screen.getByTestId('comments-page')).toBeInTheDocument();
14801481
});
14811482
});
1483+
1484+
it('should match the most specific layout route within pathless layout routes', async () => {
1485+
window.location.hash = '#/posts';
1486+
1487+
render(<PathlessLayoutRoutesPriority />);
1488+
1489+
await waitFor(() => {
1490+
expect(screen.getByTestId('posts-page')).toBeInTheDocument();
1491+
});
1492+
1493+
fireEvent.click(screen.getByText('User'));
1494+
1495+
await waitFor(() => {
1496+
expect(screen.getByTestId('users-page')).toBeInTheDocument();
1497+
});
1498+
1499+
fireEvent.click(screen.getByText('Block a user'));
1500+
1501+
await waitFor(() => {
1502+
expect(
1503+
screen.getByTestId('block-user-page')
1504+
).toBeInTheDocument();
1505+
});
1506+
});
14821507
});
14831508

14841509
describe('Resource Children (Route as children of Resource)', () => {

packages/ra-router-tanstack/src/tanStackRouterProvider.stories.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,3 +1481,107 @@ export const PathlessLayoutRoutes = () => {
14811481
</RouterProviderContext.Provider>
14821482
);
14831483
};
1484+
1485+
export const PathlessLayoutRoutesPriority = () => {
1486+
const { RouterWrapper } = tanStackRouterProvider;
1487+
1488+
return (
1489+
<RouterProviderContext.Provider value={tanStackRouterProvider}>
1490+
<RouterWrapper>
1491+
<div data-testid="layout-wrapper">
1492+
<nav>
1493+
<LinkBase to="/posts" style={{ marginRight: 10 }}>
1494+
Posts
1495+
</LinkBase>
1496+
<LinkBase to="/comments" style={{ marginRight: 10 }}>
1497+
Comments
1498+
</LinkBase>
1499+
<LinkBase
1500+
to="/users/john_doe"
1501+
style={{ marginRight: 10 }}
1502+
>
1503+
User
1504+
</LinkBase>
1505+
<LinkBase
1506+
to="/users/jane_doe/block"
1507+
style={{ marginRight: 10 }}
1508+
>
1509+
Block a user
1510+
</LinkBase>
1511+
</nav>
1512+
<div
1513+
style={{
1514+
border: '2px solid blue',
1515+
padding: 20,
1516+
marginTop: 10,
1517+
}}
1518+
>
1519+
<Routes>
1520+
<Route
1521+
path="/posts"
1522+
element={
1523+
<div data-testid="posts-page">
1524+
Posts Page
1525+
</div>
1526+
}
1527+
/>
1528+
<Route
1529+
path="/comments"
1530+
element={
1531+
<div data-testid="comments-page">
1532+
Comments Page
1533+
</div>
1534+
}
1535+
/>
1536+
<Route
1537+
element={
1538+
<div
1539+
style={{
1540+
border: '2px solid green',
1541+
padding: 20,
1542+
marginTop: 10,
1543+
}}
1544+
>
1545+
<RouterOutlet />
1546+
</div>
1547+
}
1548+
>
1549+
<Route
1550+
path="/users/*"
1551+
element={
1552+
<div data-testid="users-page">
1553+
Users View
1554+
</div>
1555+
}
1556+
/>
1557+
</Route>
1558+
<Route
1559+
element={
1560+
<div
1561+
style={{
1562+
border: '2px solid red',
1563+
padding: 20,
1564+
marginTop: 10,
1565+
}}
1566+
>
1567+
<RouterOutlet />
1568+
</div>
1569+
}
1570+
>
1571+
<Route
1572+
path="/users/:username/block"
1573+
element={
1574+
<div data-testid="block-user-page">
1575+
Block a user
1576+
</div>
1577+
}
1578+
></Route>
1579+
</Route>
1580+
</Routes>
1581+
</div>
1582+
</div>
1583+
<LocationDisplay />
1584+
</RouterWrapper>
1585+
</RouterProviderContext.Provider>
1586+
);
1587+
};

packages/ra-router-tanstack/src/tanStackRouterProvider.tsx

Lines changed: 127 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -717,103 +717,149 @@ const Routes = ({ children, location: locationProp }: RouterRoutesProps) => {
717717
[]
718718
);
719719

720-
// Find matching route and calculate the new matched path
721-
const matchResult = useMemo(() => {
722-
// Helper to calculate matched path
723-
const calcMatchedPath = (matchedPortion: string): string => {
724-
if (!parentMatchedPath || parentMatchedPath === '/') {
725-
return matchedPortion;
726-
} else if (matchedPortion === '/') {
727-
return parentMatchedPath;
728-
} else {
729-
return `${parentMatchedPath}${matchedPortion}`;
730-
}
731-
};
732-
733-
let bestMatch: {
734-
route: RouteConfig;
735-
matchedPath: string;
736-
params: Record<string, string | undefined>;
737-
} | null = null;
738-
739-
for (const route of routes) {
740-
if (route.index && (pathname === '/' || pathname === '')) {
741-
// Index route: matched path stays the same
742-
const newMatchedPath = parentMatchedPath || '/';
743-
return { route, matchedPath: newMatchedPath, params: {} };
744-
}
720+
const findMostSpecificMatchingRoute = useCallback(
721+
(
722+
routes: RouteConfig[],
723+
parentMatchedPath: string,
724+
pathname: string,
725+
fullPathname: string
726+
) => {
727+
// Helper to calculate matched path
728+
const calcMatchedPath = (matchedPortion: string): string => {
729+
if (!parentMatchedPath || parentMatchedPath === '/') {
730+
return matchedPortion;
731+
} else if (matchedPortion === '/') {
732+
return parentMatchedPath;
733+
} else {
734+
return `${parentMatchedPath}${matchedPortion}`;
735+
}
736+
};
745737

746-
// Pathless layout route: path is undefined (not empty string) and has children
747-
// Match if any child route would match the pathname
748-
if (route.path === undefined && route.children) {
749-
if (childRouteMatches(route.children, pathname)) {
738+
// The best match may be a route from a pathless layout route child.
739+
// routeForBestMatch is the route to reach the best match at this level of recursion.
740+
let bestMatch: {
741+
route: RouteConfig;
742+
matchedPath: string;
743+
params: Record<string, string | undefined>;
744+
} | null = null;
745+
let routeForBestMatch: {
746+
route: RouteConfig;
747+
matchedPath: string;
748+
params: Record<string, string | undefined>;
749+
} | null = null;
750+
751+
for (const route of routes) {
752+
if (route.index && (pathname === '/' || pathname === '')) {
753+
// Index route: matched path stays the same
750754
const newMatchedPath = parentMatchedPath || '/';
751755
return { route, matchedPath: newMatchedPath, params: {} };
752756
}
753-
}
754757

755-
if (route.path !== undefined) {
756-
const match = matchPath(route.path, pathname);
757-
if (match) {
758-
const matchedPortion = match.pathnameBase || '/';
759-
const newMatchedPath = calcMatchedPath(matchedPortion);
760-
761-
const currentMatch = {
762-
route,
763-
matchedPath: newMatchedPath,
764-
params: match.params,
765-
};
766-
767-
// If no best match yet, use this one
768-
if (!bestMatch) {
769-
bestMatch = currentMatch;
758+
// Pathless layout route: path is undefined (not empty string) and has children
759+
// Match if any child route would match the pathname
760+
if (route.path === undefined && route.children) {
761+
const childMatch = findMostSpecificMatchingRoute(
762+
route.children,
763+
parentMatchedPath,
764+
pathname,
765+
fullPathname
766+
);
767+
768+
if (
769+
childMatch &&
770+
// If no best match yet, or the child route is more specific than the current best, use this one
771+
(!bestMatch ||
772+
(bestMatch.route.path &&
773+
childMatch.route.path &&
774+
isMoreSpecific(
775+
bestMatch.route.path,
776+
childMatch.route.path
777+
)))
778+
) {
779+
const newMatchedPath = parentMatchedPath || '/';
780+
bestMatch = childMatch;
781+
routeForBestMatch = {
782+
route,
783+
matchedPath: newMatchedPath,
784+
params: {},
785+
};
786+
770787
// If this match doesn't use a catch-all, return immediately
771-
if (!hasCatchAll(route.path)) {
772-
return bestMatch;
788+
if (!hasCatchAll(bestMatch.route.path!)) {
789+
return routeForBestMatch;
773790
}
774791
// Otherwise, keep looking for more specific matches
775-
continue;
776792
}
793+
}
777794

778-
// Check if this route is more specific than the current best
779-
if (
780-
bestMatch.route.path &&
781-
isMoreSpecific(bestMatch.route.path, route.path)
782-
) {
783-
bestMatch = currentMatch;
784-
// If this match doesn't use a catch-all, return immediately
785-
if (!hasCatchAll(route.path)) {
786-
return bestMatch;
795+
if (route.path !== undefined) {
796+
const match = matchPath(route.path, pathname);
797+
if (match) {
798+
const matchedPortion = match.pathnameBase || '/';
799+
const newMatchedPath = calcMatchedPath(matchedPortion);
800+
801+
const currentMatch = {
802+
route,
803+
matchedPath: newMatchedPath,
804+
params: match.params,
805+
};
806+
807+
// If no best match yet, or this route is more specific than the current best, use this one
808+
if (
809+
!bestMatch ||
810+
(bestMatch.route.path &&
811+
isMoreSpecific(
812+
bestMatch.route.path,
813+
route.path
814+
))
815+
) {
816+
bestMatch = routeForBestMatch = currentMatch;
817+
// If this match doesn't use a catch-all, return immediately
818+
if (!hasCatchAll(route.path)) {
819+
return routeForBestMatch;
820+
}
821+
// Otherwise, keep looking for more specific matches
787822
}
788823
}
789824
}
790825
}
791-
}
792826

793-
// If we found a match (possibly a catch-all), return it
794-
if (bestMatch) {
795-
return bestMatch;
796-
}
827+
// If we found a match (possibly a catch-all), return it
828+
if (routeForBestMatch) {
829+
return routeForBestMatch;
830+
}
797831

798-
// Check for catch-all route (path="*")
799-
const catchAll = routes.find(r => r.path === '*');
800-
if (catchAll) {
801-
const match = matchPath('*', pathname);
802-
return {
803-
route: catchAll,
804-
matchedPath: fullPathname,
805-
params: match?.params ?? {},
806-
};
807-
}
808-
return null;
809-
}, [
810-
routes,
811-
parentMatchedPath,
812-
pathname,
813-
childRouteMatches,
814-
isMoreSpecific,
815-
fullPathname,
816-
]);
832+
// Check for catch-all route (path="*")
833+
const catchAll = routes.find(r => r.path === '*');
834+
if (catchAll) {
835+
const match = matchPath('*', pathname);
836+
return {
837+
route: catchAll,
838+
matchedPath: fullPathname,
839+
params: match?.params ?? {},
840+
};
841+
}
842+
return null;
843+
},
844+
[isMoreSpecific]
845+
);
846+
// Find a matching route and calculate the new matched path
847+
const matchResult = useMemo(
848+
() =>
849+
findMostSpecificMatchingRoute(
850+
routes,
851+
parentMatchedPath,
852+
pathname,
853+
fullPathname
854+
),
855+
[
856+
findMostSpecificMatchingRoute,
857+
routes,
858+
parentMatchedPath,
859+
pathname,
860+
fullPathname,
861+
]
862+
);
817863

818864
// Now that all hooks have been called, we can safely return early
819865
// if we're outside the basename scope

0 commit comments

Comments
 (0)