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,8 +1,14 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { createRootRoute } from '@tanstack/react-router';

import { createFileRoute } from './react-router.ts';

vi.mock('storybook/test', () => ({
fn: (implementation: unknown) => ({
mockName: () => implementation,
}),
}));

const build = (path: string) => {
const root = createRootRoute();
return createFileRoute(path)({
Expand Down Expand Up @@ -44,6 +50,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
90 changes: 90 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,90 @@
import { describe, expect, it } from 'vitest';
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
} from '@tanstack/react-router';

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

describe('duplicateRouteTree', () => {
it('uses a fresh preview root instead of copying source root options', () => {
const SourceRootComponent = () => null;
const sourceRootBeforeLoad = () => ({ fromSourceRoot: true });
const root = createRootRoute({
component: SourceRootComponent,
beforeLoad: sourceRootBeforeLoad,
});

const { root: duplicatedRoot } = duplicateRouteTree(root);

expect((duplicatedRoot as any).options.component).toBeUndefined();
expect((duplicatedRoot as any).options.beforeLoad).toBeUndefined();
});

it('applies explicit root overrides to the fresh preview root', () => {
const SourceRootComponent = () => null;
const OverrideRootComponent = () => null;
const overrideBeforeLoad = () => ({ fromOverrideRoot: true });
const root = createRootRoute({
component: SourceRootComponent,
});

const { root: duplicatedRoot } = duplicateRouteTree(root, {
overrides: {
__root__: {
component: OverrideRootComponent,
beforeLoad: overrideBeforeLoad,
},
} as any,
});

expect((duplicatedRoot as any).options.component).toBe(OverrideRootComponent);
expect((duplicatedRoot as any).options.beforeLoad).toBe(overrideBeforeLoad);
});

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,
]);
});
});
24 changes: 14 additions & 10 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 Expand Up @@ -109,18 +114,17 @@ export function duplicateRouteTree(
initSourceTree(rootRoute, { i: 0 });

const byId = new Map<string, AnyRoute>();
const rootOptions = (rootRoute as any).options ?? {};
const rootOverride = getOverrideFor(overrides, '__root__');

// Always build a fresh `RootRoute` instead of reusing / mutating the
// caller's root. Reusing the same root across multiple story routers is
// what causes TanStack to report a duplicated `__root__` id when more than
// one story is mounted in the same browser session (e.g. HMR, navigation).
// We strip `id` from spread options so TanStack assigns the canonical
// `__root__` id itself.
const { id: _rootId, getParentRoute: _rootGetParent, ...restRoot } = rootOptions;
// Do not copy source root options into the duplicate: TanStack Start roots
// often render the full document (`html`, `head`, `body`), which is invalid
// inside Storybook's preview root. Storybook-specific root behavior can still
// be supplied with an explicit `routeOverrides.__root__` entry.
Comment thread
MPiland marked this conversation as resolved.
const newRoot = createRootRouteWithContext()({
...restRoot,
...rootOverride,
} as any);
byId.set('__root__', newRoot as unknown as AnyRoute);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import React from 'react';

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

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

const authedRouteApi = getRouteApi('/_authenticated');
const nestedRouteApi = getRouteApi('/_authenticated/races/$racePublicId/$raceSlug/test');

function AppProviders({ children }: { children: React.ReactNode }) {
return <div data-testid="app-providers">{children}</div>;
}

function DocumentRoot() {
return (
<html lang="en" data-testid="document-root">
<head>
<HeadContent />
</head>
<body>
<AppProviders>
<Outlet />
</AppProviders>
<Scripts />
</body>
</html>
);
}

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

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

function RouteStateProbe() {
const authedContext = authedRouteApi.useRouteContext() as { sessionLabel: string };
const params = nestedRouteApi.useParams() as {
racePublicId: string;
raceSlug: string;
};

return (
<article>
<p data-testid="route-context">{authedContext.sessionLabel}</p>
<p data-testid="race-params">
{params.racePublicId}:{params.raceSlug}
</p>
</article>
);
}

const RootRoute = createRootRoute({
component: DocumentRoot,
});

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

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,
path: '/races/$racePublicId/$raceSlug/test',
params: {
racePublicId: 'dragonborn',
raceSlug: 'dragonborn',
},
},
},
},
play: async ({ canvas }) => {
await expect(canvas.getByTestId('pathless-layout')).toBeInTheDocument();
await expect(
canvas.getByRole('heading', { level: 1, name: 'Authenticated layout' })
).toBeInTheDocument();
await expect(canvas.getByTestId('race-layout')).toBeInTheDocument();
await expect(canvas.getByTestId('route-context')).toHaveTextContent('pathless route context');
await expect(canvas.getByTestId('race-params')).toHaveTextContent('dragonborn:dragonborn');
await expect(canvas.queryByTestId('document-root')).not.toBeInTheDocument();
await expect(canvas.queryByTestId('app-providers')).not.toBeInTheDocument();
},
};