Usage with Storybook? #952
Replies: 13 comments · 22 replies
-
Currently with Storybook I get this error on most stories: ReferenceError: Cannot access 'Root' before initialization |
Beta Was this translation helpful? Give feedback.
-
I would like to know how to use Storybook as well. I think a plugin would be great, or simply a working solution how to create the router context for stories that you can display components which includes any API of the router |
Beta Was this translation helpful? Give feedback.
-
I'm interested to know if there are any workaround/solutions to render the |
Beta Was this translation helpful? Give feedback.
-
Here's what I created: Storybook fake Tanstack router - Gist |
Beta Was this translation helpful? Give feedback.
-
Here is what I've done import type {PartialStoryFn, StoryContext} from '@storybook/types';
import {createMemoryHistory, createRootRoute, createRoute, createRouter, RouterProvider} from '@tanstack/react-router';
export default function withRouter(Story: PartialStoryFn, {parameters}: StoryContext) {
const {initialEntries = ['/'], initialIndex, routes = ['/']} = parameters?.router || {};
const rootRoute = createRootRoute();
const children = routes.map((path) =>
createRoute({
path,
getParentRoute: () => rootRoute,
component: Story,
}),
);
rootRoute.addChildren(children);
const router = createRouter({
history: createMemoryHistory({initialEntries, initialIndex}),
routeTree: rootRoute,
});
return <RouterProvider router={router} />;
}
declare module '@storybook/types' {
interface Parameters {
router?: {
initialEntries?: string[];
initialIndex?: number;
routes?: string[];
};
}
} You can add it as global decorator in |
Beta Was this translation helpful? Give feedback.
-
Here's something simple that's been working fine with router v1.4 and storybook v7.6. You can see it has no business logic at all, but it's enough to provide the router context so things don't break. // preview.tsx
import type { Preview } from '@storybook/react';
import { RouterProvider, createMemoryHistory, createRootRoute, createRouter } from '@tanstack/react-router';
const preview: Preview = {
decorators: [
(Story) => {
return <RouterProvider router={createRouter({
history: createMemoryHistory(),
routeTree: createRootRoute({
component: Story
})
})} />
}
]
};
export default preview |
Beta Was this translation helpful? Give feedback.
-
Just FYI, there's quite a bit of subtlety involved with the Storybook (8) integration, the simple solution I and others have pasted above does not always work properly when switching between stories:
|
Beta Was this translation helpful? Give feedback.
-
I bypassed the RouterProvider and used the RouterContextProvider and so far it works well. Can't promise it wouldn't break in a future version or that it works in all cases.
|
Beta Was this translation helpful? Give feedback.
-
I tried all the above ways but it all looses either strong typings on
I start to provide my own useSearch which basically keeps the same typings but replaces the options with {strict:false} import {
type AnyRoute,
type FullSearchSchema,
type RegisteredRouter,
type RouteById,
type RouteIds,
useSearch
} from "@tanstack/react-router"
import { isStorybook } from "../lib/generalConfig"
type StringLiteral<T> = T extends string ? (string extends T ? string : T) : never
type StrictOrFrom<TFrom, TStrict extends boolean = true> = TStrict extends false
? {
from?: never
strict: TStrict
}
: {
from: StringLiteral<TFrom> | TFrom
strict?: TStrict
}
type UseSearchOptions<TFrom, TStrict extends boolean, TSearch, TSelected> = StrictOrFrom<
TFrom,
TStrict
> & {
select?: (search: TSearch) => TSelected
}
type Expand<T> = T extends object
? T extends infer O
? O extends Function
? O
: { [K in keyof O]: O[K] }
: never
: T
/**
* Enhanced useSearch hook that optionally disables strict mode if running in Storybook.
*/
export function usePtSearch<
TRouteTree extends AnyRoute = RegisteredRouter["routeTree"],
TFrom extends RouteIds<TRouteTree> = RouteIds<TRouteTree>,
TStrict extends boolean = true,
TSearch = TStrict extends false
? FullSearchSchema<TRouteTree>
: Expand<RouteById<TRouteTree, TFrom>["types"]["fullSearchSchema"]>,
TSelected = TSearch
>(opts: UseSearchOptions<TFrom, TStrict, TSearch, TSelected>): TSelected {
// Create a modified options object if in Storybook mode
// Call the useSearch hook with the appropriate options
return useSearch(isStorybook ? { strict: false } : opts)
} |
Beta Was this translation helpful? Give feedback.
-
Here's what worked for me on a project with file-based routing. I simply create a router with a route for the story, and depending on the route I render a passthrough layout route if there's any passless (layout) on the route. It defaults to an index route for stories of components with no strict route APIs. I do on this component: export function StoryRouterProvider(
props: PropsWithChildren<{
parameters: StoryContext["parameters"];
}>,
): ReactElement {
const { children, parameters } = props;
let storyRoutePath = "/";
let layoutRouteId;
// If the route parameter exists, extract any layout segment from the path,
// if there's any layout segment, a layout route will be created as a parent of the story route
if (parameters["route"]) {
const pathSegments = parameters["route"].id.split("/").filter((e) => e);
const layoutSegments = pathSegments.slice(
0,
pathSegments.findLastIndex((s) => s.startsWith("_")) + 1,
);
layoutRouteId = layoutSegments.join("/") || undefined;
storyRoutePath = "/" + pathSegments.slice(layoutSegments.length).join("/");
}
const rootRoute = createRootRoute({});
let layout =
layoutRouteId &&
createRoute({
component: Outlet,
id: layoutRouteId,
getParentRoute: () => rootRoute,
});
const storyRoute = createRoute({
component: () => children,
path: storyRoutePath,
getParentRoute: () => (layout ? layout : rootRoute),
});
layout?.addChildren([storyRoute]);
rootRoute.addChildren([layout ? layout : storyRoute]);
const finalPath = parameters["route"]?.params
? interpolatePath({
path: storyRoutePath,
params: parameters["route"].params,
}).interpolatedPath
: storyRoutePath;
const router = createRouter({
history: createMemoryHistory({
initialEntries: [finalPath],
}),
routeTree: rootRoute,
});
return <RouterProvider router={router} defaultComponent={() => children} />;
} Then I wrap the Story decorator on parameters: {
route: {
id: "_authenticated/users/$userId/profile",
params: { userId: 1 },
},
} |
Beta Was this translation helpful? Give feedback.
-
Hey guys, based on the previous answers i came up with a solution that supports the use of hooks, layouts and dynamic parameters. I just wish to find a way to handle navigation flows based on the array entries and the generated route tree like this example with react router and mock service worker: https://github.com/mswjs/msw-storybook-addon/blob/main/packages/docs/src/demos/react-router-react-query/App.stories.tsx export interface RouteData {
path: string;
loader?: Record<string, unknown>;
}
interface InitialEntries {
entry: string;
params?: Record<string, string>;
}
interface RouterParameters {
initialEntries?: InitialEntries[];
routes?: RouteData[];
}
const defaultRoute = [{ path: '/' }];
const defaultInitialEntries = [{ entry: '/', params: undefined }];
export function withTanstackRouter(Story: PartialStoryFn, { parameters }: StoryContext) {
const { initialEntries = defaultInitialEntries, routes = defaultRoute } = parameters?.router || {};
const rootRoute = createRootRoute({});
routes.forEach((route) => {
const { parentRoute, storyRoute } = createRouteConfig(route, Story, rootRoute);
if (parentRoute !== rootRoute) rootRoute.addChildren([parentRoute]);
parentRoute.addChildren([storyRoute]);
});
// Process entries with dynamic parameters
const processedEntries = initialEntries.map(({ entry, params }) => {
if (params) {
return interpolatePath({
path: entry,
params
}).interpolatedPath;
}
return entry;
});
const router = createRouter({
history: createMemoryHistory({
initialEntries: processedEntries,
initialIndex: 0
}),
routeTree: rootRoute
});
return <RouterProvider router={router}></RouterProvider>;
}
declare module '@storybook/types' {
interface Parameters {
router?: RouterParameters;
}
}
/**
* Creates route configuration for Storybook stories
* @param {RouteData} route - Full story path pattern and loader if exists
* @param {PartialStoryFn} Story - Storybook component
* @param {RootRoute} rootRoute - Application root route
* @returns {Object} Contains layout and story routes
*/
export function createRouteConfig(route: RouteData, Story: PartialStoryFn, rootRoute: RootRoute) {
const { layoutRouteId, storyRoutePath } = parseRoutePath(route.path);
const parentRoute = layoutRouteId
? createRoute({
id: layoutRouteId,
component: Outlet,
getParentRoute: () => rootRoute
})
: rootRoute;
const storyRoute = createRoute({
component: Story,
path: `${storyRoutePath}`,
getParentRoute: () => parentRoute,
loader: () => route.loader
});
return { parentRoute, storyRoute };
}
/**
* Finds the index of the last layout segment in a route.
* @param segments - Array of route segments.
* @returns The index of the last layout found, or -1 if no layouts are present.
*/
const findLastLayoutIndex = (segments: string[]): number => {
let lastLayoutIndex = -1;
for (let i = segments.length - 1; i >= 0; i--) {
if (segments[i].startsWith('_')) {
lastLayoutIndex = i;
break;
}
}
return lastLayoutIndex;
};
/**
* Parses a route and separates layout and story components.
* @param path - Route to parse.
* @returns Object with the layout route or undefined, and the story route path.
* @throws Error if the path is invalid.
*/
export function parseRoutePath(path: string): RouteParseResult {
if (!path || typeof path !== 'string') {
throw new Error('Invalid path: must be a non-empty string');
}
const hasTrailingSlash = path.endsWith('/');
const pathSegments = path.split('/').filter(Boolean);
const layoutMarkerIndex = findLastLayoutIndex(pathSegments);
const hasLayout = layoutMarkerIndex !== -1;
const boundaryIndex = layoutMarkerIndex + 1;
const layoutSegments = hasLayout ? pathSegments.slice(0, boundaryIndex) : [];
const storySegments = hasLayout ? pathSegments.slice(boundaryIndex) : [...pathSegments];
const layoutRouteId = layoutSegments.length ? `/${layoutSegments.join('/')}` : undefined;
let storyRoutePath = storySegments.length ? `/${storySegments.join('/')}` : '/';
if (hasTrailingSlash && storyRoutePath !== '/') storyRoutePath += '/';
return {
layoutRouteId,
storyRoutePath
};
} UsageBasic Routeexport const Basic = {
parameters: {
router: {
routes: [{ path: '/dashboard' }],
initialEntries: [{ entry: '/dashboard' }]
}
}
}; Route with Dynamic Parametersexport const WithParams = {
parameters: {
router: {
routes: [{ path: '/users/:id' }],
initialEntries: [
{
entry: '/users/:id',
params: { id: '123' }
}
]
}
}
}; Route with Dynamic Parameters and loaderexport const WithParams = {
parameters: {
router: {
routes: [
{
path: '/_layout/users/:id',
loader: {
user: {id: '123', name:'Lorem Ipsum'}
}
}
],
initialEntries: [
{
entry: '/users/:id',
params: { id: '123' }
}
]
}
}
}; |
Beta Was this translation helpful? Give feedback.
-
import { Decorator } from '@storybook/react';
import {
createRootRoute,
createRouter,
RouterProvider
} from '@tanstack/react-router';
export const RouterDecorator: Decorator = (Story) => {
const rootRoute = createRootRoute({
component: () => <Story />
});
const routeTree = rootRoute;
const router = createRouter({
routeTree
});
return <RouterProvider router={router} />;
}; |
Beta Was this translation helpful? Give feedback.
-
I just decided to load the entire routeTree 🤷 import type { Meta, StoryObj } from '@storybook/react-vite';
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import { expect, userEvent, within } from 'storybook/test';
import { routeTree } from '../../routeTree.gen';
type Args = {
initialEntries: string[];
initialIndex: number;
};
function Routes({ initialEntries, initialIndex }: Args) {
const router = createRouter({
history: createMemoryHistory({ initialEntries, initialIndex }),
routeTree,
});
return <RouterProvider router={router} />;
}
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
title: 'Routes/Enroll',
component: Routes,
args: {
initialIndex: 0,
},
} satisfies Meta<typeof Routes>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Success: Story = {
args: {
initialEntries: ['/enroll'],
},
async play({ canvasElement }) {
const { findByRole, findByText } = within(canvasElement);
await userEvent.type(await findByRole('textbox', { name: 'Enrollment code' }), '1234{enter}');
await expect(await findByText('Computer successfully enrolled:')).toBeInTheDocument();
},
}; Definitely is a bit more integration testing-ish, but I can just set the proper intitialEntries and go from there. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Hi there,
We're currently using React-Router and considering migrating to Tanstack Router...
We use Storybook a lot and we needed to use an add-on to enable React Router to work with Storybook.
Is this also the case with Tanstack Router? and if so does such an add-on exist yet?
Thanks!
Beta Was this translation helpful? Give feedback.
All reactions