diff --git a/code/core/src/cli/AddonVitestService.constants.ts b/code/core/src/cli/AddonVitestService.constants.ts index e96d6833813a..92804850e965 100644 --- a/code/core/src/cli/AddonVitestService.constants.ts +++ b/code/core/src/cli/AddonVitestService.constants.ts @@ -11,4 +11,5 @@ export const SUPPORTED_FRAMEWORKS: readonly SupportedFramework[] = [ SupportedFramework.SVELTEKIT, SupportedFramework.VUE3_VITE, SupportedFramework.WEB_COMPONENTS_VITE, + SupportedFramework.TANSTACK_REACT, ]; diff --git a/code/core/src/cli/projectTypes.ts b/code/core/src/cli/projectTypes.ts index 2c35cdfa9347..7ecc7e7ebaa6 100644 --- a/code/core/src/cli/projectTypes.ts +++ b/code/core/src/cli/projectTypes.ts @@ -8,6 +8,7 @@ export enum ProjectType { PREACT = 'preact', QWIK = 'qwik', REACT = 'react', + TANSTACK_REACT = 'tanstack_react', REACT_NATIVE = 'react_native', REACT_NATIVE_AND_RNW = 'react_native_and_rnw', REACT_NATIVE_WEB = 'react_native_web', diff --git a/code/core/src/common/utils/framework.ts b/code/core/src/common/utils/framework.ts index cb5e08434af6..f7af0719bf69 100644 --- a/code/core/src/common/utils/framework.ts +++ b/code/core/src/common/utils/framework.ts @@ -18,6 +18,7 @@ export const frameworkToRenderer: Record< [SupportedFramework.SOLID]: SupportedRenderer.SOLID, [SupportedFramework.SVELTE_VITE]: SupportedRenderer.SVELTE, [SupportedFramework.SVELTEKIT]: SupportedRenderer.SVELTE, + [SupportedFramework.TANSTACK_REACT]: SupportedRenderer.REACT, [SupportedFramework.VUE3_VITE]: SupportedRenderer.VUE3, [SupportedFramework.WEB_COMPONENTS_VITE]: SupportedRenderer.WEB_COMPONENTS, [SupportedFramework.REACT_RSBUILD]: SupportedRenderer.REACT, @@ -52,6 +53,7 @@ export const frameworkToBuilder: Record = [SupportedFramework.SERVER_WEBPACK5]: SupportedBuilder.WEBPACK5, [SupportedFramework.SVELTE_VITE]: SupportedBuilder.VITE, [SupportedFramework.SVELTEKIT]: SupportedBuilder.VITE, + [SupportedFramework.TANSTACK_REACT]: SupportedBuilder.VITE, [SupportedFramework.VUE3_VITE]: SupportedBuilder.VITE, [SupportedFramework.WEB_COMPONENTS_VITE]: SupportedBuilder.VITE, [SupportedFramework.QWIK]: SupportedBuilder.VITE, diff --git a/code/core/src/common/utils/get-storybook-info.ts b/code/core/src/common/utils/get-storybook-info.ts index 7b5c44c23189..236508edb476 100644 --- a/code/core/src/common/utils/get-storybook-info.ts +++ b/code/core/src/common/utils/get-storybook-info.ts @@ -51,6 +51,7 @@ export const frameworkPackages: Record = { '@storybook/nextjs-vite': SupportedFramework.NEXTJS_VITE, '@storybook/react-native-web-vite': SupportedFramework.REACT_NATIVE_WEB_VITE, '@storybook/web-components-vite': SupportedFramework.WEB_COMPONENTS_VITE, + '@storybook/tanstack-react': SupportedFramework.TANSTACK_REACT, // community (outside of monorepo) 'storybook-framework-qwik': SupportedFramework.QWIK, 'storybook-solidjs-vite': SupportedFramework.SOLID, diff --git a/code/core/src/common/versions.ts b/code/core/src/common/versions.ts index 971105f5604f..884ccaaf099d 100644 --- a/code/core/src/common/versions.ts +++ b/code/core/src/common/versions.ts @@ -37,6 +37,7 @@ export default { '@storybook/preset-server-webpack': '10.4.0-alpha.16', '@storybook/html': '10.4.0-alpha.16', '@storybook/preact': '10.4.0-alpha.16', + '@storybook/tanstack-react': '10.4.0-alpha.16', '@storybook/react': '10.4.0-alpha.16', '@storybook/server': '10.4.0-alpha.16', '@storybook/svelte': '10.4.0-alpha.16', diff --git a/code/core/src/types/modules/frameworks.ts b/code/core/src/types/modules/frameworks.ts index e48771ceb2e1..f09694bd5181 100644 --- a/code/core/src/types/modules/frameworks.ts +++ b/code/core/src/types/modules/frameworks.ts @@ -13,6 +13,7 @@ export enum SupportedFramework { SERVER_WEBPACK5 = 'server-webpack5', SVELTE_VITE = 'svelte-vite', SVELTEKIT = 'sveltekit', + TANSTACK_REACT = 'tanstack-react', VUE3_VITE = 'vue3-vite', WEB_COMPONENTS_VITE = 'web-components-vite', // COMMUNITY diff --git a/code/frameworks/tanstack-react/README.md b/code/frameworks/tanstack-react/README.md new file mode 100644 index 000000000000..32dd7012dd63 --- /dev/null +++ b/code/frameworks/tanstack-react/README.md @@ -0,0 +1,7 @@ +# Storybook for TanStack (React + Vite) + +Develop, document, and test UI components in isolation with built-in TanStack Router and TanStack Query support. + +See [documentation](https://storybook.js.org/docs/get-started?ref=readme) for installation instructions, usage examples, APIs, and more. + +Learn more about Storybook at [storybook.js.org](https://storybook.js.org/?ref=readme). diff --git a/code/frameworks/tanstack-react/build-config.ts b/code/frameworks/tanstack-react/build-config.ts new file mode 100644 index 000000000000..398558367a6d --- /dev/null +++ b/code/frameworks/tanstack-react/build-config.ts @@ -0,0 +1,41 @@ +import type { BuildEntries } from '../../../scripts/build/utils/entry-utils.ts'; + +const config: BuildEntries = { + entries: { + browser: [ + { + exportEntries: ['.'], + entryPoint: './src/index.ts', + }, + { + exportEntries: ['./preview'], + entryPoint: './src/preview.tsx', + }, + { + exportEntries: ['./react-router'], + entryPoint: './src/export-mocks/react-router.ts', + external: ['@tanstack/react-router'], + }, + { + exportEntries: ['./start-storage-context'], + entryPoint: './src/export-mocks/start-storage-context.ts', + }, + { + exportEntries: ['./start'], + entryPoint: './src/export-mocks/start.ts', + }, + ], + node: [ + { + exportEntries: ['./preset'], + entryPoint: './src/preset.ts', + }, + { + exportEntries: ['./node'], + entryPoint: './src/node/index.ts', + }, + ], + }, +}; + +export default config; diff --git a/code/frameworks/tanstack-react/package.json b/code/frameworks/tanstack-react/package.json new file mode 100644 index 000000000000..acc8961591ec --- /dev/null +++ b/code/frameworks/tanstack-react/package.json @@ -0,0 +1,112 @@ +{ + "name": "@storybook/tanstack-react", + "version": "10.4.0-alpha.16", + "description": "Storybook for TanStack (React, Vite): Router and Start ready Storybook framework", + "keywords": [ + "storybook", + "storybook-framework", + "tanstack", + "tanstack-router", + "tanstack-start", + "react", + "vite", + "component", + "components" + ], + "homepage": "https://github.com/storybookjs/storybook/tree/next/code/frameworks/tanstack-react", + "bugs": { + "url": "https://github.com/storybookjs/storybook/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/storybookjs/storybook.git", + "directory": "code/frameworks/tanstack-react" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "code": "./src/index.ts", + "default": "./dist/index.js" + }, + "./node": { + "types": "./dist/node/index.d.ts", + "code": "./src/node/index.ts", + "default": "./dist/node/index.js" + }, + "./package.json": "./package.json", + "./preset": { + "types": "./dist/preset.d.ts", + "code": "./src/preset.ts", + "default": "./dist/preset.js" + }, + "./preview": { + "types": "./dist/preview.d.ts", + "code": "./src/preview.tsx", + "default": "./dist/preview.js" + }, + "./react-router": { + "types": "./dist/export-mocks/react-router.d.ts", + "code": "./src/export-mocks/react-router.ts", + "default": "./dist/export-mocks/react-router.js" + }, + "./start": { + "types": "./dist/export-mocks/start.d.ts", + "code": "./src/export-mocks/start.ts", + "default": "./dist/export-mocks/start.js" + }, + "./start-storage-context": { + "types": "./dist/export-mocks/start-storage-context.d.ts", + "code": "./src/export-mocks/start-storage-context.ts", + "default": "./dist/export-mocks/start-storage-context.js" + } + }, + "files": [ + "dist/**/*", + "template/**/*", + "README.md", + "*.js", + "*.d.ts", + "!src/**/*" + ], + "dependencies": { + "@storybook/builder-vite": "workspace:*", + "@storybook/react": "workspace:*", + "@storybook/react-vite": "workspace:*" + }, + "devDependencies": { + "@tanstack/react-router": "^1.168.10", + "@tanstack/react-start": "^1.167.16", + "@tanstack/router-core": "^1.168.9", + "@tanstack/start-client-core": "^1.167.9", + "@types/node": "^22.19.1", + "typescript": "^5.9.3", + "vite": "^7.0.4" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.168.10", + "@tanstack/react-start": "^1.167.16", + "@tanstack/router-core": "^1.168.9", + "@tanstack/start-client-core": "^1.167.9", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "workspace:^", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/start-client-core": { + "optional": true + } + }, + "publishConfig": { + "access": "public" + } +} diff --git a/code/frameworks/tanstack-react/project.json b/code/frameworks/tanstack-react/project.json new file mode 100644 index 000000000000..934373194d43 --- /dev/null +++ b/code/frameworks/tanstack-react/project.json @@ -0,0 +1,10 @@ +{ + "name": "tanstack-react", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "targets": { + "compile": {}, + "check": {} + }, + "tags": ["library"] +} diff --git a/code/frameworks/tanstack-react/src/export-mocks/react-router.ts b/code/frameworks/tanstack-react/src/export-mocks/react-router.ts new file mode 100644 index 000000000000..54205cc200e2 --- /dev/null +++ b/code/frameworks/tanstack-react/src/export-mocks/react-router.ts @@ -0,0 +1,99 @@ +import { createRoute } from '@tanstack/react-router'; +export * from '@tanstack/react-router'; + +import { fn } from 'storybook/test'; +import React from 'react'; +import { useEffect } from 'storybook/internal/preview-api'; + +import { + useNavigate as _useNavigate, + useRouter as _useRouter, + useBlocker as _useBlocker, + useMatch as _useMatch, + useSearch as _useSearch, + useParams as _useParams, + useLocation as _useLocation, + useRouterState as _useRouterState, + useMatchRoute as _useMatchRoute, + useLoaderData as _useLoaderData, + useLoaderDeps as _useLoaderDeps, + useRouteContext as _useRouteContext, + useMatches as _useMatches, + useParentMatches as _useParentMatches, + useChildMatches as _useChildMatches, + useCanGoBack as _useCanGoBack, + useLinkProps as _useLinkProps, +} from '@tanstack/react-router'; +import type { Navigate as _Navigate } from '@tanstack/react-router'; +import { onNavigate } from './spies.ts'; + +// Mock navigation hooks — backed by real implementations so they work in stories +export const useNavigate = fn(_useNavigate).mockName('@tanstack/react-router::useNavigate'); +export const useRouter = fn(_useRouter).mockName('@tanstack/react-router::useRouter'); +export const useBlocker = fn(_useBlocker).mockName('@tanstack/react-router::useBlocker'); +export const useSearch = fn(_useSearch).mockName('@tanstack/react-router::useSearch'); +export const useParams = fn(_useParams).mockName('@tanstack/react-router::useParams'); +export const useLocation = fn(_useLocation).mockName('@tanstack/react-router::useLocation'); +export const useRouterState = fn(_useRouterState).mockName( + '@tanstack/react-router::useRouterState' +); +export const useLoaderData = fn(_useLoaderData).mockName('@tanstack/react-router::useLoaderData'); +export const useLoaderDeps = fn(_useLoaderDeps).mockName('@tanstack/react-router::useLoaderDeps'); +export const useRouteContext = fn(_useRouteContext).mockName( + '@tanstack/react-router::useRouteContext' +); +export const useCanGoBack = fn(_useCanGoBack).mockName('@tanstack/react-router::useCanGoBack'); +export const useLinkProps = fn(_useLinkProps).mockName('@tanstack/react-router::useLinkProps'); + +export const Outlet = () => null; + +export const Navigate: typeof _Navigate = ({ to, href }) => { + useEffect(() => { + onNavigate({ to: (to as string) || href }); + }, [to, href]); + + return null; +}; + +export const Link = ({ + to, + children, + ...props +}: { + to: string; + children?: React.ReactNode; + [key: string]: unknown; +}) => { + const location = useLocation(); + return React.createElement( + 'a', + { + href: to, + onClick: (e: React.MouseEvent) => { + e.preventDefault(); + onNavigate({ to, from: location.href }); + }, + ...props, + }, + children + ); +}; + +/** + * Override createFileRoute from tanstack react router + * because the org `createFileRoute` doesn't set the path in the Route + */ +export function createFileRoute(path: string) { + return (options: any) => { + return createRoute({ + path, + ...options, + isRoot: false, + }).update({ + id: path, + path: path, + fullPath: path, + // any because tanstack router does that + } as any); + }; +} diff --git a/code/frameworks/tanstack-react/src/export-mocks/spies.ts b/code/frameworks/tanstack-react/src/export-mocks/spies.ts new file mode 100644 index 000000000000..b0ce2561f0d9 --- /dev/null +++ b/code/frameworks/tanstack-react/src/export-mocks/spies.ts @@ -0,0 +1,10 @@ +import { fn } from 'storybook/test'; + +export type NavigationEvent = { to?: string; from?: string }; + +/** + * Spy called whenever navigation is attempted (Link click, useNavigate, etc.). + * Navigation is blocked — the story stays on screen — but the spy records + * `{ to, from }` so play functions can assert on it. + */ +export const onNavigate = fn<(event: NavigationEvent) => void>().mockName('navigate'); diff --git a/code/frameworks/tanstack-react/src/export-mocks/start-storage-context.ts b/code/frameworks/tanstack-react/src/export-mocks/start-storage-context.ts new file mode 100644 index 000000000000..c34ee4911a22 --- /dev/null +++ b/code/frameworks/tanstack-react/src/export-mocks/start-storage-context.ts @@ -0,0 +1,60 @@ +import type { StartStorageContext } from '@tanstack/start-storage-context'; + +const START_CONTEXT_SYMBOL = Symbol.for('storybook.tanstack-react.start-storage-context'); + +type BrowserStartGlobals = typeof globalThis & { + __TSR_ROUTER__?: unknown; + __TSS_START_OPTIONS__?: unknown; + [START_CONTEXT_SYMBOL]?: StartStorageContext; +}; + +const browserGlobals = globalThis as BrowserStartGlobals; + +function createFallbackStartContext(): StartStorageContext | undefined { + if ( + browserGlobals.__TSR_ROUTER__ === undefined && + browserGlobals.__TSS_START_OPTIONS__ === undefined + ) { + return undefined; + } + + return { + getRouter: () => browserGlobals.__TSR_ROUTER__ as never, + request: new Request('http://localhost/'), + startOptions: browserGlobals.__TSS_START_OPTIONS__, + contextAfterGlobalMiddlewares: undefined, + executedRequestMiddlewares: new Set(), + }; +} + +export async function runWithStartContext( + context: StartStorageContext, + fn: () => T | Promise +): Promise { + const previousContext = browserGlobals[START_CONTEXT_SYMBOL]; + browserGlobals[START_CONTEXT_SYMBOL] = context; + + try { + return await fn(); + } finally { + if (previousContext) { + browserGlobals[START_CONTEXT_SYMBOL] = previousContext; + } else { + delete browserGlobals[START_CONTEXT_SYMBOL]; + } + } +} + +export function getStartContext(opts?: { + throwIfNotFound?: TThrow; +}): TThrow extends false ? StartStorageContext | undefined : StartStorageContext { + const context = browserGlobals[START_CONTEXT_SYMBOL] ?? createFallbackStartContext(); + + if (!context && opts?.throwIfNotFound !== false) { + throw new Error('No Start context found in Storybook mock.'); + } + + return context as TThrow extends false ? StartStorageContext | undefined : StartStorageContext; +} + +export type { StartStorageContext }; diff --git a/code/frameworks/tanstack-react/src/export-mocks/start.ts b/code/frameworks/tanstack-react/src/export-mocks/start.ts new file mode 100644 index 000000000000..b6c5cfe5e0ad --- /dev/null +++ b/code/frameworks/tanstack-react/src/export-mocks/start.ts @@ -0,0 +1,652 @@ +import React from 'react'; +import { fn } from 'storybook/test'; +import type { createServerFn as _createServerFn } from '@tanstack/start-client-core'; +import { onNavigate } from './spies.ts'; + +export * from '@tanstack/start-client-core'; +export * from '@tanstack/react-start'; + +const START_SERVER_STATE_SYMBOL = Symbol.for('storybook.tanstack-react.start-server.state'); + +type RequestOptions = { + context?: TRegister extends { server: { requestContext: infer TRequestContext } } + ? TRequestContext + : unknown; +}; + +type MockRequestExecutor = ( + request: Request, + opts?: RequestOptions +) => Promise | unknown; + +type HandlerContext = { + request: Request; + responseHeaders: Headers; + router?: unknown; + context?: unknown; + requestContext?: unknown; +}; + +type HandlerCallback = ( + context: HandlerContext & { router?: TRouter } +) => Promise | unknown; + +type SessionConfig = Record; + +type MockSession = Record> = { + data: Partial; + update: ( + update?: Partial | ((prev: Partial) => Partial) + ) => Promise>; + save: () => Promise; + clear: () => Promise; +}; + +type MockServerState = { + request: Request; + responseHeaders: Headers; + responseStatus?: { + code?: number; + text?: string; + }; + cookies: Map; + sessionData: Record; +}; + +type BrowserStartGlobals = typeof globalThis & { + __TSR_ROUTER__?: unknown; + [START_SERVER_STATE_SYMBOL]?: MockServerState; +}; + +const browserGlobals = globalThis as BrowserStartGlobals; + +export const HEADERS = { + TSS_SHELL: 'X-TSS_SHELL', +} as const; + +export const VIRTUAL_MODULES = { + startManifest: 'tanstack-start-manifest:v', + injectedHeadScripts: 'tanstack-start-injected-head-scripts:v', + serverFnResolver: '#tanstack-start-server-fn-resolver', +} as const; + +function createNamedMock) => any>( + name: string, + implementation: T +): T { + return fn(implementation).mockName(`@tanstack/react-start/server::${name}`) as unknown as T; +} + +function createDefaultRequest() { + return new Request('http://localhost/'); +} + +function parseCookieHeader(cookieHeader: string | null) { + const cookies = new Map(); + + if (!cookieHeader) { + return cookies; + } + + for (const segment of cookieHeader.split(';')) { + const [rawName, ...rawValue] = segment.trim().split('='); + + if (!rawName) { + continue; + } + + cookies.set(rawName, decodeURIComponent(rawValue.join('='))); + } + + return cookies; +} + +function serializeCookie(name: string, value: string, options?: Record) { + const parts = [`${name}=${encodeURIComponent(value)}`]; + + if (typeof options?.path === 'string') { + parts.push(`Path=${options.path}`); + } + + if (typeof options?.domain === 'string') { + parts.push(`Domain=${options.domain}`); + } + + if (typeof options?.maxAge === 'number') { + parts.push(`Max-Age=${options.maxAge}`); + } + + if (options?.expires instanceof Date) { + parts.push(`Expires=${options.expires.toUTCString()}`); + } + + if (options?.httpOnly) { + parts.push('HttpOnly'); + } + + if (options?.secure) { + parts.push('Secure'); + } + + if (typeof options?.sameSite === 'string') { + parts.push(`SameSite=${options.sameSite}`); + } + + return parts.join('; '); +} + +function createMockState(request = createDefaultRequest()): MockServerState { + return { + request, + responseHeaders: new Headers(), + responseStatus: undefined, + cookies: parseCookieHeader(request.headers.get('cookie')), + sessionData: {}, + }; +} + +function getState() { + const existingState = browserGlobals[START_SERVER_STATE_SYMBOL]; + + if (existingState) { + return existingState; + } + + const nextState = createMockState(); + browserGlobals[START_SERVER_STATE_SYMBOL] = nextState; + return nextState; +} + +async function withRequestState(request: Request, run: () => Promise | T) { + const previousState = browserGlobals[START_SERVER_STATE_SYMBOL]; + browserGlobals[START_SERVER_STATE_SYMBOL] = createMockState(request); + + try { + return await run(); + } finally { + if (previousState) { + browserGlobals[START_SERVER_STATE_SYMBOL] = previousState; + } else { + delete browserGlobals[START_SERVER_STATE_SYMBOL]; + } + } +} + +function mergeResponseState(response: Response) { + const state = getState(); + const headers = new Headers(response.headers); + + state.responseHeaders.forEach((value, key) => { + if (key.toLowerCase() === 'set-cookie') { + headers.append(key, value); + return; + } + + headers.set(key, value); + }); + + return new Response(response.body, { + status: state.responseStatus?.code ?? response.status, + statusText: state.responseStatus?.text ?? response.statusText, + headers, + }); +} + +function toResponse(result: unknown) { + if (result instanceof Response) { + return mergeResponseState(result); + } + + if (result === undefined || result === null) { + return mergeResponseState(new Response(null)); + } + + if (typeof result === 'string') { + return mergeResponseState(new Response(result)); + } + + return mergeResponseState( + new Response(JSON.stringify(result), { + headers: { 'content-type': 'application/json' }, + }) + ); +} + +function getSessionRecord< + TSessionData extends Record = Record, +>(): MockSession { + const state = getState(); + + return { + data: state.sessionData as Partial, + update: async ( + update?: Partial | ((prev: Partial) => Partial) + ) => { + const nextValue = + typeof update === 'function' ? update(state.sessionData as Partial) : update; + + state.sessionData = { + ...state.sessionData, + ...nextValue, + }; + + return getSessionRecord(); + }, + save: async () => {}, + clear: async () => { + state.sessionData = {}; + }, + } satisfies MockSession; +} + +export function StartServer() { + return null; +} + +export const defineHandlerCallback = createNamedMock( + 'defineHandlerCallback', + (handler: HandlerCallback) => handler +); + +export const defaultStreamHandler = createNamedMock( + 'defaultStreamHandler', + async () => new Response('Storybook Mock', { status: 200 }) +); + +export const defaultRenderHandler = createNamedMock( + 'defaultRenderHandler', + async () => new Response('Storybook Mock', { status: 200 }) +); + +export const requestHandler = createNamedMock( + 'requestHandler', + (handler: MockRequestExecutor) => { + return async (request: Request, requestOpts?: RequestOptions) => + withRequestState(request, async () => toResponse(await handler(request, requestOpts))); + } +); + +export const createRequestHandler = createNamedMock( + 'createRequestHandler', + (handler: MockRequestExecutor) => requestHandler(handler) +); + +export const createStartHandler = createNamedMock( + 'createStartHandler', + ( + cbOrOptions: + | HandlerCallback + | { + handler?: HandlerCallback; + } + ) => { + const handler = typeof cbOrOptions === 'function' ? cbOrOptions : cbOrOptions?.handler; + + return requestHandler(async (request, requestOpts) => { + if (!handler) { + return new Response('Storybook Mock', { status: 200 }); + } + + return handler({ + request, + responseHeaders: getState().responseHeaders, + router: browserGlobals.__TSR_ROUTER__, + context: requestOpts?.context, + requestContext: requestOpts?.context, + }); + }); + } +); + +export const attachRouterServerSsrUtils = createNamedMock( + 'attachRouterServerSsrUtils', + (router: TRouter) => router +); + +export const transformReadableStreamWithRouter = createNamedMock( + 'transformReadableStreamWithRouter', + (stream: TReadableStream) => stream +); + +export const transformPipeableStreamWithRouter = createNamedMock( + 'transformPipeableStreamWithRouter', + (stream: TPipeableStream) => stream +); + +export const getRequest = createNamedMock('getRequest', () => getState().request); + +export const getRequestHeaders = createNamedMock( + 'getRequestHeaders', + () => getState().request.headers +); + +export const getRequestHeader = createNamedMock('getRequestHeader', (name: string) => { + return getState().request.headers.get(name) ?? undefined; +}); + +export const getRequestIP = createNamedMock( + 'getRequestIP', + (opts?: { xForwardedFor?: boolean }) => { + if (!opts?.xForwardedFor) { + return undefined; + } + + return getRequestHeader('x-forwarded-for')?.split(',')[0]?.trim(); + } +); + +export const getRequestHost = createNamedMock( + 'getRequestHost', + (opts?: { xForwardedHost?: boolean }) => { + const host = opts?.xForwardedHost + ? getRequestHeader('x-forwarded-host') + : getRequestHeader('host'); + + return host ?? new URL(getState().request.url).host ?? 'localhost'; + } +); + +export const getRequestUrl = createNamedMock( + 'getRequestUrl', + (opts?: { xForwardedHost?: boolean; xForwardedProto?: boolean }) => { + const url = new URL(getState().request.url); + const forwardedHost = opts?.xForwardedHost ? getRequestHeader('x-forwarded-host') : undefined; + const forwardedProto = + opts?.xForwardedProto === false ? undefined : getRequestHeader('x-forwarded-proto'); + + if (forwardedHost) { + url.host = forwardedHost; + } + + if (forwardedProto) { + url.protocol = `${forwardedProto}:`; + } + + return url; + } +); + +export const getRequestProtocol = createNamedMock( + 'getRequestProtocol', + (opts?: { xForwardedProto?: boolean }) => { + const forwardedProto = + opts?.xForwardedProto === false ? undefined : getRequestHeader('x-forwarded-proto'); + + if (forwardedProto) { + return forwardedProto; + } + + return getRequestUrl().protocol.replace(/:$/, '') || 'http'; + } +); + +export const setResponseHeaders = createNamedMock('setResponseHeaders', (headers: HeadersInit) => { + getState().responseHeaders = new Headers(headers); +}); + +export const getResponseHeaders = createNamedMock( + 'getResponseHeaders', + () => getState().responseHeaders +); + +export const getResponseHeader = createNamedMock('getResponseHeader', (name: string) => { + return getState().responseHeaders.get(name) ?? undefined; +}); + +export const setResponseHeader = createNamedMock( + 'setResponseHeader', + (name: string, value: string | Array) => { + const headers = getState().responseHeaders; + headers.delete(name); + + if (Array.isArray(value)) { + value.forEach((entry) => headers.append(name, entry)); + return; + } + + headers.set(name, value); + } +); + +export const removeResponseHeader = createNamedMock('removeResponseHeader', (name: string) => { + getState().responseHeaders.delete(name); +}); + +export const clearResponseHeaders = createNamedMock( + 'clearResponseHeaders', + (headerNames?: Array) => { + if (!headerNames?.length) { + getState().responseHeaders = new Headers(); + return; + } + + headerNames.forEach((name) => getState().responseHeaders.delete(name)); + } +); + +export const getResponseStatus = createNamedMock('getResponseStatus', () => { + return getState().responseStatus?.code ?? 200; +}); + +export const setResponseStatus = createNamedMock( + 'setResponseStatus', + (code?: number, text?: string) => { + getState().responseStatus = { + code, + text, + }; + } +); + +export const getCookies = createNamedMock('getCookies', () => { + return Object.fromEntries(getState().cookies); +}); + +export const getCookie = createNamedMock('getCookie', (name: string) => { + return getState().cookies.get(name); +}); + +export const setCookie = createNamedMock( + 'setCookie', + (name: string, value: string, options?: Record) => { + const state = getState(); + state.cookies.set(name, value); + state.responseHeaders.append('set-cookie', serializeCookie(name, value, options)); + } +); + +export const deleteCookie = createNamedMock( + 'deleteCookie', + (name: string, options?: Record) => { + const state = getState(); + state.cookies.delete(name); + state.responseHeaders.append( + 'set-cookie', + serializeCookie(name, '', { + ...options, + maxAge: 0, + }) + ); + } +); + +export const useSession = createNamedMock( + 'useSession', + async = Record>( + _config: SessionConfig + ) => getSessionRecord() +); + +export const getSession = createNamedMock( + 'getSession', + async = Record>( + _config: SessionConfig + ) => getSessionRecord() +); + +export const updateSession = createNamedMock( + 'updateSession', + async = Record>( + _config: SessionConfig, + update?: Partial | ((prev: Partial) => Partial) + ) => getSessionRecord().update(update) +); + +export const sealSession = createNamedMock('sealSession', async (_config: SessionConfig) => { + return JSON.stringify(getState().sessionData); +}); + +export const unsealSession = createNamedMock( + 'unsealSession', + async (_config: SessionConfig, sealed: string) => { + try { + return JSON.parse(sealed); + } catch { + return {}; + } + } +); + +export const clearSession = createNamedMock( + 'clearSession', + async (_config: Partial) => { + getState().sessionData = {}; + } +); + +export const getResponse = createNamedMock('getResponse', () => { + return { + status: getState().responseStatus?.code, + statusText: getState().responseStatus?.text, + get headers() { + return getState().responseHeaders; + }, + }; +}); + +export const getValidatedQuery = createNamedMock( + 'getValidatedQuery', + async (schema: { + parse?: (value: Record) => unknown; + safeParse?: (value: Record) => unknown; + ['~standard']?: { + validate?: (value: Record) => Promise | unknown; + }; + }) => { + const query = Object.fromEntries(getRequestUrl().searchParams.entries()); + + if (typeof schema?.parse === 'function') { + return schema.parse(query); + } + + if (typeof schema?.safeParse === 'function') { + const result = await schema.safeParse(query); + + if (result && typeof result === 'object' && 'data' in result) { + return (result as { data: unknown }).data; + } + + return result; + } + + if (typeof schema?.['~standard']?.validate === 'function') { + return schema['~standard'].validate(query); + } + + return query; + } +); + +// ============================================================================ +// Client-side APIs (from @tanstack/react-start and @tanstack/start-client-core) +// ============================================================================ + +export function useServerFn) => Promise>( + serverFn: T +): (...args: Parameters) => ReturnType { + return React.useCallback( + (...args: Parameters) => serverFn(...args) as ReturnType, + [serverFn] + ); +} + +function createMockServerFnBuilder(): any { + const builder = () => createMockServerFnBuilder(); + + let _storedOptions: any; + + builder.options = (opts?: any) => { + _storedOptions = opts; + return builder; + }; + + builder.middleware = () => { + return createMockServerFnBuilder(); + }; + + builder.inputValidator = () => { + return createMockServerFnBuilder(); + }; + + builder.handler = (handlerFn?: (...args: any[]) => any) => { + const mock = fn().mockName('@tanstack/start-client-core::createServerFn.handler()'); + + if (handlerFn) { + mock.mockImplementation(async (opts?: any) => handlerFn(opts)); + } + + return mock; + }; + + (builder as any)._getOptions = () => _storedOptions; + + return builder; +} + +// Override `createServerFn` from start-client-core with our mock version +export const createServerFn: typeof _createServerFn = (options?: any) => { + const builder = createMockServerFnBuilder(); + if (options !== undefined) { + builder.options(options); + } + return builder; +}; + +export const Link = ({ + to, + children, + ...props +}: { + to: string; + children?: React.ReactNode; + [key: string]: unknown; +}) => React.createElement('a', { href: to, ...props }, children); + +export const Navigate = ({ to }: { to: string }) => { + React.useEffect(() => { + onNavigate({ to }); + }, [to]); + + return null; +}; + +export const notFound = () => { + throw new Error('Not found'); +}; + +// TanStack Start server entry +export const createStart = () => ({}); + +// Cookie helpers (client-side simple storage) +const clientCookieStore = new Map(); + +export const clearCookieStore = () => { + clientCookieStore.clear(); +}; + +// Server entry default export +const fetchHandler = async () => new Response('Storybook Mock', { status: 200 }); + +export { fetchHandler as fetch }; + +export default { fetch: fetchHandler }; diff --git a/code/frameworks/tanstack-react/src/index.ts b/code/frameworks/tanstack-react/src/index.ts new file mode 100644 index 000000000000..8d1be2f4436f --- /dev/null +++ b/code/frameworks/tanstack-react/src/index.ts @@ -0,0 +1,178 @@ +import type { ComponentType } from 'react'; + +import type { AddonTypes, InferTypes, PreviewAddon } from 'storybook/internal/csf'; +import type { + Args, + ArgsStoryFn, + ComponentAnnotations, + DecoratorFunction, + Parameters, + ProjectAnnotations, + Renderer, + StoryAnnotations, +} from 'storybook/internal/types'; +import type { RemoveIndexSignature, Simplify, UnionToIntersection } from 'type-fest'; +import type { AnyRoute, FileRoutesByPath } from '@tanstack/react-router'; + +import type { ReactMeta, ReactPreview } from '@storybook/react'; +import { __definePreview } from '@storybook/react'; +import type { ReactTypes } from '@storybook/react'; + +import * as tanstackPreview from './preview.tsx'; +import type { DefaultStoryPath, TanStackParameters, TanStackTypes } from './types.ts'; +import type { IsRoute } from './routing/types.ts'; +import type { ReactRenderer } from '@storybook/react'; + +import type { StoryObj as _StoryObj, Meta as _Meta } from '@storybook/react'; +export * from '@storybook/react'; +export * from './types.ts'; +export type { + CreateStoryRouteOptions, + IsRoute, + StoryRouteFileOptions, + StoryRouteOptions, + RouterParameters, +} from './routing/types.ts'; + +// -- Helper types replicating private types from @storybook/react -- + +/** Extracts and unions all args types from an array of decorators. */ +type DecoratorsArgs = UnionToIntersection< + Decorators extends DecoratorFunction ? TArgs : unknown +>; + +type InferCombinedTypes = ReactTypes & + T & { + args: Simplify< + TArgs & Simplify>> + >; + }; + +// ------- + +export type Preview = ProjectAnnotations< + ReactTypes & TanStackTypes +>; + +export function definePreview< + TRoute extends AnyRoute | undefined = undefined, + const TPath extends DefaultStoryPath = DefaultStoryPath, + Addons extends PreviewAddon[] = [], +>( + preview: { + addons?: Addons; + route?: TRoute; + } & ProjectAnnotations, TPath> & InferTypes> +): TanStackPreview, TRoute> { + // @ts-expect-error passing through addons + return __definePreview({ + ...preview, + addons: [tanstackPreview, ...(preview.addons ?? [])], + }); +} + +/** + * Metadata to configure stories for a component or a TanStack Route. + * + * When `TCmpOrArgs` is a TanStack Route, the `component` field accepts the + * Route object directly and TanStack parameters (params, query, loader, etc.) + * are inferred from the route's types. + */ +export type Meta = + IsRoute extends true + ? Omit, 'component'> & + Partial> + : _Meta; + +/** + * When the meta's `component` is a TanStack Route, the story inherits + * TanStack parameter types for type-safe params/query/loader configuration. + */ +export type StoryObj = [TMetaOrCmpOrArgs] extends [ + { component?: infer Component }, +] + ? IsRoute extends true + ? StoryAnnotations> & Partial> + : _StoryObj & Partial + : IsRoute extends true + ? StoryAnnotations> & + Partial> + : _StoryObj & Partial; + +interface TanStackPreview< + T extends AddonTypes, + TRoute extends AnyRoute | undefined = undefined, +> extends ReactPreview & T> { + type(): TanStackPreview; + + // Overload 1: with route — infers TMetaRoute from the provided route + meta< + TMetaRoute extends AnyRoute, + const TPath extends DefaultStoryPath = DefaultStoryPath, + TArgs extends Args = Args, + Decorators extends DecoratorFunction & T, any> = + DecoratorFunction & T, any>, + TMetaArgs extends Partial & T)['args']> = Partial< + TArgs & (TanStackTypes & T)['args'] + >, + >( + meta: { + render?: ArgsStoryFn< + ReactTypes & TanStackTypes & T, + TArgs & (TanStackTypes & T)['args'] + >; + component?: ComponentType; + decorators?: Decorators | Decorators[]; + args?: TMetaArgs; + parameters?: TanStackParameters & + Parameters & + (ReactTypes & T)['parameters']; + } & Omit< + ComponentAnnotations & T, TArgs>, + 'decorators' | 'component' | 'args' | 'render' | 'parameters' + > + ): ReactMeta< + InferCombinedTypes & T, TArgs, Decorators>, + Omit< + ComponentAnnotations< + InferCombinedTypes & T, TArgs, Decorators> + >, + 'args' + > & { + args: Partial extends TMetaArgs ? {} : TMetaArgs; + } + >; + + // Overload 2: without route — uses the preview-level TRoute + meta< + const TPath extends DefaultStoryPath = DefaultStoryPath, + TArgs extends Args = Args, + Decorators extends DecoratorFunction & T, any> = + DecoratorFunction & T, any>, + TMetaArgs extends Partial & T)['args']> = Partial< + TArgs & (TanStackTypes & T)['args'] + >, + >( + meta: { + render?: ArgsStoryFn< + ReactTypes & TanStackTypes & T, + TArgs & (TanStackTypes & T)['args'] + >; + component?: ComponentType; + decorators?: Decorators | Decorators[]; + args?: TMetaArgs; + parameters?: TanStackParameters & Parameters & (ReactTypes & T)['parameters']; + } & Omit< + ComponentAnnotations & T, TArgs>, + 'decorators' | 'component' | 'args' | 'render' | 'parameters' + > + ): ReactMeta< + InferCombinedTypes & T, TArgs, Decorators>, + Omit< + ComponentAnnotations & T, TArgs, Decorators>>, + 'args' + > & { + args: Partial extends TMetaArgs ? {} : TMetaArgs; + } + >; +} diff --git a/code/frameworks/tanstack-react/src/node/index.ts b/code/frameworks/tanstack-react/src/node/index.ts new file mode 100644 index 000000000000..13060894477d --- /dev/null +++ b/code/frameworks/tanstack-react/src/node/index.ts @@ -0,0 +1,7 @@ +import type { StorybookConfig } from '../types.ts'; + +export function defineMain(config: StorybookConfig) { + return config; +} + +export type { StorybookConfig }; diff --git a/code/frameworks/tanstack-react/src/plugins/module-interception.ts b/code/frameworks/tanstack-react/src/plugins/module-interception.ts new file mode 100644 index 000000000000..b32532b3b772 --- /dev/null +++ b/code/frameworks/tanstack-react/src/plugins/module-interception.ts @@ -0,0 +1,73 @@ +import type { Plugin } from 'vite'; + +const INTERCEPTED_PATTERNS = ['virtual:cloudflare', 'server-entry', 'worker-entry']; +const START_SERVER_MODULES = [ + '@tanstack/react-start', + '@tanstack/react-start/server', + '@tanstack/react-start-server', + '@tanstack/start-server-core', +]; + +export function moduleInterceptionPlugin({ + startMockPath, + startStorageContextMockPath, + routerMockPath, +}: { + startMockPath: string; + startStorageContextMockPath: string; + routerMockPath: string; +}): Plugin { + return { + name: 'storybook:tanstack-react:module-interception', + enforce: 'pre', + resolveId: { + order: 'pre', + handler(id: string, importer: string | undefined) { + // Redirect @tanstack/react-router to our mock, except when + // the importer IS the mock (to avoid a circular alias). + if ( + (id === '@tanstack/react-router' || id.startsWith('@tanstack/react-router/')) && + importer && + !importer.includes('export-mocks') + ) { + return routerMockPath; + } + + if (START_SERVER_MODULES.includes(id) || id === '@tanstack/react-start') { + return startMockPath; + } + + if (id === '@tanstack/start-storage-context') { + return startStorageContextMockPath; + } + + // Intercept virtual/server/worker entries + for (const pattern of INTERCEPTED_PATTERNS) { + if (id.includes(pattern)) { + return startMockPath; + } + } + + return null; + }, + }, + + config() { + return { + optimizeDeps: { + exclude: [ + '@storybook/react', + '@storybook/react/entry-preview', + '@storybook/react/entry-preview-argtypes', + '@storybook/react/entry-preview-docs', + '@storybook/tanstack-react', + '@tanstack/react-start', + '@tanstack/react-start/server', + '@tanstack/react-start-server', + '@tanstack/start-server-core', + ], + }, + }; + }, + }; +} diff --git a/code/frameworks/tanstack-react/src/plugins/server-code-elimination.ts b/code/frameworks/tanstack-react/src/plugins/server-code-elimination.ts new file mode 100644 index 000000000000..fa06dc6da0e1 --- /dev/null +++ b/code/frameworks/tanstack-react/src/plugins/server-code-elimination.ts @@ -0,0 +1,426 @@ +import { transformSync, types as t, type NodePath } from 'storybook/internal/babel'; +import { type Statement } from '@babel/types'; +import type { Plugin } from 'vite'; + +interface TransformState { + code: string; + modified: boolean; + needsFnImport: boolean; +} + +// Same strategy as TanStack's `detectKindsInCode`. +const SERVER_FN_RE = /\bcreateServerFn\b/; +const MIDDLEWARE_RE = /\bcreateMiddleware\b/; +const ISOMORPHIC_FN_RE = /\bcreateIsomorphicFn\b/; +const SERVER_ONLY_FN_RE = /\bcreateServerOnlyFn\b/; +const CLIENT_ONLY_FN_RE = /\bcreateClientOnlyFn\b/; +const ROUTE_FACTORY_RE = + /\b(createFileRoute|createRootRoute|createRootRouteWithContext|createRoute)\b/; + +const ROUTE_FACTORIES = new Set([ + 'createFileRoute', + 'createRootRoute', + 'createRootRouteWithContext', + 'createRoute', +]); + +const ANY_PATTERN_RE = + /\b(createServerFn|createMiddleware|createIsomorphicFn|createServerOnlyFn|createClientOnlyFn|createFileRoute|createRootRoute|createRootRouteWithContext|createRoute)\b/; + +export function serverCodeEliminationPlugin(options: { excludeFiles?: string[] } = {}): Plugin { + const excludeFiles = options.excludeFiles ?? []; + + return { + name: 'storybook:tanstack-react:server-code-elimination', + enforce: 'pre', + + transform: { + // we can fully rely on transform.filter + // and not worry about the handler since tanstack start users are Vite > 8 only + filter: { + id: { + include: [/\.tsx?$/], + exclude: [/node_modules/], + }, + code: ANY_PATTERN_RE, + }, + async handler(code, id) { + // Only process JS/TS files + if (!/\.[mc]?[jt]sx?$/.test(id)) { + return null; + } + + // Skip files explicitly excluded by the caller (e.g. our own export-mocks) + if (excludeFiles.some((excluded) => id.includes(excluded))) { + return null; + } + + if (!ANY_PATTERN_RE.test(code)) { + return null; + } + + const state: TransformState = { code, modified: false, needsFnImport: false }; + + const result = transformSync(code, { + filename: id, + sourceType: 'module', + parserOpts: { + plugins: ['typescript', 'jsx'], + }, + plugins: [() => serverCodeElimination(state)], + sourceMaps: true, + configFile: false, + babelrc: false, + }); + + if (!state.modified || !result?.code) { + return null; + } + + return { + code: result.code, + map: result.map, + }; + }, + }, + }; +} + +// todo make storybook/internal/babel export PluginObj +function serverCodeElimination( + state: TransformState +): NonNullable[1]>['plugins']>[number] { + /** No-op spy for server-side code */ + function sbFnCall() { + state.needsFnImport = true; + return buildFnCall(); + } + + /** Spy wrapping the original implementation for client-side code */ + function sbFnCallWithImpl(impl: import('@babel/types').Expression) { + state.needsFnImport = true; + return buildFnCallWithImpl(impl); + } + + return { + visitor: { + Program(programPath) { + const tanstackImports = collectTanstackImports(programPath.node.body); + + const resolves = (name: string, factory: string) => + resolvesToFactory(tanstackImports, name, factory); + + programPath.traverse({ + CallExpression(path) { + const node = path.node; + + // createFileRoute('/path')({ ..., server: {...} }) → + // createFileRoute('/path')({ ... }) + // createRootRoute({ server: {...} }) → createRootRoute({}) + // createRootRouteWithContext<...>()({ server: {...} }) → ({}) + // createRoute({ server: {...} }) → createRoute({}) + if (ROUTE_FACTORY_RE.test(state.code)) { + const routeOptionsArg = getRouteFactoryOptionsArg(node, tanstackImports); + if (routeOptionsArg && stripServerOption(routeOptionsArg)) { + state.modified = true; + // fall through — no `return`, the call may still match other rules + } + } + + // createServerOnlyFn(fn) → fn() no-op spy + if ( + t.isIdentifier(node.callee) && + resolves(node.callee.name, 'createServerOnlyFn') && + SERVER_ONLY_FN_RE.test(state.code) + ) { + path.replaceWith(sbFnCall()); + state.modified = true; + return; + } + + // createClientOnlyFn(fn) → fn(originalImpl) spy wrapping original + if ( + t.isIdentifier(node.callee) && + resolves(node.callee.name, 'createClientOnlyFn') && + CLIENT_ONLY_FN_RE.test(state.code) + ) { + const innerFn = node.arguments[0]; + if (innerFn && t.isExpression(innerFn)) { + path.replaceWith(sbFnCallWithImpl(innerFn)); + state.modified = true; + } + return; + } + + const methodName = getMethodName(node); + if (!methodName) { + return; + } + + const root = findChainRoot(node); + if (!root) { + return; + } + + // createServerFn()...handler(fn) → replace handler arg with fn() spy + if ( + methodName === 'handler' && + resolves(root.rootName, 'createServerFn') && + SERVER_FN_RE.test(state.code) + ) { + const handlerArg = node.arguments[0]; + if (handlerArg) { + if (t.isIdentifier(handlerArg)) { + const binding = path.scope.getBinding(handlerArg.name); + if (binding && binding.referencePaths.length === 1) { + binding.path.remove(); + } + } + node.arguments[0] = sbFnCall(); + } + state.modified = true; + return; + } + + // createMiddleware()...server(fn) / .inputValidator(fn) → strip call + if (resolves(root.rootName, 'createMiddleware') && MIDDLEWARE_RE.test(state.code)) { + if (methodName === 'server' || methodName === 'inputValidator') { + if (t.isMemberExpression(path.node.callee)) { + path.replaceWith(path.node.callee.object); + state.modified = true; + } + } + return; + } + + // createIsomorphicFn()...client(fn) → fn(originalImpl) spy wrapping original + // createIsomorphicFn()...server(fn) (no .client) → fn() no-op spy + if ( + resolves(root.rootName, 'createIsomorphicFn') && + ISOMORPHIC_FN_RE.test(state.code) + ) { + if (methodName === 'client') { + const innerFn = node.arguments[0]; + if (innerFn && t.isExpression(innerFn)) { + path.replaceWith(sbFnCallWithImpl(innerFn)); + state.modified = true; + } + return; + } + + if (methodName === 'server') { + const parent = path.parent; + if (!t.isMemberExpression(parent) || !t.isCallExpression(path.parentPath?.parent)) { + path.replaceWith(sbFnCall()); + state.modified = true; + } + } + return; + } + }, + }); + + if (!state.modified) { + return; + } + + if (state.needsFnImport) { + programPath.node.body.unshift(buildFnImport()); + } + + eliminateDeadImports(programPath); + }, + }, + }; +} + +/** Build `import { fn as __sb_fn } from 'storybook/test'` */ +function buildFnImport() { + return t.importDeclaration( + [t.importSpecifier(t.identifier('__sb_fn'), t.identifier('fn'))], + t.stringLiteral('storybook/test') + ); +} + +/** Build `__sb_fn()` — no-op spy for server code */ +function buildFnCall() { + return t.callExpression(t.identifier('__sb_fn'), []); +} + +/** Build `__sb_fn(impl)` — spy wrapping the original implementation for client code */ +function buildFnCallWithImpl(impl: import('@babel/types').Expression) { + return t.callExpression(t.identifier('__sb_fn'), [impl]); +} + +/** + * Collect import bindings from TanStack packages. + * Returns a map from local name → original imported name. + */ +function collectTanstackImports(body: Statement[]) { + const imports = new Map(); + for (const node of body) { + if (!t.isImportDeclaration(node)) { + continue; + } + const src = node.source.value; + if ( + !src.includes('@tanstack/') && + !src.includes('export-mocks') && + !src.includes('@storybook/tanstack-react') + ) { + continue; + } + for (const spec of node.specifiers) { + if (t.isImportSpecifier(spec)) { + const importedName = t.isIdentifier(spec.imported) + ? spec.imported.name + : spec.imported.value; + imports.set(spec.local.name, importedName); + } + } + } + return imports; +} + +/** + * Check if a local identifier resolves to a known TanStack factory, + * either directly or via the import map. + */ +function resolvesToFactory( + imports: Map, + name: string, + factoryName: string +): boolean { + return name === factoryName || imports.get(name) === factoryName; +} + +/** + * Walk up a method chain to find the root call expression. + * e.g. `createServerFn().middleware(...).handler(fn)` → `createServerFn()`. + */ +function findChainRoot( + node: ReturnType +): { rootCall: ReturnType; rootName: string } | null { + let current: ReturnType = node; + + while (true) { + const callee = current.callee; + if (t.isIdentifier(callee)) { + return { rootCall: current, rootName: callee.name }; + } + if (t.isMemberExpression(callee) && t.isCallExpression(callee.object)) { + current = callee.object; + continue; + } + return null; + } +} + +/** Get the method name from `expr.method(...)` → `"method"` */ +function getMethodName(node: ReturnType): string | null { + if (t.isMemberExpression(node.callee) && t.isIdentifier(node.callee.property)) { + return node.callee.property.name; + } + return null; +} + +/** + * Extract the options object argument of a route factory call, if the call + * matches one of the supported TanStack route factories: + * + * - `createRootRoute(opts)` + * - `createRoute(opts)` + * - `createFileRoute('/path')(opts)` + * - `createRootRouteWithContext<...>()(opts)` + * + * Returns the `ObjectExpression` for `opts`, or `null` when the call doesn't + * match or the argument isn't an inline object literal. + */ +function getRouteFactoryOptionsArg( + node: ReturnType, + imports: Map +): import('@babel/types').ObjectExpression | null { + const factoryName = getRouteFactoryName(node, imports); + if (!factoryName) { + return null; + } + const optionsArg = node.arguments[0]; + return t.isObjectExpression(optionsArg) ? optionsArg : null; +} + +function getRouteFactoryName( + node: ReturnType, + imports: Map +): string | null { + // Direct call: createRoute(...) / createRootRoute(...) + if (t.isIdentifier(node.callee)) { + const resolved = imports.get(node.callee.name) ?? node.callee.name; + return ROUTE_FACTORIES.has(resolved) ? resolved : null; + } + // Curried call: createFileRoute('/path')(...) / + // createRootRouteWithContext<...>()(...) + if (t.isCallExpression(node.callee) && t.isIdentifier(node.callee.callee)) { + const calleeName = node.callee.callee.name; + const resolved = imports.get(calleeName) ?? calleeName; + return ROUTE_FACTORIES.has(resolved) ? resolved : null; + } + return null; +} + +/** + * Drop the `server` property from a route options object literal. Used to + * strip server-only handlers (e.g. `server: { handler: ... }`) so their + * imports become unreferenced and are removed by the dead-import pass. + * + * Returns true when at least one property was removed. + */ +function stripServerOption(options: import('@babel/types').ObjectExpression): boolean { + const initialLength = options.properties.length; + options.properties = options.properties.filter((prop) => { + if (!t.isObjectProperty(prop) || prop.computed) { + return true; + } + const key = prop.key; + const keyName = t.isIdentifier(key) ? key.name : undefined; + return keyName !== 'server'; + }); + return options.properties.length !== initialLength; +} + +/** + * Remove import specifiers that are no longer referenced in the AST. + * Drops entire import declarations when all specifiers are unreferenced. + */ +function eliminateDeadImports(programPath: NodePath) { + const referencedIdentifiers = new Set(); + + programPath.traverse({ + enter(path) { + const { node } = path; + if (!t.isIdentifier(node) || path.isBindingIdentifier()) { + return; + } + // Skip identifiers that live inside an import declaration's specifiers. + // Both `imported` and `local` are Identifiers, but neither should count + // as a real "use" of the binding for the purposes of dead-import removal. + if (path.findParent((p) => p.isImportDeclaration())) { + return; + } + referencedIdentifiers.add(node.name); + }, + }); + + programPath.traverse({ + ImportDeclaration(path) { + const specifiers = path.node.specifiers.filter((spec) => + referencedIdentifiers.has(spec.local.name) + ); + + if (specifiers.length === 0) { + path.remove(); + } else if (specifiers.length !== path.node.specifiers.length) { + path.node.specifiers = specifiers; + } + }, + }); +} diff --git a/code/frameworks/tanstack-react/src/plugins/server-only-stub.ts b/code/frameworks/tanstack-react/src/plugins/server-only-stub.ts new file mode 100644 index 000000000000..05823a03f2c2 --- /dev/null +++ b/code/frameworks/tanstack-react/src/plugins/server-only-stub.ts @@ -0,0 +1,123 @@ +import { babelParse as parse, types as t } from 'storybook/internal/babel'; + +import type { Plugin } from 'vite'; + +const SERVER_FILE_RE = /\.server\.(?:[mc]?[jt]sx?)$/; + +interface ServerOnlyStubOptions { + /** Additional regex patterns that should also be stubbed. */ + extraPatterns?: RegExp[]; +} + +/** + * Replaces TanStack Start `*.server.{ts,js,tsx,jsx,mts,mjs,cts,cjs}` modules + * (and any extra patterns) with no-op stubs in the browser bundle. + */ +export function serverOnlyStubPlugin(options: ServerOnlyStubOptions = {}): Plugin { + const patterns = [SERVER_FILE_RE, ...(options.extraPatterns ?? [])]; + + return { + name: 'storybook:tanstack-react:server-only-stub', + enforce: 'pre', + transform: { + filter: { + id: { + // The `id` filter is matched against the full resolved id, so the + // pattern matches both `src/utils/foo.server.ts` and any other + // `.server.*` file. We additionally re-check inside the handler in + // case Vite ever changes how filters are applied. + include: patterns, + exclude: [/node_modules/], + }, + }, + handler(code, id) { + if (!patterns.some((re) => re.test(id))) { + return null; + } + + const exports = collectExports(code, id); + return { + code: buildStubCode(exports, id), + map: { mappings: '' }, + }; + }, + }, + }; +} + +interface ExportInfo { + named: Set; + hasDefault: boolean; +} + +function collectExports(code: string, id: string): ExportInfo { + const named = new Set(); + let hasDefault = false; + + let ast: ReturnType; + try { + ast = parse(code); + } catch { + return { named, hasDefault }; + } + + for (const node of ast.program.body) { + if (t.isExportDefaultDeclaration(node)) { + hasDefault = true; + continue; + } + if (t.isExportAllDeclaration(node)) { + // `export * from '...'` — we can't enumerate; ignore to be safe. + // Consumers of unknown re-exports will get `undefined`, which is + // acceptable for server-only modules. + continue; + } + if (t.isExportNamedDeclaration(node)) { + const decl = node.declaration; + if (decl) { + if (t.isVariableDeclaration(decl)) { + for (const declarator of decl.declarations) { + if (t.isIdentifier(declarator.id)) { + named.add(declarator.id.name); + } + } + } else if ((t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) && decl.id?.name) { + named.add(decl.id.name); + } + } + for (const spec of node.specifiers) { + if (t.isExportSpecifier(spec)) { + const exportedName = t.isIdentifier(spec.exported) + ? spec.exported.name + : spec.exported.value; + if (exportedName !== 'default') { + named.add(exportedName); + } else { + hasDefault = true; + } + } + } + } + } + + return { named, hasDefault }; +} + +function buildStubCode(exports: ExportInfo, id: string): string { + const label = JSON.stringify(id); + const namedExports = [...exports.named] + .map((name) => `export const ${name} = __sb_serverOnly(${JSON.stringify(name)});`) + .join('\n'); + const defaultExport = exports.hasDefault ? `export default __sb_serverOnly('default');` : ''; + + return `import { fn as __sb_fn } from 'storybook/test'; +function __sb_serverOnly(name) { + return __sb_fn(() => { + throw new Error( + \`[storybook] Tried to call server-only export "\${name}" from \${${label}} in the browser.\` + ); + }).mockName(name); +} +${namedExports} +${defaultExport}`.trim(); +} diff --git a/code/frameworks/tanstack-react/src/preset.ts b/code/frameworks/tanstack-react/src/preset.ts new file mode 100644 index 000000000000..3d8ccbc44663 --- /dev/null +++ b/code/frameworks/tanstack-react/src/preset.ts @@ -0,0 +1,71 @@ +import { fileURLToPath } from 'node:url'; + +import type { PresetProperty } from 'storybook/internal/types'; +import { dirname } from 'pathe'; +import type { StorybookConfigVite } from '@storybook/builder-vite'; +import { viteFinal as reactViteFinal } from '@storybook/react-vite/preset'; +import { serverCodeEliminationPlugin } from './plugins/server-code-elimination.ts'; +import { serverOnlyStubPlugin } from './plugins/server-only-stub.ts'; +import { moduleInterceptionPlugin } from './plugins/module-interception.ts'; + +export const core: PresetProperty<'core'> = async (config, options) => { + const framework = await options.presets.apply('framework'); + + return { + ...config, + builder: { + name: fileURLToPath(import.meta.resolve('@storybook/builder-vite')), + options: typeof framework === 'string' ? {} : framework.options.builder || {}, + }, + renderer: fileURLToPath(import.meta.resolve('@storybook/react/preset')), + }; +}; + +export const previewAnnotations: PresetProperty<'previewAnnotations'> = (entry = []) => [ + ...entry, + fileURLToPath(import.meta.resolve('@storybook/tanstack-react/preview')), +]; + +export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, options) => { + const reactConfig = await reactViteFinal(config, options); + + /** + * A custom viteFinal implementation that removes any TanStack Start Vite plugins from the user's + * Vite config, as a workaround for compatibility issues. + * + * This follows the pattern discussed at: https://github.com/storybookjs/storybook/issues/33754 + */ + const isTanStackStartPlugin = (p: unknown): boolean => { + if (Array.isArray(p)) { + return p.some(isTanStackStartPlugin); + } + const pluginRecord = p as Record; + return ( + typeof p === 'object' && + p !== null && + 'name' in pluginRecord && + typeof pluginRecord.name === 'string' && + (pluginRecord.name.startsWith('tanstack-start') || pluginRecord.name.includes('rsc:')) + ); + }; + + const startMockPath = fileURLToPath(import.meta.resolve('./export-mocks/start.js')); + const startStorageContextMockPath = fileURLToPath( + import.meta.resolve('./export-mocks/start-storage-context.js') + ); + const routerMockPath = fileURLToPath( + import.meta.resolve('@storybook/tanstack-react/react-router') + ); + const basePlugins = reactConfig.plugins ?? []; + const plugins = [ + ...basePlugins.filter((p) => !isTanStackStartPlugin(p)), + serverCodeEliminationPlugin({ excludeFiles: [dirname(startMockPath)] }), + serverOnlyStubPlugin(), + moduleInterceptionPlugin({ startMockPath, startStorageContextMockPath, routerMockPath }), + ]; + + return { + ...reactConfig, + plugins, + }; +}; diff --git a/code/frameworks/tanstack-react/src/preview.tsx b/code/frameworks/tanstack-react/src/preview.tsx new file mode 100644 index 000000000000..7a8f041072ec --- /dev/null +++ b/code/frameworks/tanstack-react/src/preview.tsx @@ -0,0 +1,30 @@ +import type { DecoratorFunction, LoaderFunction, Renderer } from 'storybook/internal/types'; +// @ts-expect-error untyped +import { applyDecorators as reactApplyDecorators } from '@storybook/react/entry-preview-docs'; + +import type { TanStackParameters } from './types.ts'; +import { tanstackRouteDecorator } from './routing/decorator.tsx'; +import { routeComponentLoader } from './routing/loader.ts'; + +export const loaders: LoaderFunction[] = [routeComponentLoader]; + +export const applyDecorators = ( + storyFn: Parameters[0], + allDecorators: DecoratorFunction[] +) => + // reorder decorators so `jsxDecorator` is innermost, and `tanstackRouteDecorator` is just outside it + // There is an issue if `tanstackRouteDecorator` is innermost. All stories crashes due to a bug with the jsxDecorator. + reactApplyDecorators(storyFn, [ + tanstackRouteDecorator as DecoratorFunction, + ...allDecorators, + ] as Parameters[1]); + +export const parameters: TanStackParameters = { + tanstack: {}, +}; + +export const optimizeDeps = [ + '@tanstack/react-devtools', + '@tanstack/react-query-devtools', + '@tanstack/react-router-devtools', +]; diff --git a/code/frameworks/tanstack-react/src/routing/decorator.tsx b/code/frameworks/tanstack-react/src/routing/decorator.tsx new file mode 100644 index 000000000000..bc9eb8ca10cc --- /dev/null +++ b/code/frameworks/tanstack-react/src/routing/decorator.tsx @@ -0,0 +1,215 @@ +import React, { type ComponentType } from 'react'; +import type { Decorator } from '@storybook/react-vite'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + type Route, + RouterProvider, + type RootRoute, + interpolatePath, + defaultStringifySearch, +} from '@tanstack/react-router'; +import type { Router, AnyRootRoute, AnyRoute } from '@tanstack/react-router'; +import type { RouterParameters } from './types.ts'; +import { + duplicateRouteTree, + findRootRoute, + resolveStoryLeaf, + type DuplicatedTree, +} from './duplicate-tree.ts'; +import { isRoute } from './utils.ts'; + +interface TanStackRouterStoryProps { + Story: ComponentType; + context: Parameters[1]; +} + +type MockRouterOptions = { + Story: ComponentType; + context: Parameters[1]; + routerContext?: Record; +}; + +interface ResolvedTree { + tree: DuplicatedTree; + leaf: AnyRoute; +} + +const StoryContext = React.createContext<{ Story: ComponentType }>({ Story: () => null }); + +const StoryFromContext: ComponentType = () => { + const { Story } = React.useContext(StoryContext); + return ; +}; + +export const tanstackRouteDecorator: Decorator = (Story, context) => { + return ; +}; + +function TanStackRouterStory({ Story, context }: TanStackRouterStoryProps) { + const routerContext = context.parameters.tanstack?.router?.useRouterContext?.({ + storyContext: context, + }); + + const router = React.useMemo( + () => createStoryRouter({ Story: StoryFromContext, context, routerContext }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [context.id] + ); + + const providerContext = React.useMemo( + () => ({ + ...context.parameters.tanstack?.router?.context, + ...routerContext, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [context.id, routerContext] + ); + + return ( + + + + ); +} + +function createStoryRouter({ + Story, + context, + routerContext, +}: MockRouterOptions): Router { + const routerParameters: RouterParameters = context.parameters.tanstack?.router ?? {}; + const { tree, leaf } = resolveTree(Story, context); + const routeTree = tree.root; + + // Infer the initial path for the router. The priority is: + // 1. `parameters.tanstack.router.path` (explicit path override) + // 2. `leaf.fullPath` (the full path of the resolved leaf route, if it has one) + // 3. The full path of the first child of the route tree, if it exists (e.g. the Story's parent route) + // 4. `/` as a last resort + const inferredPath = + routerParameters?.path || + leaf.fullPath || + (routeTree.children as AnyRoute[] | undefined)?.[0]?.fullPath || + '/'; + + // Interpolate params into the path and append query/search params. + let resolvedPath = interpolatePath({ + path: inferredPath, + params: routerParameters?.params ?? {}, + }).interpolatedPath; + const search = routerParameters?.query ? defaultStringifySearch(routerParameters.query) : ''; + if (search) { + resolvedPath += search; + } + const history = createMemoryHistory({ + initialEntries: [resolvedPath], + }); + + history.replace(resolvedPath); + + return createRouter({ + routeTree, + history, + defaultNotFoundComponent(props) { + return
Route not found: {props.routeId}
; + }, + defaultErrorComponent({ error }) { + return
Story did something wrong : {String(error)}
; + }, + context: routerContext, + }); +} + +function injectStoryComponent( + leaf: AnyRoute, + Story: ComponentType, + overrides: RouterParameters['routeOverrides'], + leafId: string +) { + // Respect explicit user override of the leaf's component. + const userOverride = (overrides as Record | undefined)?.[leafId]; + if (userOverride && 'component' in userOverride && userOverride.component !== undefined) { + return; + } + (leaf as any).update({ component: () => }); +} + +/** + * Resolves the route tree and leaf to render for a given story, based on the following inputs (in order of precedence): + * 1. `parameters.tanstack.router.route` (if it's a Route instance) + * 2. `route` from the story's meta (if it's a Route instance) + * 3. A synthetic root + child created from plain options in `parameters.tanstack.router.route` (if it's a plain object) + * 4. A synthetic root + child with no options. + * + * If the resolved route isn't already a root, walks up its parent chain to find the enclosing root, and duplicates the entire tree under that root to ensure isolation between stories. + * The story component is injected at the resolved leaf route. + * If no route can be resolved from the inputs, creates a synthetic root and injects the story at a child route. + * + */ +function resolveTree(Story: ComponentType, context: Parameters[1]): ResolvedTree { + const metaRoute = context.route as Route | RootRoute | undefined; + const routerParameters: RouterParameters = context.parameters.tanstack?.router ?? {}; + const routerParameterRoute = routerParameters.route; + const routeOverrides = routerParameters.routeOverrides; + + const resolvedRoute = isRoute(routerParameterRoute) + ? routerParameterRoute + : isRoute(metaRoute) + ? metaRoute + : undefined; + + // `resolvedRoute` may already be a `RootRoute` (e.g. the `routeTree` from + // `routeTree.gen.ts`); `findRootRoute` returns it unchanged in that case, + // otherwise it walks up `getParentRoute()` to the enclosing root. + const rootRoute = resolvedRoute ? findRootRoute(resolvedRoute) : undefined; + + if (rootRoute) { + // The user provided a route that's already connected to a root. Use it as the leaf of a duplicated tree. + // Could be a custom RootRoute or the whole application RouteTree. + const tree = duplicateRouteTree(rootRoute, { overrides: routeOverrides }); + const leaf = resolveStoryLeaf(tree, { + path: routerParameters.path as string | undefined, + boundRouteId: resolvedRoute && resolvedRoute !== rootRoute ? resolvedRoute.id : undefined, + }); + + injectStoryComponent(leaf, Story, routeOverrides, leaf.id); + return { tree, leaf }; + } + + if (isRoute(routerParameterRoute)) { + // The user provided a route instance that isn't connected to any root. + // Could be a simple Route import from a Route file. + // Use it as the leaf of a new synthetic root, and duplicate it to ensure any nested children are cloned properly. + const syntheticRoot = createRootRoute( + (routeOverrides as Record | undefined)?.__root__ ?? {} + ); + routerParameterRoute.update({ getParentRoute: () => syntheticRoot } as any); + syntheticRoot.addChildren([routerParameterRoute]); + const tree = duplicateRouteTree(syntheticRoot, { overrides: routeOverrides }); + const leaf = tree.byId.get(routerParameterRoute.id) ?? tree.root; + injectStoryComponent(leaf, Story, routeOverrides, leaf.id); + return { tree, leaf }; + } + + // No route instance — build a synthetic root + child from plain options. + const plainOptions = routerParameterRoute ?? {}; + const syntheticRoot = createRootRoute( + (routeOverrides as Record | undefined)?.__root__ ?? {} + ); + const syntheticChild = createRoute({ + component: () => , + id: 'storybook-story', + ...plainOptions, + getParentRoute: () => syntheticRoot, + } as any); + syntheticRoot.addChildren([syntheticChild]); + + injectStoryComponent(syntheticChild, Story, routeOverrides, syntheticChild.id); + return { + tree: { root: syntheticRoot, byId: new Map([[syntheticChild.id, syntheticChild]]) }, + leaf: syntheticChild, + }; +} diff --git a/code/frameworks/tanstack-react/src/routing/duplicate-tree.ts b/code/frameworks/tanstack-react/src/routing/duplicate-tree.ts new file mode 100644 index 000000000000..aad658f0f22e --- /dev/null +++ b/code/frameworks/tanstack-react/src/routing/duplicate-tree.ts @@ -0,0 +1,183 @@ +import type { AnyRootRoute, AnyRoute } from '@tanstack/react-router'; +import { createRoute, RootRoute, createRootRouteWithContext } from '@tanstack/react-router'; + +import type { RouteTreeOverrides } from './types.ts'; + +const MAX_PARENT_WALK = 50; + +export interface DuplicateRouteTreeOptions { + overrides?: RouteTreeOverrides | undefined; +} + +export interface DuplicatedTree { + root: AnyRootRoute; + byId: Map; +} + +/** + * Walks up `getParentRoute()` from any route to find the enclosing `RootRoute`. + * + * Falls back to the input route if no parent chain leads to a `RootRoute` + * (e.g. when the user constructed a stand-alone route by hand). The walk is + * capped at `MAX_PARENT_WALK` hops to defend against accidental cycles. + */ +export function findRootRoute(route: AnyRoute): AnyRoute | undefined { + let current: AnyRoute | undefined = route; + for (let i = 0; i < MAX_PARENT_WALK && current; i += 1) { + if (current instanceof RootRoute) { + return current; + } + const getParent: () => AnyRoute = current.options?.getParentRoute; + const parent = typeof getParent === 'function' ? getParent() : undefined; + + current = parent; + } +} + +function getOverrideFor( + overrides: RouteTreeOverrides | undefined, + routeId: string +): Record { + if (!overrides) { + return {}; + } + return ((overrides as Record)[routeId] as Record) ?? {}; +} + +function initSourceTree(route: AnyRoute, counter: { i: number }): void { + route.init({ originalIndex: counter.i }); + counter.i += 1; + const children = route.children as AnyRoute[] | undefined; + if (children?.length) { + for (const child of children) { + initSourceTree(child, counter); + } + } +} + +function cloneChild( + oldRoute: AnyRoute, + parent: AnyRoute, + overrides: RouteTreeOverrides | undefined, + 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`). + 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({ + ...rest, + ...override, + getParentRoute: () => parent as any, + } as any); + + byId.set(oldRoute.id, cloned as unknown as AnyRoute); + + const children = (oldRoute as any).children as AnyRoute[] | undefined; + if (children?.length) { + const clonedChildren = children.map((child) => + cloneChild(child, cloned as unknown as AnyRoute, overrides, byId) + ); + (cloned as any).addChildren(clonedChildren); + } + + return cloned as unknown as AnyRoute; +} + +/** + * Recursively clones a TanStack Router tree starting from a `RootRoute`, + * applying per-route option overrides keyed by `route.id`. + * + * Existing route instances are not mutated — every node in the tree is + * rebuilt via `createRootRoute` / `createRoute` so the cloned tree can be + * safely mounted in a story-scoped router without leaking state across + * stories. + */ +export function duplicateRouteTree( + rootRoute: AnyRoute, + { overrides }: DuplicateRouteTreeOptions = {} +): DuplicatedTree { + // init route to get all derived properties populated + initSourceTree(rootRoute, { i: 0 }); + + const byId = new Map(); + 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; + const newRoot = createRootRouteWithContext()({ + ...restRoot, + ...rootOverride, + } as any); + byId.set('__root__', newRoot as unknown as AnyRoute); + + const children = (rootRoute as any).children as AnyRoute[] | undefined; + if (children?.length) { + const clonedChildren = children.map((child) => + cloneChild(child, newRoot as unknown as AnyRoute, overrides, byId) + ); + (newRoot as any).addChildren(clonedChildren); + } + + return { root: newRoot, byId }; +} + +/** + * Picks the route in the cloned tree that should host the ``. + * + * Resolution order: + * + * 1. The route whose `fullPath` exactly matches the explicit `path` parameter. + * 2. The route bound to the story (`boundRouteId`), if it is present in the cloned tree. + * 3. The first top-level child of the root. + * 4. The root itself. + */ +export function resolveStoryLeaf( + tree: DuplicatedTree, + { path, boundRouteId }: { path?: string | undefined; boundRouteId?: string | undefined } +): AnyRoute { + const { root, byId } = tree; + + if (path) { + let bestMatch: AnyRoute | undefined; + let bestMatchLength = -1; + for (const route of byId.values()) { + const fullPath = (route as any).fullPath as string | undefined; + if (fullPath && fullPath === path && fullPath.length > bestMatchLength) { + bestMatch = route; + bestMatchLength = fullPath.length; + } + } + if (bestMatch) { + return bestMatch; + } + } + + if (boundRouteId) { + const bound = byId.get(boundRouteId); + if (bound) { + return bound; + } + } + + const firstChild = (root.children as AnyRoute[] | undefined)?.[0]; + if (firstChild) { + return firstChild; + } + + return root as unknown as AnyRoute; +} diff --git a/code/frameworks/tanstack-react/src/routing/loader.ts b/code/frameworks/tanstack-react/src/routing/loader.ts new file mode 100644 index 000000000000..d1431ccdc232 --- /dev/null +++ b/code/frameworks/tanstack-react/src/routing/loader.ts @@ -0,0 +1,43 @@ +import type { LoaderFunction, Renderer } from 'storybook/internal/types'; +import { type Route, RootRoute } from '@tanstack/react-router'; + +import type { RouterParameters } from './types.ts'; +import { isRoute } from './utils.ts'; + +function getComponentFromRoute(route: InstanceType) { + if (route.options?.component) { + return route.options.component; + } + + if (route instanceof RootRoute) { + return (route.children as Route[] | undefined)?.[0]?.options?.component; + } + + return undefined; +} + +/** + * Loader that extracts the render component from a TanStack Route when the + * story uses `parameters.tanstack.router.route`. + */ +export const routeComponentLoader: LoaderFunction = (context) => { + const routerParameters: RouterParameters = context.parameters.tanstack?.router ?? {}; + const parameterRoute = isRoute(routerParameters.route) ? routerParameters.route : undefined; + + if (!parameterRoute) { + return; + } + + if (!context.component) { + const component = getComponentFromRoute(parameterRoute); + + if (component && !context.component) { + context.component = component; + } + } + + if (!context.route) { + // don't override parameters route with component route, as parameters take priority + context.route = parameterRoute; + } +}; diff --git a/code/frameworks/tanstack-react/src/routing/types.ts b/code/frameworks/tanstack-react/src/routing/types.ts new file mode 100644 index 000000000000..df89fcf0bae0 --- /dev/null +++ b/code/frameworks/tanstack-react/src/routing/types.ts @@ -0,0 +1,187 @@ +import type { AnyRoute, FileRoutesByPath, Register } from '@tanstack/react-router'; +import type { AnyContext, ResolveParams, RouteOptions, RoutesByPath } from '@tanstack/router-core'; +import type { Decorator } from '@storybook/react'; + +/** Union of every registered full path (e.g. `'/' | '/admin/users' | '/$libraryId/$version'`). */ +// @ts-expect-error - router is registered in user land +export type RegisteredFullPath = keyof Register['router']['routesByPath']; +// @ts-expect-error - router is registered in user land +export type IsAppRouteTree = TRoute extends Register['router']['routeTree'] ? true : false; + +// @ts-expect-error - router is registered in user land +export type RegisteredRouteFromRoute = TRoute extends Register['router']['routesById'] + ? true + : false; + +export type IsRoute = T extends AnyRoute + ? true + : T extends FileRoutesByPath[keyof FileRoutesByPath] + ? true + : false; + +export type IsFileRoute = TRoute extends FileRoutesByPath[keyof FileRoutesByPath] + ? true + : false; + +type ExtractAllPathsFromFileRoutes< + TRoute extends FileRoutesByPath[keyof FileRoutesByPath]['preLoaderRoute'] | AnyRoute, +> = TRoute['path']; + +export type StoryRoutePath = + TRoute extends FileRoutesByPath[keyof FileRoutesByPath] + ? ExtractAllPathsFromFileRoutes + : keyof FileRoutesByPath | `/${string}`; + +type StoryRouteSearch = + IsAppRouteTree extends true + ? Record + : TRoute extends FileRoutesByPath[keyof FileRoutesByPath] + ? TRoute['preLoaderRoute'] extends { types: { allSearch: infer A } } + ? A + : never + : Record; + +export type StoryRouteFileOptions = + IsRoute extends true + ? TRoute extends { options: infer O } + ? Pick< + O, + Extract< + keyof O, + | 'loader' + | 'beforeLoad' + | 'validateSearch' + | 'loaderDeps' + | 'context' + | 'params' + | 'head' + | 'search' + | 'parseParams' + | 'context' + > + > + : Pick< + RouteOptions, + | 'loader' + | 'beforeLoad' + | 'validateSearch' + | 'loaderDeps' + | 'context' + | 'params' + | 'head' + | 'search' + | 'parseParams' + | 'context' + > + : Pick< + RouteOptions, + | 'loader' + | 'beforeLoad' + | 'validateSearch' + | 'loaderDeps' + | 'context' + | 'params' + | 'head' + | 'search' + | 'parseParams' + | 'context' + >; + +export type CreateStoryRouteOptions = StoryRouteFileOptions & { + path?: StoryRoutePath; +}; + +export type StoryRouteOptions = + | CreateStoryRouteOptions + | (TRoute extends AnyRoute ? TRoute : AnyRoute); + +/** + * Per-route override options for use inside `RouteTreeOverrides`. + * Users can override `loader`, `beforeLoad`, etc. for a specific route. + */ +export interface RouteOverrideOptions< + TRoute extends FileRoutesByPath[keyof FileRoutesByPath]['preLoaderRoute'] | undefined = undefined, +> { + /** Override the route's loader function. */ + loader?: TRoute extends FileRoutesByPath[keyof FileRoutesByPath]['preLoaderRoute'] + ? TRoute['options']['loader'] | ((ctx: unknown) => Promise | unknown) + : (ctx: unknown) => Promise | unknown; + /** Override the route's beforeLoad function. */ + beforeLoad?: TRoute extends FileRoutesByPath[keyof FileRoutesByPath]['preLoaderRoute'] + ? TRoute['options']['beforeLoad'] | ((ctx: unknown) => Promise | void) + : (ctx: unknown) => Promise | void; + /** Override the route's search params validation. */ + validateSearch?: TRoute extends FileRoutesByPath[keyof FileRoutesByPath]['preLoaderRoute'] + ? TRoute['options']['validateSearch'] | ((search: unknown) => Promise | void) + : (search: unknown) => Promise | void; + /** Override the route's loader dependencies. */ + loaderDeps?: TRoute extends FileRoutesByPath[keyof FileRoutesByPath]['preLoaderRoute'] + ? TRoute['options']['loaderDeps'] | string[] + : string[]; + /** Override the route's context function. */ + context?: TRoute extends FileRoutesByPath[keyof FileRoutesByPath]['preLoaderRoute'] + ? TRoute['options']['context'] | ((ctx: unknown) => Promise | unknown) + : (ctx: unknown) => Promise | unknown; +} + +/** + * A map of route overrides keyed by route ID. + * Each entry can override `loader`, `beforeLoad`, etc. for that route. + * + * @example + * ```ts + * routeOverrides: { + * '/_authed': { beforeLoad: () => {} }, + * '/demo/form/simple/$id': { + * loader: async () => ({ name: 'Mock User' }), + * }, + * } + * ``` + */ +export type RouteTreeOverrides = Partial<{ + [routePath in keyof FileRoutesByPath]: + | RouteOverrideOptions + | undefined; +}>; + +export interface RouterParameters< + TRoute = undefined, + Path extends TRoute extends AnyRoute ? keyof RoutesByPath : RegisteredFullPath = + TRoute extends AnyRoute ? keyof RoutesByPath : keyof FileRoutesByPath, +> { + route?: StoryRouteOptions; + /** + * Path to resolve the story route against. + * Constrained to known registered paths in route tree mode, but can be any string in app route mode (since the user may be passing a custom `route` that doesn't exist in the registered tree). + */ + path?: Path; + /** URL params to interpolate into the path (e.g. `{ id: '42' }` for `/$id`). */ + // @ts-expect-error - route is registered in user land, and we want to allow any params when the user is passing a custom route that doesn't exist in the registered tree + params?: ResolveParams; + /** Search/query params to append to the URL (e.g. `{ tab: 'details' }`). */ + query?: Partial>; + /** + * Override options for specific routes in the app route tree (route tree mode only). + * + * Each key is a route ID (e.g. `'/about'`, `'__root__'`, `'/demo/form/simple/$id'`). + * Values can override `loader`, `beforeLoad`, etc. for that route. + * + * @example + * ```ts + * routeOverrides: { + * '/_authed': { beforeLoad: () => {} }, + * '/demo/form/simple/$id': { + * loader: async () => ({ name: 'Mock User' }), + * }, + * } + * ``` + */ + routeOverrides?: RouteTreeOverrides; + + context?: Record; + + /** + * + */ + useRouterContext?: ({ storyContext }: { storyContext: Parameters[1] }) => AnyContext; +} diff --git a/code/frameworks/tanstack-react/src/routing/utils.ts b/code/frameworks/tanstack-react/src/routing/utils.ts new file mode 100644 index 000000000000..eec58284808d --- /dev/null +++ b/code/frameworks/tanstack-react/src/routing/utils.ts @@ -0,0 +1,5 @@ +import { Route, RootRoute } from '@tanstack/react-router'; + +export function isRoute(value: unknown): value is InstanceType { + return value instanceof Route || value instanceof RootRoute; +} diff --git a/code/frameworks/tanstack-react/src/types.ts b/code/frameworks/tanstack-react/src/types.ts new file mode 100644 index 000000000000..015dd563bbda --- /dev/null +++ b/code/frameworks/tanstack-react/src/types.ts @@ -0,0 +1,64 @@ +import type { CompatibleString } from 'storybook/internal/types'; + +import type { AnyRoute } from '@tanstack/react-router'; +import type { RoutesByPath } from '@tanstack/router-core'; +import type { BuilderOptions } from '@storybook/builder-vite'; +import type { StorybookConfig as StorybookConfigReactVite } from '@storybook/react-vite'; +import type { RegisteredFullPath, RouterParameters } from './routing/types.ts'; + +type FrameworkName = CompatibleString<'@storybook/tanstack-react'>; +type BuilderName = CompatibleString<'@storybook/builder-vite'>; + +export type FrameworkOptions = { + /** Builder options passed through to @storybook/builder-vite. */ + builder?: BuilderOptions; +}; + +type StorybookConfigFramework = { + framework: + | FrameworkName + | { + name: FrameworkName; + options: FrameworkOptions; + }; + core?: StorybookConfigReactVite['core'] & { + builder?: + | BuilderName + | { + name: BuilderName; + options: BuilderOptions; + }; + }; +}; + +/** The interface for Storybook configuration in `main.ts` files. */ +export type StorybookConfig = Omit & + StorybookConfigFramework; + +/** Path constraint mirroring `RouterParameters`'s second generic. */ +export type DefaultStoryPath = TRoute extends AnyRoute + ? keyof RoutesByPath + : RegisteredFullPath; + +export interface TanStackPreviewOptions< + TRoute = undefined, + Path extends DefaultStoryPath = DefaultStoryPath, +> { + /** Router configuration for stories */ + router?: RouterParameters; +} + +export interface TanStackParameters< + TRoute = undefined, + Path extends DefaultStoryPath = DefaultStoryPath, +> { + /** TanStack framework configuration (router integration). */ + tanstack?: TanStackPreviewOptions; +} + +export interface TanStackTypes< + TRoute = undefined, + Path extends DefaultStoryPath = DefaultStoryPath, +> { + parameters: TanStackParameters; +} diff --git a/code/frameworks/tanstack-react/template/cli/ts/Button.stories.ts b/code/frameworks/tanstack-react/template/cli/ts/Button.stories.ts new file mode 100644 index 000000000000..c820473dee7e --- /dev/null +++ b/code/frameworks/tanstack-react/template/cli/ts/Button.stories.ts @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/tanstack-react'; + +import { fn } from 'storybook/test'; + +import { Button } from './Button'; + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta = { + title: 'Example/Button', + component: Button, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout + layout: 'centered', + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], + // More on argTypes: https://storybook.js.org/docs/api/argtypes + argTypes: { + backgroundColor: { control: 'color' }, + }, + // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args + args: { onClick: fn() }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const Primary: Story = { + args: { + primary: true, + label: 'Button', + }, +}; + +export const Secondary: Story = { + args: { + label: 'Button', + }, +}; + +export const Large: Story = { + args: { + size: 'large', + label: 'Button', + }, +}; + +export const Small: Story = { + args: { + size: 'small', + label: 'Button', + }, +}; diff --git a/code/frameworks/tanstack-react/template/cli/ts/Button.tsx b/code/frameworks/tanstack-react/template/cli/ts/Button.tsx new file mode 100644 index 000000000000..b9f10e9397e7 --- /dev/null +++ b/code/frameworks/tanstack-react/template/cli/ts/Button.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import './button.css'; + +export interface ButtonProps { + /** Is this the principal call to action on the page? */ + primary?: boolean; + /** What background color to use */ + backgroundColor?: string; + /** How large should the button be? */ + size?: 'small' | 'medium' | 'large'; + /** Button contents */ + label: string; + /** Optional click handler */ + onClick?: () => void; +} + +/** Primary UI component for user interaction */ +export const Button = ({ + primary = false, + size = 'medium', + backgroundColor, + label, + ...props +}: ButtonProps) => { + const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + return ( + + ); +}; diff --git a/code/frameworks/tanstack-react/template/cli/ts/Header.stories.ts b/code/frameworks/tanstack-react/template/cli/ts/Header.stories.ts new file mode 100644 index 000000000000..7b1634a568d0 --- /dev/null +++ b/code/frameworks/tanstack-react/template/cli/ts/Header.stories.ts @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/tanstack-react'; + +import { fn } from 'storybook/test'; + +import { Header } from './Header'; + +const meta = { + title: 'Example/Header', + component: Header, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + layout: 'fullscreen', + }, + args: { + onLogin: fn(), + onLogout: fn(), + onCreateAccount: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const LoggedIn: Story = { + args: { + user: { + name: 'Jane Doe', + }, + }, +}; + +export const LoggedOut: Story = {}; diff --git a/code/frameworks/tanstack-react/template/cli/ts/Header.tsx b/code/frameworks/tanstack-react/template/cli/ts/Header.tsx new file mode 100644 index 000000000000..1bf981a4251f --- /dev/null +++ b/code/frameworks/tanstack-react/template/cli/ts/Header.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import { Button } from './Button'; +import './header.css'; + +type User = { + name: string; +}; + +export interface HeaderProps { + user?: User; + onLogin?: () => void; + onLogout?: () => void; + onCreateAccount?: () => void; +} + +export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => ( +
+
+
+ + + + + + + +

Acme

+
+
+ {user ? ( + <> + + Welcome, {user.name}! + +
+
+
+); diff --git a/code/frameworks/tanstack-react/template/cli/ts/Page.stories.ts b/code/frameworks/tanstack-react/template/cli/ts/Page.stories.ts new file mode 100644 index 000000000000..de7b13a99786 --- /dev/null +++ b/code/frameworks/tanstack-react/template/cli/ts/Page.stories.ts @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/tanstack-react'; + +import { expect, userEvent, within } from 'storybook/test'; + +import { Route } from './Page'; +import './page.css'; + +const meta = { + title: 'Example/Page', + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + layout: 'fullscreen', + tanstack: { + // Example of providing a custom route for a story. + // The page component is extracted if a component is not set for the story. + // More on mocking Tanstack Router at: https://storybook.js.org/docs/get-started/tanstack-react#routing + router: { + route: Route, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const LoggedOut: Story = {}; + +// More on component testing: https://storybook.js.org/docs/writing-tests/interaction-testing +export const LoggedIn: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const loginButton = canvas.getByRole('button', { name: /Log in/i }); + await expect(loginButton).toBeInTheDocument(); + await userEvent.click(loginButton); + await expect(loginButton).not.toBeInTheDocument(); + + const logoutButton = canvas.getByRole('button', { name: /Log out/i }); + await expect(logoutButton).toBeInTheDocument(); + }, +}; diff --git a/code/frameworks/tanstack-react/template/cli/ts/Page.tsx b/code/frameworks/tanstack-react/template/cli/ts/Page.tsx new file mode 100644 index 000000000000..5dc1f0958842 --- /dev/null +++ b/code/frameworks/tanstack-react/template/cli/ts/Page.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import { createFileRoute } from '@tanstack/react-router'; + +import { Header } from './Header'; + +type User = { + name: string; +}; + +export const Route = createFileRoute('/')({ + component: () => , +}); + +const Page: React.FC = () => { + const [user, setUser] = React.useState(); + + return ( +
+
setUser({ name: 'Jane Doe' })} + onLogout={() => setUser(undefined)} + onCreateAccount={() => setUser({ name: 'Jane Doe' })} + /> + +
+

Pages in Storybook

+

+ We recommend building UIs with a{' '} + + component-driven + {' '} + process starting with atomic components and ending with pages. +

+

+ Render pages with mock data. This makes it easy to build and review page states without + needing to navigate to them in your app. Here are some handy patterns for managing page + data in Storybook: +

+
    +
  • + Use a higher-level connected component. Storybook helps you compose such data from the + "args" of child component stories +
  • +
  • + Assemble data in the page component from your services. You can mock these services out + using Storybook. +
  • +
+

+ Get a guided tutorial on component-driven development at{' '} + + Storybook tutorials + + . Read more in the{' '} + + docs + + . +

+ +
+ Tip Adjust the width of the canvas with the{' '} + + + + + + Viewports addon in the toolbar +
+
+
+ ); +}; diff --git a/code/frameworks/tanstack-react/tsconfig.json b/code/frameworks/tanstack-react/tsconfig.json new file mode 100644 index 000000000000..c749496d9a6e --- /dev/null +++ b/code/frameworks/tanstack-react/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "storybook/internal/*": ["../../lib/cli/core/*"] + }, + "rootDir": "./src" + }, + "extends": "../../tsconfig.json", + "include": ["src/**/*"] +} diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index 05fac134b976..c169252cf1da 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -523,6 +523,46 @@ export const baseTemplates = { }, skipTasks: ['e2e-tests', 'e2e-tests-dev', 'bench', 'vitest-integration'], }, + 'tanstack-react-router/default-ts': { + name: 'TanStack React Router Latest (Vite | TypeScript)', + script: 'npx @tanstack/cli@latest create {{beforeDir}} --tailwind --router-only', + expected: { + framework: '@storybook/tanstack-react', + renderer: '@storybook/react', + builder: '@storybook/builder-vite', + }, + modifications: { + useCsfFactory: true, + extraDependencies: ['prop-types'], + mainConfig: { + framework: '@storybook/tanstack-react', + features: { + experimentalTestSyntax: true, + }, + }, + }, + skipTasks: ['bench'], + }, + 'tanstack-react-start/default-ts': { + name: 'TanStack React Start Latest (Vite | TypeScript)', + script: 'npx @tanstack/cli@latest create {{beforeDir}} --tailwind', + expected: { + framework: '@storybook/tanstack-react', + renderer: '@storybook/react', + builder: '@storybook/builder-vite', + }, + modifications: { + useCsfFactory: true, + extraDependencies: ['prop-types'], + mainConfig: { + framework: '@storybook/tanstack-react', + features: { + experimentalTestSyntax: true, + }, + }, + }, + skipTasks: ['bench'], + }, 'vue3-vite/default-js': { name: 'Vue v3 (Vite | JavaScript)', script: 'npm create vite --yes {{beforeDir}} -- --template vue', @@ -1031,6 +1071,8 @@ export const normal: TemplateKey[] = [ 'bench/react-webpack-18-ts-test-build', // 'ember/default-js', 'react-rsbuild/default-ts', + 'tanstack-react-router/default-ts', + 'tanstack-react-start/default-ts', ]; export const merged: TemplateKey[] = [ diff --git a/code/lib/create-storybook/src/generators/TANSTACK/index.ts b/code/lib/create-storybook/src/generators/TANSTACK/index.ts new file mode 100644 index 000000000000..37f03d7b8a0c --- /dev/null +++ b/code/lib/create-storybook/src/generators/TANSTACK/index.ts @@ -0,0 +1,18 @@ +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedBuilder, SupportedFramework, SupportedRenderer } from 'storybook/internal/types'; + +import { defineGeneratorModule } from '../modules/GeneratorModule.ts'; + +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.TANSTACK_REACT, + renderer: SupportedRenderer.REACT, + framework: SupportedFramework.TANSTACK_REACT, + builderOverride: SupportedBuilder.VITE, + }, + configure: async () => { + return { + extraPackages: ['@tanstack/react-query', '@tanstack/react-router'], + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/baseGenerator.ts b/code/lib/create-storybook/src/generators/baseGenerator.ts index 877afbc9e5ae..9255d7dc302b 100644 --- a/code/lib/create-storybook/src/generators/baseGenerator.ts +++ b/code/lib/create-storybook/src/generators/baseGenerator.ts @@ -119,6 +119,7 @@ const hasFrameworkTemplates = (framework?: string) => { SupportedFramework.SOLID, SupportedFramework.SVELTE_VITE, SupportedFramework.SVELTEKIT, + SupportedFramework.TANSTACK_REACT, SupportedFramework.VUE3_VITE, SupportedFramework.WEB_COMPONENTS_VITE, ]; diff --git a/code/lib/create-storybook/src/generators/registerGenerators.ts b/code/lib/create-storybook/src/generators/registerGenerators.ts index 2d940f1a0a21..db9638c78314 100644 --- a/code/lib/create-storybook/src/generators/registerGenerators.ts +++ b/code/lib/create-storybook/src/generators/registerGenerators.ts @@ -15,6 +15,7 @@ import serverGenerator from './SERVER/index.ts'; import solidGenerator from './SOLID/index.ts'; import svelteGenerator from './SVELTE/index.ts'; import svelteKitGenerator from './SVELTEKIT/index.ts'; +import tanstackGenerator from './TANSTACK/index.ts'; import vue3Generator from './VUE3/index.ts'; import webComponentsGenerator from './WEB-COMPONENTS/index.ts'; import type { GeneratorModule } from './types.ts'; @@ -38,6 +39,7 @@ const setOfGenerators = new Set([ solidGenerator, serverGenerator, qwikGenerator, + tanstackGenerator, ]); /** Register all framework generators with the central registry */ diff --git a/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts b/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts index 8e8f2a7b611c..69c774fdd258 100644 --- a/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts +++ b/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts @@ -5,6 +5,7 @@ import { SupportedBuilder, SupportedFramework, SupportedRenderer } from 'storybo /** Project types that support the onboarding feature */ const ONBOARDING_PROJECT_TYPES: ProjectType[] = [ ProjectType.REACT, + ProjectType.TANSTACK_REACT, ProjectType.REACT_SCRIPTS, ProjectType.REACT_NATIVE_WEB, ProjectType.NEXTJS, diff --git a/code/lib/create-storybook/src/services/ProjectTypeService.ts b/code/lib/create-storybook/src/services/ProjectTypeService.ts index 39e2699bcb6d..18bb16701f5e 100644 --- a/code/lib/create-storybook/src/services/ProjectTypeService.ts +++ b/code/lib/create-storybook/src/services/ProjectTypeService.ts @@ -43,6 +43,13 @@ export class ProjectTypeService { return dependencies?.every(Boolean) ?? true; }, }, + { + preset: ProjectType.TANSTACK_REACT, + dependencies: ['@tanstack/start', '@tanstack/react-start', '@tanstack/react-router'], + matcherFunction: ({ dependencies }) => { + return dependencies?.some(Boolean) ?? false; + }, + }, { preset: ProjectType.VUE3, dependencies: { diff --git a/scripts/build/entry-configs.ts b/scripts/build/entry-configs.ts index 7414ffcca0f9..39bdff8c5f55 100644 --- a/scripts/build/entry-configs.ts +++ b/scripts/build/entry-configs.ts @@ -47,6 +47,8 @@ import vue3ViteFrameworkConfig from '../../../code/frameworks/vue3-vite/build-co // @ts-ignore import webComponentsViteFrameworkConfig from '../../../code/frameworks/web-components-vite/build-config'; // @ts-ignore +import tanstackReactFrameworkConfig from '../../../code/frameworks/tanstack-react/build-config'; +// @ts-ignore import cliConfig from '../../../code/lib/cli-storybook/build-config'; // @ts-ignore import codemodConfig from '../../../code/lib/codemod/build-config'; @@ -113,6 +115,7 @@ export const buildEntries = { '@storybook/sveltekit': sveltekitFrameworkConfig, '@storybook/vue3-vite': vue3ViteFrameworkConfig, '@storybook/web-components-vite': webComponentsViteFrameworkConfig, + '@storybook/tanstack-react': tanstackReactFrameworkConfig, // lib '@storybook/cli': cliConfig, diff --git a/yarn.lock b/yarn.lock index ffe60945e660..39f817ac572c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -485,6 +485,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:7.27.1": + version: 7.27.1 + resolution: "@babel/code-frame@npm:7.27.1" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.27.1" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/5dd9a18baa5fce4741ba729acc3a3272c49c25cb8736c4b18e113099520e7ef7b545a4096a26d600e4416157e63e87d66db46aa3fbf0a5f2286da2705c12da00 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.0, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.24.2, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" @@ -549,7 +560,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.12.0, @babel/core@npm:^7.18.9, @babel/core@npm:^7.23.0, @babel/core@npm:^7.23.9, @babel/core@npm:^7.28.0, @babel/core@npm:^7.28.5, @babel/core@npm:^7.29.0, @babel/core@npm:^7.3.4, @babel/core@npm:^7.7.5": +"@babel/core@npm:^7.12.0, @babel/core@npm:^7.18.9, @babel/core@npm:^7.23.0, @babel/core@npm:^7.23.7, @babel/core@npm:^7.23.9, @babel/core@npm:^7.28.0, @babel/core@npm:^7.28.5, @babel/core@npm:^7.29.0, @babel/core@npm:^7.3.4, @babel/core@npm:^7.7.5": version: 7.29.0 resolution: "@babel/core@npm:7.29.0" dependencies: @@ -782,7 +793,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.28.5": +"@babel/helper-validator-identifier@npm:^7.27.1, @babel/helper-validator-identifier@npm:^7.28.5": version: 7.28.5 resolution: "@babel/helper-validator-identifier@npm:7.28.5" checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 @@ -817,7 +828,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.0, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.7, @babel/parser@npm:^7.25.4, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.26.9, @babel/parser@npm:^7.28.5, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0, @babel/parser@npm:^7.4.5, @babel/parser@npm:^7.6.0, @babel/parser@npm:^7.9.6": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.0, @babel/parser@npm:^7.23.6, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.7, @babel/parser@npm:^7.25.4, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.26.9, @babel/parser@npm:^7.28.5, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0, @babel/parser@npm:^7.4.5, @babel/parser@npm:^7.6.0, @babel/parser@npm:^7.9.6": version: 7.29.2 resolution: "@babel/parser@npm:7.29.2" dependencies: @@ -4704,6 +4715,43 @@ __metadata: languageName: node linkType: hard +"@oozcitak/dom@npm:^2.0.2": + version: 2.0.2 + resolution: "@oozcitak/dom@npm:2.0.2" + dependencies: + "@oozcitak/infra": "npm:^2.0.2" + "@oozcitak/url": "npm:^3.0.0" + "@oozcitak/util": "npm:^10.0.0" + checksum: 10c0/053dfec09c6b5a38e2deb601f97313e01184a2c6452c13767a9629aa6686749f6f9d230806c51ab213dd454465b9e0f403cb7c45e210b447d5fe059a780278cf + languageName: node + linkType: hard + +"@oozcitak/infra@npm:^2.0.2": + version: 2.0.2 + resolution: "@oozcitak/infra@npm:2.0.2" + dependencies: + "@oozcitak/util": "npm:^10.0.0" + checksum: 10c0/26392f3cc6c0787b9256e1eaa39a4dc374dc54f437ea1e8d3314fb41f7bc2c011c3c3eac7bc58b1773968df0c2991ca41761aa12778a0ca4059f0a79002b6205 + languageName: node + linkType: hard + +"@oozcitak/url@npm:^3.0.0": + version: 3.0.0 + resolution: "@oozcitak/url@npm:3.0.0" + dependencies: + "@oozcitak/infra": "npm:^2.0.2" + "@oozcitak/util": "npm:^10.0.0" + checksum: 10c0/0a43eb9db072078f0826ea5eeefd5c398e4e47aa6f99a09b87724eed3bf018894de3e6617631547df194ec0ceb965cd5feb9bf666eb094952fed1280770b4f7a + languageName: node + linkType: hard + +"@oozcitak/util@npm:^10.0.0": + version: 10.0.0 + resolution: "@oozcitak/util@npm:10.0.0" + checksum: 10c0/4f143f50779790166e747c722383551af819776e7f946c9bd25b9875a128c517c91f058fb4ea11b235388eea83bf7b84a5590f882c74171e95b04487bc2b948d + languageName: node + linkType: hard + "@openai/codex-darwin-arm64@npm:@openai/codex@0.117.0-darwin-arm64": version: 0.117.0-darwin-arm64 resolution: "@openai/codex@npm:0.117.0-darwin-arm64" @@ -7557,6 +7605,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/pluginutils@npm:1.0.0-beta.40": + version: 1.0.0-beta.40 + resolution: "@rolldown/pluginutils@npm:1.0.0-beta.40" + checksum: 10c0/057c640a526e7ba0f4c6d379918665017aaf616e682393df1f112f6e103e738267d27a70015f08992bd19186d7627e4c096d37f03b87e7cc4aa0353430c40e42 + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-rc.17, @rolldown/pluginutils@npm:^1.0.0-rc.9": version: 1.0.0-rc.17 resolution: "@rolldown/pluginutils@npm:1.0.0-rc.17" @@ -9109,6 +9164,37 @@ __metadata: languageName: unknown linkType: soft +"@storybook/tanstack-react@workspace:code/frameworks/tanstack-react": + version: 0.0.0-use.local + resolution: "@storybook/tanstack-react@workspace:code/frameworks/tanstack-react" + dependencies: + "@storybook/builder-vite": "workspace:*" + "@storybook/react": "workspace:*" + "@storybook/react-vite": "workspace:*" + "@tanstack/react-router": "npm:^1.168.10" + "@tanstack/react-start": "npm:^1.167.16" + "@tanstack/router-core": "npm:^1.168.9" + "@tanstack/start-client-core": "npm:^1.167.9" + "@types/node": "npm:^22.19.1" + typescript: "npm:^5.9.3" + vite: "npm:^7.0.4" + peerDependencies: + "@tanstack/react-router": ^1.168.10 + "@tanstack/react-start": ^1.167.16 + "@tanstack/router-core": ^1.168.9 + "@tanstack/start-client-core": ^1.167.9 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: "workspace:^" + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@tanstack/react-start": + optional: true + "@tanstack/start-client-core": + optional: true + languageName: unknown + linkType: soft + "@storybook/vue3-vite@workspace:*, @storybook/vue3-vite@workspace:code/frameworks/vue3-vite": version: 0.0.0-use.local resolution: "@storybook/vue3-vite@workspace:code/frameworks/vue3-vite" @@ -9243,6 +9329,93 @@ __metadata: languageName: node linkType: hard +"@tanstack/history@npm:1.161.6": + version: 1.161.6 + resolution: "@tanstack/history@npm:1.161.6" + checksum: 10c0/38be1af2ebb3511874f2ac922527b84fc01c568e24833a9fa85d483bcc88311e0daae38a97a80eee22581bf07350875a04f33a5c2d9d4edf421bed2f5f5da4a5 + languageName: node + linkType: hard + +"@tanstack/react-router@npm:1.168.10, @tanstack/react-router@npm:^1.168.10": + version: 1.168.10 + resolution: "@tanstack/react-router@npm:1.168.10" + dependencies: + "@tanstack/history": "npm:1.161.6" + "@tanstack/react-store": "npm:^0.9.3" + "@tanstack/router-core": "npm:1.168.9" + isbot: "npm:^5.1.22" + peerDependencies: + react: ">=18.0.0 || >=19.0.0" + react-dom: ">=18.0.0 || >=19.0.0" + checksum: 10c0/d52d6a5929266c70ca63dd1875a3f6919a364b7e2253ff9448043a4cf973720e4742b0e1bbf7691001dc3d639e31065806d581eb64ec18da198917712ab0d5dc + languageName: node + linkType: hard + +"@tanstack/react-start-client@npm:1.166.25": + version: 1.166.25 + resolution: "@tanstack/react-start-client@npm:1.166.25" + dependencies: + "@tanstack/react-router": "npm:1.168.10" + "@tanstack/router-core": "npm:1.168.9" + "@tanstack/start-client-core": "npm:1.167.9" + peerDependencies: + react: ">=18.0.0 || >=19.0.0" + react-dom: ">=18.0.0 || >=19.0.0" + checksum: 10c0/0b442a2a7b5b5e736885225af8a7f651ed77be24aacc3c5a00306c06c0ff48589c3472f47c655d3d86dfa2a03d17fe57cb112777390ad33a7109b5c3d87321a6 + languageName: node + linkType: hard + +"@tanstack/react-start-server@npm:1.166.25": + version: 1.166.25 + resolution: "@tanstack/react-start-server@npm:1.166.25" + dependencies: + "@tanstack/history": "npm:1.161.6" + "@tanstack/react-router": "npm:1.168.10" + "@tanstack/router-core": "npm:1.168.9" + "@tanstack/start-client-core": "npm:1.167.9" + "@tanstack/start-server-core": "npm:1.167.9" + peerDependencies: + react: ">=18.0.0 || >=19.0.0" + react-dom: ">=18.0.0 || >=19.0.0" + checksum: 10c0/be68effd67989bf9d7d4530cf03ce24a4154a86303dff67ff935c7f5274529fddbe935130079a2f74058d9553a14e7f2cfca6ec23d7f1fff2b18fd739b1481ef + languageName: node + linkType: hard + +"@tanstack/react-start@npm:^1.167.16": + version: 1.167.16 + resolution: "@tanstack/react-start@npm:1.167.16" + dependencies: + "@tanstack/react-router": "npm:1.168.10" + "@tanstack/react-start-client": "npm:1.166.25" + "@tanstack/react-start-server": "npm:1.166.25" + "@tanstack/router-utils": "npm:^1.161.6" + "@tanstack/start-client-core": "npm:1.167.9" + "@tanstack/start-plugin-core": "npm:1.167.17" + "@tanstack/start-server-core": "npm:1.167.9" + pathe: "npm:^2.0.3" + peerDependencies: + react: ">=18.0.0 || >=19.0.0" + react-dom: ">=18.0.0 || >=19.0.0" + vite: ">=7.0.0" + bin: + intent: bin/intent.js + checksum: 10c0/70722e08ec2e9e27e555b192e29e7082ff90b0c5b375a9bde9ab6897175fa630c9aee2c0674ba76a58a463f77b3015d8b4f655e1e4e535cb3ce0ee8b15c9b1a7 + languageName: node + linkType: hard + +"@tanstack/react-store@npm:^0.9.3": + version: 0.9.3 + resolution: "@tanstack/react-store@npm:0.9.3" + dependencies: + "@tanstack/store": "npm:0.9.3" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/4d043b7211129418efa5ec1fafec7315b46b7ef92658f434b8e7a9a5dbf7687418c70f3c11f9d1636f304fa868a6d969380444d2042442ef94fc5517d19c5e4c + languageName: node + linkType: hard + "@tanstack/react-virtual@npm:^3.3.0": version: 3.13.12 resolution: "@tanstack/react-virtual@npm:3.13.12" @@ -9255,6 +9428,177 @@ __metadata: languageName: node linkType: hard +"@tanstack/router-core@npm:1.168.9, @tanstack/router-core@npm:^1.168.9": + version: 1.168.9 + resolution: "@tanstack/router-core@npm:1.168.9" + dependencies: + "@tanstack/history": "npm:1.161.6" + cookie-es: "npm:^2.0.0" + seroval: "npm:^1.4.2" + seroval-plugins: "npm:^1.4.2" + bin: + intent: bin/intent.js + checksum: 10c0/cdc4a3de30adae18d355ea79569fdde2a51e3952eef9e9db8f6ad5eec970781f1e5fca7ccb5c8f562b139cb440780803f871f1c812686c2ff83328634433617d + languageName: node + linkType: hard + +"@tanstack/router-generator@npm:1.166.24": + version: 1.166.24 + resolution: "@tanstack/router-generator@npm:1.166.24" + dependencies: + "@tanstack/router-core": "npm:1.168.9" + "@tanstack/router-utils": "npm:1.161.6" + "@tanstack/virtual-file-routes": "npm:1.161.7" + prettier: "npm:^3.5.0" + recast: "npm:^0.23.11" + source-map: "npm:^0.7.4" + tsx: "npm:^4.19.2" + zod: "npm:^3.24.2" + checksum: 10c0/66677f3f56bb83a45f4ff2f356ba20d99ea5d1a0222d68af015f57b097454905fa7672cbc3939e58c9981dbe8a927ed440f1841ea4341590cce52ff196e860e8 + languageName: node + linkType: hard + +"@tanstack/router-plugin@npm:1.167.12": + version: 1.167.12 + resolution: "@tanstack/router-plugin@npm:1.167.12" + dependencies: + "@babel/core": "npm:^7.28.5" + "@babel/plugin-syntax-jsx": "npm:^7.27.1" + "@babel/plugin-syntax-typescript": "npm:^7.27.1" + "@babel/template": "npm:^7.27.2" + "@babel/traverse": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + "@tanstack/router-core": "npm:1.168.9" + "@tanstack/router-generator": "npm:1.166.24" + "@tanstack/router-utils": "npm:1.161.6" + "@tanstack/virtual-file-routes": "npm:1.161.7" + chokidar: "npm:^3.6.0" + unplugin: "npm:^2.1.2" + zod: "npm:^3.24.2" + peerDependencies: + "@rsbuild/core": ">=1.0.2" + "@tanstack/react-router": ^1.168.10 + vite: ">=5.0.0 || >=6.0.0 || >=7.0.0" + vite-plugin-solid: ^2.11.10 + webpack: ">=5.92.0" + peerDependenciesMeta: + "@rsbuild/core": + optional: true + "@tanstack/react-router": + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + bin: + intent: bin/intent.js + checksum: 10c0/82cf03b64a3bd1ed8f44751623f59cd496b3015952a991d06a1af185a4bb3fcf4473d634a8bc72be9b8a75ffe296fe99843149ebc90ef441c8444247fc98bba2 + languageName: node + linkType: hard + +"@tanstack/router-utils@npm:1.161.6, @tanstack/router-utils@npm:^1.161.6": + version: 1.161.6 + resolution: "@tanstack/router-utils@npm:1.161.6" + dependencies: + "@babel/core": "npm:^7.28.5" + "@babel/generator": "npm:^7.28.5" + "@babel/parser": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + ansis: "npm:^4.1.0" + babel-dead-code-elimination: "npm:^1.0.12" + diff: "npm:^8.0.2" + pathe: "npm:^2.0.3" + tinyglobby: "npm:^0.2.15" + checksum: 10c0/4713a58ce733d8cb3681366d26add95df6849445aaa3e5fb0e8caeee4a077fd7eaa399874a588b276e519f932008572a5b8ed10acd478062a463beefeb0580ff + languageName: node + linkType: hard + +"@tanstack/start-client-core@npm:1.167.9, @tanstack/start-client-core@npm:^1.167.9": + version: 1.167.9 + resolution: "@tanstack/start-client-core@npm:1.167.9" + dependencies: + "@tanstack/router-core": "npm:1.168.9" + "@tanstack/start-fn-stubs": "npm:1.161.6" + "@tanstack/start-storage-context": "npm:1.166.23" + seroval: "npm:^1.4.2" + bin: + intent: bin/intent.js + checksum: 10c0/b75ff7ccafe1d6d308ee81aac8e6fc6576c512a683087da67bb060e0cda688eceb16df61f9254a717484d2fed05af9ad5b5c4c129aff32fabed0809cbfe49e17 + languageName: node + linkType: hard + +"@tanstack/start-fn-stubs@npm:1.161.6": + version: 1.161.6 + resolution: "@tanstack/start-fn-stubs@npm:1.161.6" + checksum: 10c0/f822b8c7e79c884488b24b6f03648d260467eeff3662f2c8e91670dc980c1baaf194faa98a073830023c9d087a1e555555a53f3dea175df37aa7dfaff271db62 + languageName: node + linkType: hard + +"@tanstack/start-plugin-core@npm:1.167.17": + version: 1.167.17 + resolution: "@tanstack/start-plugin-core@npm:1.167.17" + dependencies: + "@babel/code-frame": "npm:7.27.1" + "@babel/core": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + "@rolldown/pluginutils": "npm:1.0.0-beta.40" + "@tanstack/router-core": "npm:1.168.9" + "@tanstack/router-generator": "npm:1.166.24" + "@tanstack/router-plugin": "npm:1.167.12" + "@tanstack/router-utils": "npm:1.161.6" + "@tanstack/start-client-core": "npm:1.167.9" + "@tanstack/start-server-core": "npm:1.167.9" + cheerio: "npm:^1.0.0" + exsolve: "npm:^1.0.7" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + source-map: "npm:^0.7.6" + srvx: "npm:^0.11.9" + tinyglobby: "npm:^0.2.15" + ufo: "npm:^1.5.4" + vitefu: "npm:^1.1.1" + xmlbuilder2: "npm:^4.0.3" + zod: "npm:^3.24.2" + peerDependencies: + vite: ">=7.0.0" + checksum: 10c0/8decab4378eed518a39c3793b36b75148fcb5e4579f7ba153b76518f37515bb5e30fe2383e73c81a62b5e3e600c9c586ed5622cef8a67939213bc45b8b1856cd + languageName: node + linkType: hard + +"@tanstack/start-server-core@npm:1.167.9": + version: 1.167.9 + resolution: "@tanstack/start-server-core@npm:1.167.9" + dependencies: + "@tanstack/history": "npm:1.161.6" + "@tanstack/router-core": "npm:1.168.9" + "@tanstack/start-client-core": "npm:1.167.9" + "@tanstack/start-storage-context": "npm:1.166.23" + h3-v2: "npm:h3@2.0.1-rc.16" + seroval: "npm:^1.4.2" + bin: + intent: bin/intent.js + checksum: 10c0/5a2bad0585d35b6ddab337977f3a36552ca79d7e4c3dc7c5d7d626274b97990271bbf05de43351707654023d744fcc20ba6b025144574b31875aaef8697f8b8c + languageName: node + linkType: hard + +"@tanstack/start-storage-context@npm:1.166.23": + version: 1.166.23 + resolution: "@tanstack/start-storage-context@npm:1.166.23" + dependencies: + "@tanstack/router-core": "npm:1.168.9" + checksum: 10c0/364733b20a2d594e1885563da1b24c134279b123969250812604dd3382ca327d9674570d9c8d8174aba50f5f527721ee465a117762f01e28a8add2ad960a1aed + languageName: node + linkType: hard + +"@tanstack/store@npm:0.9.3": + version: 0.9.3 + resolution: "@tanstack/store@npm:0.9.3" + checksum: 10c0/ec022c792c298be0717d7a2d06d6db4459077db775a27d86a2a248f446257e133c5d7c77a74bf6a4fa6993cfaef09b6f58a68a601c3becca834ed0b457ebe824 + languageName: node + linkType: hard + "@tanstack/virtual-core@npm:3.13.12": version: 3.13.12 resolution: "@tanstack/virtual-core@npm:3.13.12" @@ -9262,6 +9606,15 @@ __metadata: languageName: node linkType: hard +"@tanstack/virtual-file-routes@npm:1.161.7": + version: 1.161.7 + resolution: "@tanstack/virtual-file-routes@npm:1.161.7" + bin: + intent: bin/intent.js + checksum: 10c0/fee6b4b3dbb6ea9b490c4c9a6a113ec238254cdae3c8533f9f8e6a723f69474acda6095c255a258f6fd01990249274116ca502ffdb7ad84af9085bbd9b873f6c + languageName: node + linkType: hard + "@testing-library/dom@npm:9.x.x || 10.x.x, @testing-library/dom@npm:^10.4.0, @testing-library/dom@npm:^10.4.1": version: 10.4.1 resolution: "@testing-library/dom@npm:10.4.1" @@ -12695,6 +13048,18 @@ __metadata: languageName: node linkType: hard +"babel-dead-code-elimination@npm:^1.0.12": + version: 1.0.12 + resolution: "babel-dead-code-elimination@npm:1.0.12" + dependencies: + "@babel/core": "npm:^7.23.7" + "@babel/parser": "npm:^7.23.6" + "@babel/traverse": "npm:^7.23.7" + "@babel/types": "npm:^7.23.6" + checksum: 10c0/9289b66ce202a5f2b8c160f6a0ed16b97c42a9e06e92ea997df4e34d6a8309a01cf641b21467ee6a2713efd7e158b4b770e04a07f3f9d30eb05d428d186f7a60 + languageName: node + linkType: hard + "babel-loader@npm:9.1.3": version: 9.1.3 resolution: "babel-loader@npm:9.1.3" @@ -13859,6 +14224,39 @@ __metadata: languageName: node linkType: hard +"cheerio-select@npm:^2.1.0": + version: 2.1.0 + resolution: "cheerio-select@npm:2.1.0" + dependencies: + boolbase: "npm:^1.0.0" + css-select: "npm:^5.1.0" + css-what: "npm:^6.1.0" + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.3" + domutils: "npm:^3.0.1" + checksum: 10c0/2242097e593919dba4aacb97d7b8275def8b9ec70b00aa1f43335456870cfc9e284eae2080bdc832ed232dabb9eefcf56c722d152da4a154813fb8814a55d282 + languageName: node + linkType: hard + +"cheerio@npm:^1.0.0": + version: 1.2.0 + resolution: "cheerio@npm:1.2.0" + dependencies: + cheerio-select: "npm:^2.1.0" + dom-serializer: "npm:^2.0.0" + domhandler: "npm:^5.0.3" + domutils: "npm:^3.2.2" + encoding-sniffer: "npm:^0.2.1" + htmlparser2: "npm:^10.1.0" + parse5: "npm:^7.3.0" + parse5-htmlparser2-tree-adapter: "npm:^7.1.0" + parse5-parser-stream: "npm:^7.1.2" + undici: "npm:^7.19.0" + whatwg-mimetype: "npm:^4.0.0" + checksum: 10c0/91a566aabfa9962f28056045bb7d92d79c0f8f3abb1fb86a852a9d1760556adddeb01a36b6f08fa7c133282375d387ae450a181a659e76c6a64016c30cc3f611 + languageName: node + linkType: hard + "chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" @@ -14462,6 +14860,13 @@ __metadata: languageName: node linkType: hard +"cookie-es@npm:^2.0.0": + version: 2.0.0 + resolution: "cookie-es@npm:2.0.0" + checksum: 10c0/3b2459030a5ad2bc715aeb27a32f274340670bfc5031ac29e1fba804212517411bb617880d3fe66ace2b64dfb28f3049e2d1ff40d4bec342154ccdd124deaeaa + languageName: node + linkType: hard + "cookie-signature@npm:1.0.6": version: 1.0.6 resolution: "cookie-signature@npm:1.0.6" @@ -15590,7 +15995,7 @@ __metadata: languageName: node linkType: hard -"domutils@npm:^3.0.1, domutils@npm:^3.2.1": +"domutils@npm:^3.0.1, domutils@npm:^3.2.2": version: 3.2.2 resolution: "domutils@npm:3.2.2" dependencies: @@ -16008,6 +16413,16 @@ __metadata: languageName: node linkType: hard +"encoding-sniffer@npm:^0.2.1": + version: 0.2.1 + resolution: "encoding-sniffer@npm:0.2.1" + dependencies: + iconv-lite: "npm:^0.6.3" + whatwg-encoding: "npm:^3.1.1" + checksum: 10c0/d6b591880788f3baf8dd1744636dd189d24a1ec93e6f9817267c60ac3458a5191ca70ab1a186fb67731beff1c3489c6527dfdc4718158ed8460ab2f400dd5e7d + languageName: node + linkType: hard + "encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" @@ -16108,6 +16523,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^7.0.1": + version: 7.0.1 + resolution: "entities@npm:7.0.1" + checksum: 10c0/b4fb9937bb47ecb00aaaceb9db9cdd1cc0b0fb649c0e843d05cf5dbbd2e9d2df8f98721d8b1b286445689c72af7b54a7242fc2d63ef7c9739037a8c73363e7ca + languageName: node + linkType: hard + "env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -18543,12 +18965,12 @@ __metadata: languageName: node linkType: hard -"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.10.1": - version: 4.13.0 - resolution: "get-tsconfig@npm:4.13.0" +"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.7.5": + version: 4.13.7 + resolution: "get-tsconfig@npm:4.13.7" dependencies: resolve-pkg-maps: "npm:^1.0.0" - checksum: 10c0/2c49ef8d3907047a107f229fd610386fe3b7fe9e42dfd6b42e7406499493cdda8c62e83e57e8d7a98125610774b9f604d3a0ff308d7f9de5c7ac6d1b07cb6036 + checksum: 10c0/1118eb7e9b27bce0b9b6f042e98f0d067e26dfa1ca32bc4b56e892b615b57a5a4af9e6f801c7b0611a4afef2e31c4941be4c6026e0e6a480aaf1ddaf261113d5 languageName: node linkType: hard @@ -18891,6 +19313,23 @@ __metadata: languageName: node linkType: hard +"h3-v2@npm:h3@2.0.1-rc.16": + version: 2.0.1-rc.16 + resolution: "h3@npm:2.0.1-rc.16" + dependencies: + rou3: "npm:^0.8.0" + srvx: "npm:^0.11.9" + peerDependencies: + crossws: ^0.4.1 + peerDependenciesMeta: + crossws: + optional: true + bin: + h3: bin/h3.mjs + checksum: 10c0/5da1c575307ef3cb953c4e79a1538878b93bca5218935ce77d36e8b4a9ac581ff789252cbbab6288855bcd796eeb2e91d16d00b52d9722d72bbfa515bba576ce + languageName: node + linkType: hard + "handle-thing@npm:^2.0.0": version: 2.0.1 resolution: "handle-thing@npm:2.0.1" @@ -19372,15 +19811,15 @@ __metadata: languageName: node linkType: hard -"htmlparser2@npm:^10.0.0": - version: 10.0.0 - resolution: "htmlparser2@npm:10.0.0" +"htmlparser2@npm:^10.0.0, htmlparser2@npm:^10.1.0": + version: 10.1.0 + resolution: "htmlparser2@npm:10.1.0" dependencies: domelementtype: "npm:^2.3.0" domhandler: "npm:^5.0.3" - domutils: "npm:^3.2.1" - entities: "npm:^6.0.0" - checksum: 10c0/47cfa37e529c86a7ba9a1e0e6f951ad26ef8ca5af898ab6e8916fa02c0264c1453b4a65f28b7b8a7f9d0d29b5a70abead8203bf8b3f07bc69407e85e7d9a68e4 + domutils: "npm:^3.2.2" + entities: "npm:^7.0.1" + checksum: 10c0/36394e29b80cfcc5e78e0fa4d3aa21fdaac3e6778d23e5c933e625c290987cd9a724a2eb0753ab60ed0c69dfaba0ab115f0ee50fb112fd8f0c4d522e7e0089a2 languageName: node linkType: hard @@ -20539,6 +20978,13 @@ __metadata: languageName: node linkType: hard +"isbot@npm:^5.1.22": + version: 5.1.34 + resolution: "isbot@npm:5.1.34" + checksum: 10c0/9b19e4b7c1da914f06dea6c30c9acc00a5507d1ccd0fb71a49cef29038ef88a56c77a2cf2b57613cb36454accb43cc883a3cd16ab72428d033662c616a59e602 + languageName: node + linkType: hard + "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -24718,6 +25164,25 @@ __metadata: languageName: node linkType: hard +"parse5-htmlparser2-tree-adapter@npm:^7.1.0": + version: 7.1.0 + resolution: "parse5-htmlparser2-tree-adapter@npm:7.1.0" + dependencies: + domhandler: "npm:^5.0.3" + parse5: "npm:^7.0.0" + checksum: 10c0/e5a4e0b834c84c9e244b5749f8d007f4baaeafac7a1da2c54be3421ffd9ef8fdec4f198bf55cda22e88e6ba95e9943f6ed5aa3ae5900b39972ebf5dc8c3f4722 + languageName: node + linkType: hard + +"parse5-parser-stream@npm:^7.1.2": + version: 7.1.2 + resolution: "parse5-parser-stream@npm:7.1.2" + dependencies: + parse5: "npm:^7.0.0" + checksum: 10c0/e236c61000d38ecad369e725a48506b051cebad8abb00e6d4e8bff7aa85c183820fcb45db1559cc90955bdbbdbd665ea94c41259594e74566fff411478dc7fcb + languageName: node + linkType: hard + "parse5-sax-parser@npm:^7.0.0": version: 7.0.0 resolution: "parse5-sax-parser@npm:7.0.0" @@ -24727,7 +25192,7 @@ __metadata: languageName: node linkType: hard -"parse5@npm:^7.0.0": +"parse5@npm:^7.0.0, parse5@npm:^7.3.0": version: 7.3.0 resolution: "parse5@npm:7.3.0" dependencies: @@ -25458,12 +25923,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:*, prettier@npm:^3.7.1": - version: 3.7.1 - resolution: "prettier@npm:3.7.1" +"prettier@npm:*, prettier@npm:^3.5.0, prettier@npm:^3.7.1": + version: 3.8.1 + resolution: "prettier@npm:3.8.1" bin: prettier: bin/prettier.cjs - checksum: 10c0/a6610043ee0a64a3251a948bf82fad3e59d984a8e8dea206400cfa190585417e3343b32c1f6ae7d8f40798a9b4bd91affc08fa7795dd99a9dec5c9bccdf31500 + checksum: 10c0/33169b594009e48f570471271be7eac7cdcf88a209eed39ac3b8d6d78984039bfa9132f82b7e6ba3b06711f3bfe0222a62a1bfb87c43f50c25a83df1b78a2c42 languageName: node linkType: hard @@ -26694,7 +27159,7 @@ __metadata: languageName: node linkType: hard -"recast@npm:^0.23.1, recast@npm:^0.23.3, recast@npm:^0.23.5, recast@npm:^0.23.9": +"recast@npm:^0.23.1, recast@npm:^0.23.11, recast@npm:^0.23.3, recast@npm:^0.23.5, recast@npm:^0.23.9": version: 0.23.11 resolution: "recast@npm:0.23.11" dependencies: @@ -27535,6 +28000,13 @@ __metadata: languageName: node linkType: hard +"rou3@npm:^0.8.0": + version: 0.8.1 + resolution: "rou3@npm:0.8.1" + checksum: 10c0/c8728cf3c41833db0e20cbadba07b3c678b8b9fb12db1d8803f275a7a6cce02d0be9bee79367575883f65659c9c0ed1001e6527146ed27772e439e5d6c68d264 + languageName: node + linkType: hard + "rsvp@npm:^3.0.14, rsvp@npm:^3.0.18": version: 3.6.2 resolution: "rsvp@npm:3.6.2" @@ -27927,6 +28399,22 @@ __metadata: languageName: node linkType: hard +"seroval-plugins@npm:^1.4.2": + version: 1.5.0 + resolution: "seroval-plugins@npm:1.5.0" + peerDependencies: + seroval: ^1.0 + checksum: 10c0/a70636d35e0644e37efad37963e6d41ae9e4a02fbf1b57c89dbe4d62122908039e8a0fda1720b8a56aea93741735b2028ada6d3d50c1d40bbb67661f0de92042 + languageName: node + linkType: hard + +"seroval@npm:^1.4.2": + version: 1.5.0 + resolution: "seroval@npm:1.5.0" + checksum: 10c0/aff16b14a7145388555cefd4ebd41759024ee1c2c064080fd8d4fabea4b7c89d103155cd98f5109523b8878e577da73cc6cd8abf98965f2d1f0ba19dc38317ab + languageName: node + linkType: hard + "serve-index@npm:^1.9.1": version: 1.9.1 resolution: "serve-index@npm:1.9.1" @@ -28551,7 +29039,7 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.7.0, source-map@npm:^0.7.3, source-map@npm:^0.7.4": +"source-map@npm:^0.7.0, source-map@npm:^0.7.3, source-map@npm:^0.7.4, source-map@npm:^0.7.6": version: 0.7.6 resolution: "source-map@npm:0.7.6" checksum: 10c0/59f6f05538539b274ba771d2e9e32f6c65451982510564438e048bc1352f019c6efcdc6dd07909b1968144941c14015c2c7d4369fb7c4d7d53ae769716dcc16c @@ -28661,6 +29149,15 @@ __metadata: languageName: node linkType: hard +"srvx@npm:^0.11.9": + version: 0.11.13 + resolution: "srvx@npm:0.11.13" + bin: + srvx: bin/srvx.mjs + checksum: 10c0/571eb5463eee301a43cfd49b629145962f964a1c049454bd390267c3eae29261d5aee026aefe2ee42a66dbe2bf66db7853d2f8e2d247feea3a6032ba6286c8fd + languageName: node + linkType: hard + "sshpk@npm:^1.18.0, sshpk@npm:^1.7.0": version: 1.18.0 resolution: "sshpk@npm:1.18.0" @@ -30208,6 +30705,22 @@ __metadata: languageName: node linkType: hard +"tsx@npm:^4.19.2": + version: 4.21.0 + resolution: "tsx@npm:4.21.0" + dependencies: + esbuild: "npm:~0.27.0" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10c0/f5072923cd8459a1f9a26df87823a2ab5754641739d69df2a20b415f61814322b751fa6be85db7c6ec73cf68ba8fac2fd1cfc76bdb0aa86ded984d84d5d2126b + languageName: node + linkType: hard + "tty-browserify@npm:^0.0.1": version: 0.0.1 resolution: "tty-browserify@npm:0.0.1" @@ -30358,6 +30871,13 @@ __metadata: languageName: node linkType: hard +"ufo@npm:^1.5.4": + version: 1.6.3 + resolution: "ufo@npm:1.6.3" + checksum: 10c0/bf0e4ebff99e54da1b9c7182ac2f40475988b41faa881d579bc97bc2a0509672107b0a0e94c4b8d31a0ab8c4bf07f4aa0b469ac6da8536d56bda5b085ea2e953 + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.17.4 resolution: "uglify-js@npm:3.17.4" @@ -30415,6 +30935,13 @@ __metadata: languageName: node linkType: hard +"undici@npm:^7.19.0": + version: 7.24.6 + resolution: "undici@npm:7.24.6" + checksum: 10c0/0f5413ccb20bafe27637a3a02cada731c53ee75f1df79029099db3af1eaaed410488489d9f430c09bd30bf0b925cb75fc30c39dff0689f656fd6fb7d75ded95f + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.1 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.1" @@ -30640,15 +31167,15 @@ __metadata: languageName: node linkType: hard -"unplugin@npm:^2.3.5": - version: 2.3.10 - resolution: "unplugin@npm:2.3.10" +"unplugin@npm:^2.1.2, unplugin@npm:^2.3.5": + version: 2.3.11 + resolution: "unplugin@npm:2.3.11" dependencies: "@jridgewell/remapping": "npm:^2.3.5" acorn: "npm:^8.15.0" picomatch: "npm:^4.0.3" webpack-virtual-modules: "npm:^0.6.2" - checksum: 10c0/29dcd738772aeff91c6f0154f156c95c58a37a4674fcb7cc34d6868af763834f0f447a1c3af074818c0c5602baead49bd3b9399a13f0425d69a00a527e58ddda + checksum: 10c0/273c1eab0eca4470c7317428689295c31dbe8ab0b306504de9f03cd20c156debb4131bef24b27ac615862958c5dd950a3951d26c0723ea774652ab3624149cff languageName: node linkType: hard @@ -30830,7 +31357,7 @@ __metadata: languageName: node linkType: hard -"use-sync-external-store@npm:^1.4.0, use-sync-external-store@npm:^1.5.0": +"use-sync-external-store@npm:^1.4.0, use-sync-external-store@npm:^1.5.0, use-sync-external-store@npm:^1.6.0": version: 1.6.0 resolution: "use-sync-external-store@npm:1.6.0" peerDependencies: @@ -32068,6 +32595,15 @@ __metadata: languageName: node linkType: hard +"whatwg-encoding@npm:^3.1.1": + version: 3.1.1 + resolution: "whatwg-encoding@npm:3.1.1" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10c0/273b5f441c2f7fda3368a496c3009edbaa5e43b71b09728f90425e7f487e5cef9eb2b846a31bd760dd8077739c26faf6b5ca43a5f24033172b003b72cf61a93e + languageName: node + linkType: hard + "whatwg-mimetype@npm:^3.0.0": version: 3.0.0 resolution: "whatwg-mimetype@npm:3.0.0" @@ -32075,6 +32611,13 @@ __metadata: languageName: node linkType: hard +"whatwg-mimetype@npm:^4.0.0": + version: 4.0.0 + resolution: "whatwg-mimetype@npm:4.0.0" + checksum: 10c0/a773cdc8126b514d790bdae7052e8bf242970cebd84af62fb2f35a33411e78e981f6c0ab9ed1fe6ec5071b09d5340ac9178e05b52d35a9c4bcf558ba1b1551df + languageName: node + linkType: hard + "whatwg-url@npm:^5.0.0": version: 5.0.0 resolution: "whatwg-url@npm:5.0.0" @@ -32402,6 +32945,18 @@ __metadata: languageName: node linkType: hard +"xmlbuilder2@npm:^4.0.3": + version: 4.0.3 + resolution: "xmlbuilder2@npm:4.0.3" + dependencies: + "@oozcitak/dom": "npm:^2.0.2" + "@oozcitak/infra": "npm:^2.0.2" + "@oozcitak/util": "npm:^10.0.0" + js-yaml: "npm:^4.1.1" + checksum: 10c0/ba257ca8a29b35d916fda190659b95d861137922fc792f760223f4a3bad07b5f18c992fb3a669d3dd6de6607eff64440cda86de083fc4648e4ec6b8f6be636a0 + languageName: node + linkType: hard + "xtend@npm:^4.0.0, xtend@npm:^4.0.2, xtend@npm:~4.0.1": version: 4.0.2 resolution: "xtend@npm:4.0.2" @@ -32589,7 +33144,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.25.76": +"zod@npm:^3.24.2, zod@npm:^3.25.76": version: 3.25.76 resolution: "zod@npm:3.25.76" checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c