Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/docs/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ const routes = [
component: defer(<ExamplesPage />, { name: "ExamplesPage" }),
}),
route({
path: "/*",
component: defer(<NotFoundPage />, { name: "NotFoundPage" }),
}),
],
Expand Down
6 changes: 3 additions & 3 deletions packages/docs/src/pages/ExamplesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,14 +394,14 @@ const routes = [
</ul>
<h3>Catch-All Routes</h3>
<p>
A pathless route at the end of a route list can serve as a catch-all
for unmatched paths:
Use <code>path: "/*"</code> at the end of a route list to catch any
unmatched paths:
</p>
<CodeBlock language="tsx">{`const routes = [
route({ path: "/", component: HomePage }),
route({ path: "/about", component: AboutPage }),
// Catch-all: matches any unmatched path
route({ component: NotFoundPage }),
route({ path: "/*", component: NotFoundPage }),
];`}</CodeBlock>
</section>

Expand Down
10 changes: 6 additions & 4 deletions packages/router/src/__tests__/fallback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,21 +384,23 @@ describe("ssr", () => {
expect(screen.getByText("loaded")).toBeInTheDocument();
});

it("skips route with loader and matches sibling without loader", () => {
it("skips route with loader and does not fall back to catch-all sibling", () => {
const routes: RouteDefinition[] = [
{
path: "/about",
component: () => <div>About with loader</div>,
// Using cast to add loader since RouteDefinition union doesn't expose it directly
},
{ path: "/*", component: () => <div>Catch All</div> },
];

// Manually add loader to first route to test skipping
(routes[0] as Record<string, unknown>).loader = () => "data";

render(<Router routes={routes} ssr={{ path: "/about" }} />);
expect(screen.getByText("Catch All")).toBeInTheDocument();
const { container } = render(
<Router routes={routes} ssr={{ path: "/about" }} />,
);
// Skipped route blocks the catch-all, so nothing renders
expect(container.textContent).toBe("");
});

it("renders parent shell when all children have loaders", () => {
Expand Down
68 changes: 65 additions & 3 deletions packages/router/src/__tests__/matchRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ describe("matchRoutes", () => {
expect(matchRoutes(routes, "/about", { skipLoaders: true })).toBeNull();
});

it("skips route with loader and falls back to sibling", () => {
it("skipped route with matching path prevents catch-all from matching", () => {
const routes = [
{
path: "/about",
Expand All @@ -629,9 +629,10 @@ describe("matchRoutes", () => {
},
] as unknown as InternalRouteDefinition[];

// The /about route would match but is skipped due to loader;
// this should prevent the catch-all from matching
const result = matchRoutes(routes, "/about", { skipLoaders: true });
expect(result).toHaveLength(1);
expect(result![0].route.path).toBe("/*");
expect(result).toBeNull();
});

it("skips child route with loader, parent renders as shell", () => {
Expand Down Expand Up @@ -716,5 +717,66 @@ describe("matchRoutes", () => {
expect(result).toHaveLength(3);
expect(result![2].params).toEqual({ id: "42" });
});

it("non-matching loader route does not block siblings", () => {
const routes = [
{
path: "/about",
component: () => null,
loader: () => "data",
},
{
path: "/*",
component: () => null,
},
] as unknown as InternalRouteDefinition[];

// /other does NOT match /about, so the loader route is not skipped (returns null).
// The catch-all should still match.
const result = matchRoutes(routes, "/other", { skipLoaders: true });
expect(result).toHaveLength(1);
expect(result![0].route.path).toBe("/*");
});

it("skipped child prevents catch-all sibling, parent renders as shell", () => {
const routes = [
{
component: () => null, // pathless layout
children: [
{
path: "/about",
component: () => null,
loader: () => "data",
},
{
component: () => null, // catch-all sibling
},
],
},
] as unknown as InternalRouteDefinition[];

// /about child would match but is skipped; catch-all sibling should NOT match.
// Parent pathless layout renders as shell.
const result = matchRoutes(routes, "/about", { skipLoaders: true });
expect(result).toHaveLength(1);
expect(result![0].route.path).toBeUndefined();
expect(result![0].route.loader).toBeUndefined();
});

it("skipped pathless loader route prevents catch-all sibling", () => {
const routes = [
{
component: () => null,
loader: () => "data",
},
{
component: () => null, // catch-all sibling
},
] as unknown as InternalRouteDefinition[];

// Pathless route with loader would always match; should be SKIPPED, blocking siblings
const result = matchRoutes(routes, "/anything", { skipLoaders: true });
expect(result).toBeNull();
});
});
});
35 changes: 34 additions & 1 deletion packages/router/src/core/matchRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { InternalRouteDefinition, MatchedRoute } from "../types.js";

const SKIPPED = Symbol("skipped");
type MatchRouteInternalResult = MatchedRoute[] | typeof SKIPPED | null;

export type MatchRoutesOptions = {
/**
* When true, routes with loaders are skipped during matching.
Expand All @@ -19,6 +22,7 @@ export function matchRoutes(
): MatchedRoute[] | null {
for (const route of routes) {
const matched = matchRoute(route, pathname, options);
if (matched === SKIPPED) return null;
if (matched) {
return matched;
}
Expand All @@ -33,12 +37,22 @@ function matchRoute(
route: InternalRouteDefinition,
pathname: string | null,
options?: MatchRoutesOptions,
): MatchedRoute[] | null {
): MatchRouteInternalResult {
const hasChildren = Boolean(route.children?.length);
const skipLoaders = options?.skipLoaders ?? false;

// Routes with loaders can't render during SSR (no request context)
if ((pathname === null || skipLoaders) && route.loader) {
if (skipLoaders && pathname !== null) {
// This route can't render (loader skipped), but check if it would match.
// If it would, return SKIPPED to prevent fallback routes from matching.
if (route.path === undefined) {
return SKIPPED; // pathless always matches
}
const isExact = route.exact ?? !hasChildren;
const { matched } = matchPath(route.path, pathname, isExact);
if (matched) return SKIPPED;
}
return null;
}

Expand All @@ -51,12 +65,21 @@ function matchRoute(
};

if (hasChildren) {
let anySkipped = false;
for (const child of route.children!) {
const childMatch = matchRoute(child, pathname, options);
if (childMatch === SKIPPED) {
anySkipped = true;
break;
}
if (childMatch) {
return [result, ...childMatch];
}
}
if (anySkipped) {
if (route.component) return [result]; // render as shell
return SKIPPED; // propagate
}
// No children matched - only valid if requireChildren is false and route has a component
if (route.component && route.requireChildren === false) {
return [result];
Expand Down Expand Up @@ -105,8 +128,13 @@ function matchRoute(
remainingPathname = "/";
}

let anyChildSkipped = false;
for (const child of route.children!) {
const childMatch = matchRoute(child, remainingPathname, options);
if (childMatch === SKIPPED) {
anyChildSkipped = true;
break;
}
if (childMatch) {
// Merge params from parent into children
return [
Expand All @@ -119,6 +147,11 @@ function matchRoute(
}
}

if (anyChildSkipped) {
if (route.component) return [result]; // render as shell
return SKIPPED; // propagate
}

// If no children matched - only valid if requireChildren is false and route has a component
if (route.component && route.requireChildren === false) {
return [result];
Expand Down