diff --git a/code/frameworks/tanstack-react/src/export-mocks/react-router.test.ts b/code/frameworks/tanstack-react/src/export-mocks/react-router.test.ts index c1dde615cfcb..f7e2416a8df2 100644 --- a/code/frameworks/tanstack-react/src/export-mocks/react-router.test.ts +++ b/code/frameworks/tanstack-react/src/export-mocks/react-router.test.ts @@ -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(); @@ -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'); diff --git a/code/frameworks/tanstack-react/src/export-mocks/react-router.ts b/code/frameworks/tanstack-react/src/export-mocks/react-router.ts index d8b70efe0e28..df84c3014cc1 100644 --- a/code/frameworks/tanstack-react/src/export-mocks/react-router.ts +++ b/code/frameworks/tanstack-react/src/export-mocks/react-router.ts @@ -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); }; diff --git a/code/frameworks/tanstack-react/src/routing/duplicate-tree.test.ts b/code/frameworks/tanstack-react/src/routing/duplicate-tree.test.ts new file mode 100644 index 000000000000..b3ac61574f3c --- /dev/null +++ b/code/frameworks/tanstack-react/src/routing/duplicate-tree.test.ts @@ -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, + ]); + }); +}); diff --git a/code/frameworks/tanstack-react/src/routing/duplicate-tree.ts b/code/frameworks/tanstack-react/src/routing/duplicate-tree.ts index aad658f0f22e..0768bd63bb6c 100644 --- a/code/frameworks/tanstack-react/src/routing/duplicate-tree.ts +++ b/code/frameworks/tanstack-react/src/routing/duplicate-tree.ts @@ -62,10 +62,10 @@ function cloneChild( byId: Map ): 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); @@ -73,12 +73,17 @@ function cloneChild( // 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; diff --git a/code/frameworks/tanstack-react/template/stories/PathlessLayoutRoutes.stories.tsx b/code/frameworks/tanstack-react/template/stories/PathlessLayoutRoutes.stories.tsx new file mode 100644 index 000000000000..3986e631d0b0 --- /dev/null +++ b/code/frameworks/tanstack-react/template/stories/PathlessLayoutRoutes.stories.tsx @@ -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 ( +
+

pathless layout

+ +
+ ); +} + +function RaceLayout() { + return ( +
+

nested race layout

+ +
+ ); +} + +function RouteStateProbe() { + const context = authenticatedRouteApi.useRouteContext() as { sessionLabel: string }; + const params = testRouteApi.useParams() as { + racePublicId: string; + raceSlug: string; + }; + + return ( +
+
route context
+
{context.sessionLabel}
+
route params
+
+ {params.racePublicId}:{params.raceSlug} +
+
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +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(); + }, +};