Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
import { describe, expect, it } from 'vitest';
import { createRootRoute } from '@tanstack/react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { createFileRoute } from './react-router.ts';
const storybookTestMock = vi.hoisted(() => ({
fn: vi.fn<(implementation: unknown) => { mockName: () => unknown }>(),
}));
const previewApiMock = vi.hoisted(() => ({
useEffect: vi.fn<() => undefined>(),
}));

// @ts-expect-error Vitest supports factory mocks with spy options, but the pinned types only expose the two-argument overload.
vi.mock('storybook/test', () => storybookTestMock, { spy: true });
// @ts-expect-error Vitest supports factory mocks with spy options, but the pinned types only expose the two-argument overload.
vi.mock('storybook/internal/preview-api', () => previewApiMock, { spy: true });

let createFileRoute: typeof import('./react-router.ts').createFileRoute;

beforeEach(async () => {
vi.resetModules();
vi.mocked(storybookTestMock.fn).mockImplementation((implementation: unknown) => ({
mockName: () => implementation,
}));
vi.mocked(previewApiMock.useEffect).mockImplementation(() => undefined);

({ createFileRoute } = await import('./react-router.ts'));
});

const build = (path: string) => {
const root = createRootRoute();
Expand Down Expand Up @@ -44,6 +66,14 @@ describe('createFileRoute', () => {
expect(Route.options.fullPath).toBe('/page');
});

it('keeps pure pathless `_layout` routes id-only', () => {
const Route = build('/_layout');

expect(Route.options.id).toBe('/_layout');
expect(Route.options.path).toBeUndefined();
expect(Route.options.fullPath).toBeUndefined();
});

it('normalizes a mix of `_layout` and `(group)` segments', () => {
const Route = build('/_layout/(group)/page');

Expand Down
14 changes: 11 additions & 3 deletions code/frameworks/tanstack-react/src/export-mocks/react-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,24 @@ export const Link = ({
*/
export function createFileRoute(path: string) {
const normalizedPath = normalizeFileRoutePath(path);
const segments = path.split('/').filter(Boolean);
const lastSegment = segments[segments.length - 1];
const isPurePathlessLayoutRoute =
(lastSegment?.startsWith('_') ?? false) && normalizedPath === '/';

return (options: any) => {
return createRoute({
path: normalizedPath,
...(isPurePathlessLayoutRoute ? { id: path } : { path: normalizedPath }),
...options,
isRoot: false,
}).update({
id: path,
path: normalizedPath,
fullPath: normalizedPath,
...(isPurePathlessLayoutRoute
? {}
: {
path: normalizedPath,
fullPath: normalizedPath,
}),
// any because tanstack router does that
} as any);
};
Expand Down
55 changes: 55 additions & 0 deletions code/frameworks/tanstack-react/src/routing/duplicate-tree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
} from '@tanstack/react-router';

import { duplicateRouteTree } from './duplicate-tree.ts';

describe('duplicateRouteTree', () => {
it('preserves pathless layout routes as id-only routes', async () => {
const root = createRootRoute();
const authed = createRoute({
id: '/_authed',
getParentRoute: () => root,
component: () => null,
});
const race = createRoute({
path: 'races/$racePublicId/$raceSlug',
getParentRoute: () => authed,
component: () => null,
});
const test = createRoute({
path: 'test',
getParentRoute: () => race,
component: () => null,
});

root.addChildren([authed.addChildren([race.addChildren([test])])]);

const { root: duplicatedRoot, byId } = duplicateRouteTree(root);
const clonedAuthed = byId.get(authed.id) as any;
const clonedRace = byId.get(race.id) as any;
const clonedTest = byId.get(test.id) as any;

expect(clonedAuthed.options.id).toBe('/_authed');
expect(clonedAuthed.options.path).toBeUndefined();

const router = createRouter({
routeTree: duplicatedRoot,
history: createMemoryHistory({
initialEntries: ['/races/dragonborn/dragonborn/test'],
}),
});
await router.load();

expect(router.state.matches.map((match) => match.routeId)).toEqual([
duplicatedRoot.id,
clonedAuthed.id,
clonedRace.id,
clonedTest.id,
]);
});
});
15 changes: 10 additions & 5 deletions code/frameworks/tanstack-react/src/routing/duplicate-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,23 +62,28 @@ function cloneChild(
byId: Map<string, AnyRoute>
): AnyRoute {
const options = (oldRoute as any).options ?? {};
// Strip identity / parent-link options so the cloned route gets its identity
// derived from the new parent + path, and its parent linked to the cloned
// parent below. Keeping the original `id` would cause TanStack to register
// two routes with the same generated id (e.g. `__root__/about`).
// Strip parent-link options so the cloned route gets linked to the cloned
// parent below. Path routes derive identity from the new parent + path, then
// preserve the source file-route id after creation. Pathless layout routes
// are id-only, so giving them a path would turn the layout into a URL route.
const { id: _id, getParentRoute: _g, ...rest } = options;
const override = getOverrideFor(overrides, oldRoute.id);

// Use `createRoute` (not `createFileRoute`) for nested clones: `createFileRoute`
// registers the route in TanStack's global file-route registry by path, so
// re-running the duplication on every story re-render registers a duplicate
// and TanStack throws `Duplicate routeIds found: __root__`.
const cloned = createRoute({
let cloned = createRoute({
...(options.id && options.path === undefined ? { id: options.id } : {}),
...rest,
...override,
getParentRoute: () => parent as any,
} as any);

if (options.id && options.path !== undefined) {
cloned = cloned.update({ id: options.id } as any);
}

byId.set(oldRoute.id, cloned as unknown as AnyRoute);

const children = (oldRoute as any).children as AnyRoute[] | undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React from 'react';

import type { Meta, StoryObj } from '@storybook/tanstack-react';

import { Outlet, createRootRoute, createRoute, getRouteApi } from '@tanstack/react-router';
import { expect, within } from 'storybook/test';

const authenticatedRouteApi = getRouteApi('/_authenticated');
const testRouteApi = getRouteApi('/_authenticated/races/$racePublicId/$raceSlug/test');

function AuthenticatedLayout() {
return (
<section data-testid="pathless-layout">
<h1>pathless layout</h1>
<Outlet />
</section>
);
}

function RaceLayout() {
return (
<section data-testid="race-layout">
<h2>nested race layout</h2>
<Outlet />
</section>
);
}

function RouteStateProbe() {
const context = authenticatedRouteApi.useRouteContext() as { sessionLabel: string };
const params = testRouteApi.useParams() as {
racePublicId: string;
raceSlug: string;
};

return (
<dl>
<dt>route context</dt>
<dd>{context.sessionLabel}</dd>
<dt>route params</dt>
<dd>
{params.racePublicId}:{params.raceSlug}
</dd>
</dl>
);
}

const RootRoute = createRootRoute();

const AuthenticatedRoute = createRoute({
id: '/_authenticated',
getParentRoute: () => RootRoute,
beforeLoad: () => ({ sessionLabel: 'pathless route context' }),
component: AuthenticatedLayout,
});

const RaceRoute = createRoute({
path: 'races/$racePublicId/$raceSlug',
getParentRoute: () => AuthenticatedRoute,
component: RaceLayout,
});

const TestRoute = createRoute({
path: 'test',
getParentRoute: () => RaceRoute,
component: RouteStateProbe,
});

RootRoute.addChildren([AuthenticatedRoute.addChildren([RaceRoute.addChildren([TestRoute])])]);

const meta = {
component: RouteStateProbe,
parameters: { layout: 'fullscreen' },
} satisfies Meta<typeof RouteStateProbe>;

export default meta;

type Story = StoryObj<typeof meta>;

export const PreservesPathlessLayoutRouteState: Story = {
parameters: {
tanstack: {
router: {
route: TestRoute,
params: {
racePublicId: 'dragonborn',
raceSlug: 'dragonborn',
},
},
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

await expect(canvas.getByRole('heading', { level: 1, name: 'pathless layout' })).toBeVisible();
await expect(
canvas.getByRole('heading', { level: 2, name: 'nested race layout' })
).toBeVisible();
await expect(canvas.getByText('pathless route context')).toBeVisible();
await expect(canvas.getByText('dragonborn:dragonborn')).toBeVisible();
},
};