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
37 changes: 30 additions & 7 deletions packages/docs/src/pages/ApiHooksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,13 @@ function EditForm() {
These hooks provide type-safe access to route data when using routes
defined with an <code>id</code>. They extract type information from{" "}
<code>TypefulOpaqueRouteDefinition</code> and validate at runtime that
you're using them within the correct route.
the specified route exists in the current route hierarchy.
</p>
<p>
In nested routes, these hooks can access data from any ancestor route in
the hierarchy. For example, a child route component can use{" "}
<code>useRouteParams(parentRoute)</code> to access the parent route's
parameters.
</p>

<article className="api-item">
Expand All @@ -159,13 +165,28 @@ function UserPage() {
const params = useRouteParams(userRoute);

return <div>User ID: {params.userId}</div>;
}

// In nested routes, access parent route params:
const orgRoute = route({
id: "org",
path: "/org/:orgId",
component: OrgLayout,
children: [teamRoute],
});

function TeamPage() {
// Access parent route's params
const { orgId } = useRouteParams(orgRoute);
return <div>Org: {orgId}</div>;
}`}</CodeBlock>
<h4>Errors</h4>
<ul>
<li>Throws if called outside a route component (no RouteContext).</li>
<li>
Throws if the current route's <code>id</code> doesn't match the
provided route definition's <code>id</code>.
Throws if the specified route's <code>id</code> is not found in the
current route hierarchy (neither the current route nor any
ancestor).
</li>
</ul>
</article>
Expand Down Expand Up @@ -205,8 +226,9 @@ function ScrollPage() {
<ul>
<li>Throws if called outside a route component (no RouteContext).</li>
<li>
Throws if the current route's <code>id</code> doesn't match the
provided route definition's <code>id</code>.
Throws if the specified route's <code>id</code> is not found in the
current route hierarchy (neither the current route nor any
ancestor).
</li>
</ul>
</article>
Expand Down Expand Up @@ -244,8 +266,9 @@ function UserPage() {
<ul>
<li>Throws if called outside a route component (no RouteContext).</li>
<li>
Throws if the current route's <code>id</code> doesn't match the
provided route definition's <code>id</code>.
Throws if the specified route's <code>id</code> is not found in the
current route hierarchy (neither the current route nor any
ancestor).
</li>
</ul>
</article>
Expand Down
6 changes: 5 additions & 1 deletion packages/router/src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ function RouteRenderer({
matchedRoutes,
index,
}: RouteRendererProps): ReactNode {
// Get parent route context (null for root route)
const parentRouteContext = useContext(RouteContext);

const match = matchedRoutes[index];
if (!match) return null;

Expand Down Expand Up @@ -249,8 +252,9 @@ function RouteRenderer({
state: routeState,
data,
outlet,
parent: parentRouteContext,
}),
[routeId, params, pathname, routeState, data, outlet],
[routeId, params, pathname, routeState, data, outlet, parentRouteContext],
);

// Render component with or without data prop based on loader presence
Expand Down
190 changes: 187 additions & 3 deletions packages/router/src/__tests__/useRouteParams.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { Router } from "../Router.js";
import { Outlet } from "../Outlet.js";
import { route, routeState } from "../route.js";
import { useRouteParams } from "../hooks/useRouteParams.js";
import { useRouteState } from "../hooks/useRouteState.js";
Expand Down Expand Up @@ -85,7 +86,7 @@ describe("useRouteParams", () => {
];

expect(() => render(<Router routes={routes} />)).toThrow(
'useRouteParams: Route ID mismatch. Expected "different" but current route is "user"',
'useRouteParams: Route ID "different" not found in current route hierarchy. Current route is "user"',
);
});
});
Expand Down Expand Up @@ -170,7 +171,7 @@ describe("useRouteState", () => {
];

expect(() => render(<Router routes={routes} />)).toThrow(
'useRouteState: Route ID mismatch. Expected "different" but current route is "counter"',
'useRouteState: Route ID "different" not found in current route hierarchy. Current route is "counter"',
);
});
});
Expand Down Expand Up @@ -264,7 +265,190 @@ describe("useRouteData", () => {
];

expect(() => render(<Router routes={routes} />)).toThrow(
'useRouteData: Route ID mismatch. Expected "different" but current route is "user"',
'useRouteData: Route ID "different" not found in current route hierarchy. Current route is "user"',
);
});
});

describe("nested route access", () => {
afterEach(() => {
cleanupNavigationMock();
});

it("useRouteParams can access current route params", () => {
setupNavigationMock("http://localhost/admin/users/123");

const adminUserRoute = route({
id: "admin.user",
path: "users/:userId",
component: () => null,
});

function AdminUserPage() {
// Access child route params
const childParams = useRouteParams(adminUserRoute);
return (
<div>
<span data-testid="userId">{childParams.userId}</span>
</div>
);
}

const routes = [
route({
id: "admin",
path: "/admin",
component: Outlet,
children: [
route({
id: "admin.user",
path: "users/:userId",
component: AdminUserPage,
}),
],
}),
];

render(<Router routes={routes} />);
expect(screen.getByTestId("userId").textContent).toBe("123");
});

it("useRouteData can access parent route data from child", () => {
setupNavigationMock("http://localhost/admin/settings");

const adminRoute = route({
id: "admin",
path: "/admin",
loader: () => ({ adminName: "Super Admin" }),
component: () => null,
});

function AdminSettingsPage() {
// Access parent route data from child
const parentData = useRouteData(adminRoute);
return (
<div>
<span data-testid="adminName">{parentData.adminName}</span>
</div>
);
}

const routes = [
route({
id: "admin",
path: "/admin",
loader: () => ({ adminName: "Super Admin" }),
component: Outlet,
children: [
route({
id: "admin.settings",
path: "settings",
component: AdminSettingsPage,
}),
],
}),
];

render(<Router routes={routes} />);
expect(screen.getByTestId("adminName").textContent).toBe("Super Admin");
});

it("throws when route ID is not in ancestor chain", () => {
setupNavigationMock("http://localhost/admin/settings");

const unrelatedRoute = route({
id: "unrelated",
path: "/other",
component: () => null,
});

function AdminSettingsPage() {
// Try to access a route that is not in the ancestor chain
useRouteParams(unrelatedRoute);
return null;
}

const routes = [
route({
id: "admin",
path: "/admin",
component: Outlet,
children: [
route({
id: "admin.settings",
path: "settings",
component: AdminSettingsPage,
}),
],
}),
];

expect(() => render(<Router routes={routes} />)).toThrow(
'useRouteParams: Route ID "unrelated" not found in current route hierarchy. Current route is "admin.settings"',
);
});

it("can access params from all ancestor routes in deeply nested routes", () => {
setupNavigationMock("http://localhost/org/123/team/456/member/789");

const memberRoute = route({
id: "member",
path: "member/:memberId",
component: () => null,
});

const teamRoute = route({
id: "team",
path: "team/:teamId",
component: () => null,
});

const orgRoute = route({
id: "org",
path: "/org/:orgId",
component: () => null,
});

function MemberPage() {
// Access params from all levels of the hierarchy
const memberParams = useRouteParams(memberRoute);
const teamParams = useRouteParams(teamRoute);
const orgParams = useRouteParams(orgRoute);

return (
<div>
<span data-testid="orgId">{orgParams.orgId}</span>
<span data-testid="teamId">{teamParams.teamId}</span>
<span data-testid="memberId">{memberParams.memberId}</span>
</div>
);
}

const routes = [
route({
id: "org",
path: "/org/:orgId",
component: Outlet,
children: [
route({
id: "team",
path: "team/:teamId",
component: Outlet,
children: [
route({
id: "member",
path: "member/:memberId",
component: MemberPage,
}),
],
}),
],
}),
];

render(<Router routes={routes} />);
expect(screen.getByTestId("orgId").textContent).toBe("123");
expect(screen.getByTestId("teamId").textContent).toBe("456");
expect(screen.getByTestId("memberId").textContent).toBe("789");
});
});
18 changes: 18 additions & 0 deletions packages/router/src/context/RouteContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ export type RouteContextValue = {
data: unknown;
/** Child route element to render via Outlet */
outlet: ReactNode;
/** Parent route context (for nested routes) */
parent: RouteContextValue | null;
};

export const RouteContext = createContext<RouteContextValue | null>(null);

/**
* Find a route context by ID in the ancestor chain.
* Returns the matching context or null if not found.
*/
export function findRouteContextById(
context: RouteContextValue | null,
id: string,
): RouteContextValue | null {
let current = context;
while (current !== null) {
if (current.id === id) return current;
current = current.parent;
}
return null;
}
43 changes: 43 additions & 0 deletions packages/router/src/hooks/useRouteContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useContext } from "react";
import {
RouteContext,
findRouteContextById,
type RouteContextValue,
} from "../context/RouteContext.js";

/**
* Internal hook that returns the RouteContextValue for the given route.
* If the route has an ID, it searches the ancestor chain for a matching route.
* If no ID is provided, it returns the current (nearest) route context.
*
* @param hookName - Name of the calling hook (for error messages)
* @param routeId - Optional route ID to search for in the ancestor chain
* @returns The matching RouteContextValue
* @throws If called outside a route component or if the route ID is not found
* @internal
*/
export function useRouteContext(
hookName: string,
routeId: string | undefined,
): RouteContextValue {
const context = useContext(RouteContext);
if (!context) {
throw new Error(`${hookName} must be used within a route component`);
}

// If no expected ID, use current context (backwards compatible)
if (routeId === undefined) {
return context;
}

// Look for matching route in ancestor chain
const matchedContext = findRouteContextById(context, routeId);
if (!matchedContext) {
throw new Error(
`${hookName}: Route ID "${routeId}" not found in current route hierarchy. ` +
`Current route is "${context.id ?? "(no id)"}"`,
);
}

return matchedContext;
}
Loading