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
28 changes: 26 additions & 2 deletions packages/router/src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ export type RouterProps = {
* - `"static"`: Render matched routes without navigation capabilities (MPA behavior)
*/
fallback?: FallbackMode;
/**
* Pathname to use for route matching during SSR.
*
* By default, during SSR only pathless routes match. When this prop is provided,
* the router uses this pathname to match path-based routes during SSR as well.
* Loaders are not executed during SSR regardless of this setting.
*
* This prop is only used when the location entry is not available (during SSR
* or hydration). Once the client hydrates, the real URL from the Navigation API
* takes over.
*
* @example
* ```tsx
* <Router routes={routes} ssrPathname="/about" />
* ```
*/
ssrPathname?: string;
};

/**
Expand All @@ -60,6 +77,7 @@ export function Router({
routes: inputRoutes,
onNavigate,
fallback = "none",
ssrPathname,
}: RouterProps): ReactNode {
const routes = internalRoutes(inputRoutes);

Expand Down Expand Up @@ -150,8 +168,13 @@ export function Router({
// Match routes and execute loaders
const matchedRoutesWithData = (() => {
if (locationEntry === null) {
// SSR/hydration: match only pathless routes, skip loaders
const matched = matchRoutes(routes, null);
// SSR/hydration: match routes without executing loaders.
// When ssrPathname is provided, path-based routes can match;
// otherwise only pathless routes match (null pathname).
// Routes with loaders are always skipped during SSR.
const matched = matchRoutes(routes, ssrPathname ?? null, {
skipLoaders: true,
});
if (!matched) return null;
return matched.map((m) => ({ ...m, data: undefined }));
}
Expand Down Expand Up @@ -195,6 +218,7 @@ export function Router({
routes,
adapter,
blockerRegistry,
ssrPathname,
]);
}

Expand Down
177 changes: 177 additions & 0 deletions packages/router/src/__tests__/fallback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,180 @@ describe("Fallback Mode", () => {
});
});
});

describe("ssrPathname", () => {
beforeEach(() => {
// Ensure Navigation API is not available for SSR tests
delete (globalThis as Record<string, unknown>).navigation;
clearLoaderCache();
});

afterEach(() => {
delete (globalThis as Record<string, unknown>).navigation;
});

it("matches path-based routes during SSR when ssrPathname is provided", () => {
const routes: RouteDefinition[] = [
{ path: "/", component: () => <div>Home Page</div> },
{ path: "/about", component: () => <div>About Page</div> },
];

render(<Router routes={routes} ssrPathname="/about" />);
expect(screen.getByText("About Page")).toBeInTheDocument();
});

it("matches root route with ssrPathname='/'", () => {
const routes: RouteDefinition[] = [
{ path: "/", component: () => <div>Home Page</div> },
{ path: "/about", component: () => <div>About Page</div> },
];

render(<Router routes={routes} ssrPathname="/" />);
expect(screen.getByText("Home Page")).toBeInTheDocument();
});

it("renders nothing when ssrPathname does not match any route", () => {
const routes: RouteDefinition[] = [
{ path: "/", component: () => <div>Home Page</div> },
];

const { container } = render(
<Router routes={routes} ssrPathname="/nonexistent" />,
);
expect(container.textContent).toBe("");
});

it("extracts route params from ssrPathname", () => {
const routes = [
route({
path: "/users/:id",
component: ({ params }) => <div>User {params.id}</div>,
}),
];

render(<Router routes={routes} ssrPathname="/users/42" />);
expect(screen.getByText("User 42")).toBeInTheDocument();
});

it("matches nested routes with ssrPathname", () => {
function Layout() {
return (
<div>
<header>Layout</header>
<Outlet />
</div>
);
}

const routes: RouteDefinition[] = [
{
path: "/",
component: Layout,
children: [
{ path: "", component: () => <div>Home</div> },
{ path: "about", component: () => <div>About</div> },
],
},
];

render(<Router routes={routes} ssrPathname="/about" />);
expect(screen.getByText("Layout")).toBeInTheDocument();
expect(screen.getByText("About")).toBeInTheDocument();
});

it("does not match routes with loaders during SSR with ssrPathname", () => {
const loader = vi.fn(() => ({ message: "loaded" }));

function MyComponent({ data }: { data: { message: string } }) {
return <div>{data?.message ?? "no data"}</div>;
}

const routes = [
route({
path: "/about",
loader,
component: MyComponent,
}),
];

const { container } = render(
<Router routes={routes} ssrPathname="/about" />,
);
expect(loader).not.toHaveBeenCalled();
expect(container.textContent).toBe("");
});

it("skips route with loader and matches sibling without loader", () => {
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} ssrPathname="/about" />);
expect(screen.getByText("Catch All")).toBeInTheDocument();
});

it("renders parent shell when all children have loaders", () => {
function Layout() {
return (
<div>
<header>Shell</header>
<Outlet />
</div>
);
}

const childRoute = {
path: "dashboard",
component: () => <div>Dashboard</div>,
};
(childRoute as Record<string, unknown>).loader = () => "data";

const routes: RouteDefinition[] = [
{
path: "/",
component: Layout,
children: [childRoute],
},
];

render(<Router routes={routes} ssrPathname="/dashboard" />);
expect(screen.getByText("Shell")).toBeInTheDocument();
});

it("falls back to pathless-only matching when ssrPathname is not provided", () => {
const routes: RouteDefinition[] = [
{ path: "/about", component: () => <div>About Page</div> },
];

const { container } = render(<Router routes={routes} />);
expect(container.textContent).toBe("");
});

it("pathless route wrapping path-based children works with ssrPathname", () => {
const routes: RouteDefinition[] = [
{
component: () => (
<div>
Shell <Outlet />
</div>
),
children: [
{ path: "/about", component: () => <span>About</span> },
{ path: "/contact", component: () => <span>Contact</span> },
],
},
];

render(<Router routes={routes} ssrPathname="/contact" />);
expect(screen.getByText(/Shell/)).toBeInTheDocument();
expect(screen.getByText("Contact")).toBeInTheDocument();
});
});
136 changes: 136 additions & 0 deletions packages/router/src/__tests__/matchRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,4 +581,140 @@ describe("matchRoutes", () => {
expect(result![0].route.loader).toBeDefined();
});
});

describe("skipLoaders option", () => {
it("skips path-based route with loader when skipLoaders is true", () => {
const routes = [
{
path: "/about",
component: () => null,
loader: () => "data",
},
] as unknown as InternalRouteDefinition[];

expect(matchRoutes(routes, "/about", { skipLoaders: true })).toBeNull();
});

it("matches path-based route without loader when skipLoaders is true", () => {
const routes = internalRoutes([
{ path: "/about", component: () => null },
]);

const result = matchRoutes(routes, "/about", { skipLoaders: true });
expect(result).toHaveLength(1);
expect(result![0].route.path).toBe("/about");
});

it("skips pathless route with loader when skipLoaders is true", () => {
const routes = [
{
component: () => null,
loader: () => "data",
},
] as unknown as InternalRouteDefinition[];

expect(matchRoutes(routes, "/about", { skipLoaders: true })).toBeNull();
});

it("skips route with loader and falls back to sibling", () => {
const routes = [
{
path: "/about",
component: () => null,
loader: () => "data",
},
{
path: "/*",
component: () => null,
},
] as unknown as InternalRouteDefinition[];

const result = matchRoutes(routes, "/about", { skipLoaders: true });
expect(result).toHaveLength(1);
expect(result![0].route.path).toBe("/*");
});

it("skips child route with loader, parent renders as shell", () => {
const routes = [
{
path: "/",
component: () => null,
children: [
{
path: "dashboard",
component: () => null,
loader: () => "data",
},
],
},
] as unknown as InternalRouteDefinition[];

const result = matchRoutes(routes, "/dashboard", { skipLoaders: true });
expect(result).toHaveLength(1);
expect(result![0].route.path).toBe("/");
});

it("pathless wrapper with path-based children skips children with loaders", () => {
const routes = [
{
component: () => null,
children: [
{
path: "/about",
component: () => null,
loader: () => "data",
},
],
},
] as unknown as InternalRouteDefinition[];

// Pathless route matches alone as SSR shell since child with loader is skipped
const result = matchRoutes(routes, "/about", { skipLoaders: true });
expect(result).toHaveLength(1);
expect(result![0].route.path).toBeUndefined();
});

it("does not affect matching when skipLoaders is false", () => {
const routes = [
{
path: "/about",
component: () => null,
loader: () => "data",
},
] as unknown as InternalRouteDefinition[];

const result = matchRoutes(routes, "/about", { skipLoaders: false });
expect(result).toHaveLength(1);
});

it("extracts params from path-based routes without loaders", () => {
const routes = internalRoutes([
{ path: "/users/:id", component: () => null },
]);

const result = matchRoutes(routes, "/users/42", { skipLoaders: true });
expect(result).toHaveLength(1);
expect(result![0].params).toEqual({ id: "42" });
});

it("matches nested routes where only leaf has no loader", () => {
const routes = [
{
path: "/",
component: () => null,
children: [
{
path: "users",
component: () => null,
children: [{ path: ":id", component: () => null }],
},
],
},
] as unknown as InternalRouteDefinition[];

const result = matchRoutes(routes, "/users/42", { skipLoaders: true });
expect(result).toHaveLength(3);
expect(result![2].params).toEqual({ id: "42" });
});
});
});
Loading