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
15 changes: 15 additions & 0 deletions packages/docs/src/pages/ApiUtilitiesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ const myRoute = route({
for leaf routes, <code>false</code> for parent routes.
</td>
</tr>
<tr>
<td>
<code>requireChildren</code>
</td>
<td>
<code>boolean</code>
</td>
<td>
Whether a parent route requires a child to match.{" "}
<code>true</code> (default) = parent only matches if a child
matches, <code>false</code> = parent can match alone with{" "}
<code>outlet</code> as <code>null</code>. Enables catch-all
routes to work intuitively.
</td>
</tr>
</tbody>
</table>
</article>
Expand Down
47 changes: 47 additions & 0 deletions packages/docs/src/pages/LearnNestedRoutesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,53 @@ const routes = [
match this route at all. This is rarely needed but useful when you
want a route to behave as a leaf even though it has children defined.
</p>

<h4>Requiring Children to Match</h4>
<p>
By default, parent routes <strong>require</strong> at least one child
route to match. If no children match, the parent doesn't match
either&mdash;allowing other routes (like a catch-all) to handle the
URL instead.
</p>
<CodeBlock language="tsx">{`const routes = [
route({
path: "/dashboard",
component: DashboardLayout,
children: [
route({ path: "/", component: DashboardHome }),
route({ path: "/settings", component: SettingsPage }),
],
}),
route({
path: "/*", // Catch-all for unmatched routes
component: NotFoundPage,
}),
];

// /dashboard → matches DashboardLayout + DashboardHome
// /dashboard/settings → matches DashboardLayout + SettingsPage
// /dashboard/unknown → matches NotFoundPage (not DashboardLayout)`}</CodeBlock>
<p>
This behavior ensures that catch-all routes work intuitively. Without
it, <code>/dashboard/unknown</code> would match the dashboard layout
with an empty outlet, which is usually not desired.
</p>
<p>
If you want a parent route to match even when no children match, set{" "}
<code>requireChildren: false</code>. The <code>{"<Outlet>"}</code>{" "}
will render <code>null</code> in this case.
</p>
<CodeBlock language="tsx">{`route({
path: "/files",
component: FileExplorer,
requireChildren: false, // Match even without child matches
children: [
route({ path: "/:fileId", component: FileDetails }),
],
});

// /files → matches FileExplorer (outlet is null)
// /files/123 → matches FileExplorer + FileDetails`}</CodeBlock>
</section>

<section>
Expand Down
86 changes: 84 additions & 2 deletions packages/router/src/__tests__/matchRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ describe("matchRoutes", () => {
path: "/users",
component: () => null,
exact: true,
requireChildren: false,
children: [{ path: ":id", component: () => null }],
},
]);
Expand All @@ -168,7 +169,7 @@ describe("matchRoutes", () => {
const childResult = matchRoutes(routes, "/users/123");
expect(childResult).toBeNull();

// But exact match on parent still works
// But exact match on parent still works (with requireChildren: false)
const exactResult = matchRoutes(routes, "/users");
expect(exactResult).toHaveLength(1);
expect(exactResult![0].route.path).toBe("/users");
Expand All @@ -179,6 +180,7 @@ describe("matchRoutes", () => {
{
path: "/",
component: () => null,
requireChildren: false,
children: [{ path: "about", component: () => null }],
},
]);
Expand All @@ -188,7 +190,7 @@ describe("matchRoutes", () => {
expect(result).toHaveLength(2);

// Leaf requires exact match (default) - child "about" doesn't match "/about/extra"
// But parent "/" has a component, so it's still a valid match (just the parent)
// But parent "/" has a component and requireChildren: false, so it's still a valid match (just the parent)
const partialMatch = matchRoutes(routes, "/about/extra");
expect(partialMatch).toHaveLength(1);
expect(partialMatch![0].route.path).toBe("/");
Expand Down Expand Up @@ -367,4 +369,84 @@ describe("matchRoutes", () => {
expect(result![3].route.path).toBe("deep");
});
});

describe("requireChildren option", () => {
it("parent with children does not match when no children match (default)", () => {
const routes = internalRoutes([
{
path: "/dashboard",
component: () => null,
children: [{ path: "/main", component: () => null }],
},
]);
expect(matchRoutes(routes, "/dashboard/sub")).toBeNull();
expect(matchRoutes(routes, "/dashboard")).toBeNull();
});

it("parent with requireChildren: false matches when no children match", () => {
const routes = internalRoutes([
{
path: "/dashboard",
component: () => null,
requireChildren: false,
children: [{ path: "/main", component: () => null }],
},
]);
expect(matchRoutes(routes, "/dashboard/sub")).toHaveLength(1);
expect(matchRoutes(routes, "/dashboard")).toHaveLength(1);
});

it("catch-all NotFound works with requireChildren: true (default)", () => {
const routes = internalRoutes([
{
path: "/dashboard",
component: () => null,
children: [{ path: "/main", component: () => null }],
},
{ path: "/*", component: () => null }, // NotFound
]);
const result = matchRoutes(routes, "/dashboard/sub");
expect(result).toHaveLength(1);
expect(result![0].route.path).toBe("/*");
});

it("pathless route with children and requireChildren: false matches when no children match", () => {
const routes = internalRoutes([
{
component: () => null,
requireChildren: false,
children: [{ path: "/specific", component: () => null }],
},
]);
// Child matches
expect(matchRoutes(routes, "/specific")).toHaveLength(2);
// No child matches, but pathless route has component and requireChildren: false
expect(matchRoutes(routes, "/other")).toHaveLength(1);
});

it("pathless route with children does not match when no children match (default)", () => {
const routes = internalRoutes([
{
component: () => null,
children: [{ path: "/specific", component: () => null }],
},
]);
// Child matches
expect(matchRoutes(routes, "/specific")).toHaveLength(2);
// No child matches, default requireChildren is true
expect(matchRoutes(routes, "/other")).toBeNull();
});

it("requireChildren: false without component still does not match", () => {
const routes = internalRoutes([
{
path: "/dashboard",
requireChildren: false,
children: [{ path: "/main", component: () => null }],
},
]);
// No component means no match even with requireChildren: false
expect(matchRoutes(routes, "/dashboard")).toBeNull();
});
});
});
8 changes: 4 additions & 4 deletions packages/router/src/core/matchRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ function matchRoute(
return [result, ...childMatch];
}
}
// No children matched - only valid if this route has a component
if (route.component) {
// No children matched - only valid if requireChildren is false and route has a component
if (route.component && route.requireChildren === false) {
return [result];
}
return null;
Expand Down Expand Up @@ -94,8 +94,8 @@ function matchRoute(
}
}

// If no children matched but this route has a component, it's still a valid match
if (route.component) {
// 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
7 changes: 7 additions & 0 deletions packages/router/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface OpaqueRouteDefinition {
path?: string;
children?: RouteDefinition[];
exact?: boolean;
requireChildren?: boolean;
}

/**
Expand All @@ -101,6 +102,7 @@ export interface TypefulOpaqueRouteDefinition<
path?: string;
children?: RouteDefinition[];
exact?: boolean;
requireChildren?: boolean;
}

/** Extract the Id type from a TypefulOpaqueRouteDefinition */
Expand Down Expand Up @@ -183,6 +185,7 @@ export type RouteDefinition =
component?: ComponentType<object> | ReactNode;
children?: RouteDefinition[];
exact?: boolean;
requireChildren?: boolean;
};

/**
Expand All @@ -207,6 +210,7 @@ type RouteWithLoader<
| ReactNode;
children?: RouteDefinition[];
exact?: boolean;
requireChildren?: boolean;
};

/**
Expand All @@ -227,6 +231,7 @@ type RouteWithoutLoader<
| ReactNode;
children?: RouteDefinition[];
exact?: boolean;
requireChildren?: boolean;
};

/**
Expand All @@ -247,6 +252,7 @@ type PathlessRouteWithLoader<
>
| ReactNode;
children?: RouteDefinition[];
requireChildren?: boolean;
};

/**
Expand All @@ -263,6 +269,7 @@ type PathlessRouteWithoutLoader<
| ComponentType<RouteComponentProps<Record<string, never>, TState>>
| ReactNode;
children?: RouteDefinition[];
requireChildren?: boolean;
};

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/router/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export type InternalRouteDefinition = {
* - undefined: Default (exact for leaf, prefix for parent)
*/
exact?: boolean;
/**
* Whether this route requires a child to match when it has children.
* - true (default): Parent does not match if no child matches
* - false: Parent can match alone if it has a component, outlet will be null
*/
requireChildren?: boolean;

// Note: `loader` and `component` may both exist or both not exist.
// Also, `unknown`s may actually be more specific types. They are guaranteed
Expand Down