diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b75bf7e9cff..6fce6fb7d827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 10.5.2 + +- Angular-Vite: Drop @angular/platform-browser-dynamic peer dependency - [#35457](https://github.com/storybookjs/storybook/pull/35457), thanks @valentinpalkovic! +- Angular-Vite: Widen TypeScript peer dependency range to support TypeScript 6 - [#35455](https://github.com/storybookjs/storybook/pull/35455), thanks @valentinpalkovic! +- Core: Include chromatic packages in ecosystem identifier - [#35170](https://github.com/storybookjs/storybook/pull/35170), thanks @yannbf! +- TanStack: Fix createServerFn validator mock - [#35185](https://github.com/storybookjs/storybook/pull/35185), thanks @sjh9714! +- TanStack: Support pathless layout routes (id-only) in story routing - [#35465](https://github.com/storybookjs/storybook/pull/35465), thanks @unpunnyfuns! +- Tanstack-react: Add missing Hydrate export - [#35111](https://github.com/storybookjs/storybook/pull/35111), thanks @arun-357! +- Tanstack-react: Keep JSX-only component references during dead-code elimination - [#35206](https://github.com/storybookjs/storybook/pull/35206), thanks @yatishgoel! +- Vitest: Fix coverage toggle crash on Vite 6 by clearing closed Vitest instance on restart - [#35461](https://github.com/storybookjs/storybook/pull/35461), thanks @yannbf! + ## 10.5.1 - Angular-Vite: Drop @angular/platform-browser-dynamic peer dependency - [#35457](https://github.com/storybookjs/storybook/pull/35457), thanks @valentinpalkovic! diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts index 100b7bd01da5..28a8493c5795 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -190,6 +190,11 @@ export class VitestManager { try { await this.runningPromise; await this.vitest?.close(); + // Drop the closed instance before restarting. The coverage reporter options passed to + // createVitest reference this manager, and Vitest deep-clones its options — on Vite 6 + // that traversal reaches the closed module runner's `import.meta.env` proxy (an own + // property of ModuleRunner there), whose get trap throws on any dynamic access. + this.vitest = null; await this.startVitest({ coverage }); resolve(); } catch (e) { diff --git a/code/frameworks/angular-vite/build-schema.json b/code/frameworks/angular-vite/build-schema.json index 1bd08d04b64e..75ec4dfce515 100644 --- a/code/frameworks/angular-vite/build-schema.json +++ b/code/frameworks/angular-vite/build-schema.json @@ -97,6 +97,13 @@ "items": { "type": "string" } + }, + "loadPaths": { + "description": "Alias for `includePaths` using the dart-sass/Vite spelling. Paths will be resolved to workspace root.", + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false diff --git a/code/frameworks/angular-vite/src/preset.test.ts b/code/frameworks/angular-vite/src/preset.test.ts new file mode 100644 index 000000000000..071e1c7a5bbf --- /dev/null +++ b/code/frameworks/angular-vite/src/preset.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { resolve } from 'node:path'; + +import { normalizePath } from 'vite'; + +import { angularOptionsPlugin } from './preset.ts'; +import type { StandaloneOptions } from './builders/utils/standalone-options.ts'; + +// The plugin's `config` hook looks up the preview file on disk before reading +// style options; stub just that lookup so the test stays hermetic. +vi.mock(import('storybook/internal/common'), async (importOriginal) => ({ + ...(await importOriginal()), + findConfigFile: () => undefined, +})); + +const WORKSPACE_ROOT = '/workspace'; + +function runConfig(stylePreprocessorOptions: Record | undefined) { + const options = { + configDir: '/workspace/.storybook', + angularBuilderContext: { workspaceRoot: WORKSPACE_ROOT } as any, + angularBuilderOptions: stylePreprocessorOptions ? { stylePreprocessorOptions } : {}, + } as unknown as StandaloneOptions; + + const plugin = angularOptionsPlugin(options, { normalizePath, zoneless: true }); + // `config` is defined as a plain method above, so invoke it directly. + return (plugin.config as (userConfig: unknown) => any)({ root: WORKSPACE_ROOT }); +} + +describe('angularOptionsPlugin style preprocessor paths', () => { + it('resolves `includePaths` (angular.json spelling) to workspace-absolute SCSS load paths', () => { + const result = runConfig({ includePaths: ['src/styles', 'libs/theme'] }); + + expect(result.css.preprocessorOptions.scss.loadPaths).toEqual([ + resolve(WORKSPACE_ROOT, 'src/styles'), + resolve(WORKSPACE_ROOT, 'libs/theme'), + ]); + }); + + it('accepts `loadPaths` as a dart-sass/Vite-spelling alias', () => { + const result = runConfig({ loadPaths: ['src/styles'] }); + + expect(result.css.preprocessorOptions.scss.loadPaths).toEqual([ + resolve(WORKSPACE_ROOT, 'src/styles'), + ]); + }); + + it('prefers `includePaths` over `loadPaths` when both are present', () => { + const result = runConfig({ includePaths: ['a'], loadPaths: ['b'] }); + + expect(result.css.preprocessorOptions.scss.loadPaths).toEqual([resolve(WORKSPACE_ROOT, 'a')]); + }); + + it('forwards `sass` options alongside the resolved load paths', () => { + const result = runConfig({ + includePaths: ['src/styles'], + sass: { silenceDeprecations: ['import'] }, + }); + + expect(result.css.preprocessorOptions.scss).toMatchObject({ + silenceDeprecations: ['import'], + loadPaths: [resolve(WORKSPACE_ROOT, 'src/styles')], + }); + }); + + it('returns nothing when no style preprocessor paths are configured', () => { + expect(runConfig(undefined)).toBeUndefined(); + expect(runConfig({})).toBeUndefined(); + }); +}); diff --git a/code/frameworks/angular-vite/src/preset.ts b/code/frameworks/angular-vite/src/preset.ts index 38bee963bae2..0e840587b48b 100644 --- a/code/frameworks/angular-vite/src/preset.ts +++ b/code/frameworks/angular-vite/src/preset.ts @@ -238,8 +238,15 @@ export function angularOptionsPlugin( config(userConfig: UserConfig) { resolvedConfig = userConfig; resolvedPreviewPath = findConfigFile('preview', options.configDir) ?? undefined; - const loadPaths = options?.angularBuilderOptions?.stylePreprocessorOptions?.loadPaths; - const sassOptions = options?.angularBuilderOptions?.stylePreprocessorOptions?.sass; + const stylePreprocessorOptions = + options?.angularBuilderOptions?.stylePreprocessorOptions ?? {}; + // Angular's builder schema (and this framework's builder schema) names the + // SCSS search-path array `includePaths`, matching angular.json. dart-sass + // and Vite call the same thing `loadPaths`; accept that spelling too so + // configs written against either convention work. `includePaths` wins when + // both are present. + const loadPaths = stylePreprocessorOptions.includePaths ?? stylePreprocessorOptions.loadPaths; + const sassOptions = stylePreprocessorOptions.sass; if (Array.isArray(loadPaths)) { const workspaceRoot = diff --git a/code/frameworks/angular-vite/start-schema.json b/code/frameworks/angular-vite/start-schema.json index 7a5f3f0fc0c0..a0bd5556cc20 100644 --- a/code/frameworks/angular-vite/start-schema.json +++ b/code/frameworks/angular-vite/start-schema.json @@ -110,6 +110,13 @@ "items": { "type": "string" } + }, + "loadPaths": { + "description": "Alias for `includePaths` using the dart-sass/Vite spelling. Paths will be resolved to workspace root.", + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false diff --git a/code/frameworks/tanstack-react/src/export-mocks/react-router.test.ts b/code/frameworks/tanstack-react/src/export-mocks/react-router.test.ts index c1dde615cfcb..4f7b0faceb98 100644 --- a/code/frameworks/tanstack-react/src/export-mocks/react-router.test.ts +++ b/code/frameworks/tanstack-react/src/export-mocks/react-router.test.ts @@ -28,14 +28,54 @@ describe('createFileRoute', () => { expect(Route.options.fullPath).toBe('/page'); }); - it('normalizes a pure pathless group route to root path', () => { + it('keeps a pathless layout nested under a pathful segment id-only', () => { + const Route = build('/posts/_layout'); + + expect(Route.options.id).toBe('/posts/_layout'); + expect(Route.options.path).toBeUndefined(); + expect(Route.options.fullPath).toBeUndefined(); + }); + + it('keeps a group nested under a pathful segment id-only', () => { + const Route = build('/posts/(admin)'); + + expect(Route.options.id).toBe('/posts/(admin)'); + expect(Route.options.path).toBeUndefined(); + expect(Route.options.fullPath).toBeUndefined(); + }); + + it('keeps a pure pathless group route id-only (no path)', () => { const Route = build('/(group)'); expect(Route.options.id).toBe('/(group)'); + expect(Route.options.path).toBeUndefined(); + expect(Route.options.fullPath).toBeUndefined(); + }); + + it('keeps a pure pathless layout route id-only (no path)', () => { + const Route = build('/_authed'); + + expect(Route.options.id).toBe('/_authed'); + expect(Route.options.path).toBeUndefined(); + expect(Route.options.fullPath).toBeUndefined(); + }); + + it('keeps the root index route path', () => { + const Route = build('/'); + + expect(Route.options.id).toBe('/'); expect(Route.options.path).toBe('/'); expect(Route.options.fullPath).toBe('/'); }); + it('trims trailing-underscore (un-nesting) segments', () => { + const Route = build('/posts_/$postId'); + + expect(Route.options.id).toBe('/posts_/$postId'); + expect(Route.options.path).toBe('/posts/$postId'); + expect(Route.options.fullPath).toBe('/posts/$postId'); + }); + it('normalizes pathless `_layout` segments the same way the generator does', () => { const Route = build('/_layout/page'); @@ -51,4 +91,23 @@ describe('createFileRoute', () => { expect(Route.options.path).toBe('/page'); expect(Route.options.fullPath).toBe('/page'); }); + + it('lets an explicit options.path win over a pathless id', () => { + const root = createRootRoute(); + const Route = createFileRoute('/_authed')({ + component: () => null, + getParentRoute: () => root, + path: '/authed', + }) as any; + + expect(Route.options.id).toBe('/_authed'); + expect(Route.options.path).toBe('/authed'); + }); + + it('treats an empty id as the root index path', () => { + const Route = build(''); + + expect(Route.options.path).toBe('/'); + expect(Route.options.fullPath).toBe('/'); + }); }); diff --git a/code/frameworks/tanstack-react/src/export-mocks/react-router.ts b/code/frameworks/tanstack-react/src/export-mocks/react-router.ts index d8b70efe0e28..e389d6ec44f2 100644 --- a/code/frameworks/tanstack-react/src/export-mocks/react-router.ts +++ b/code/frameworks/tanstack-react/src/export-mocks/react-router.ts @@ -26,7 +26,7 @@ import { } from '@tanstack/react-router'; import type { Navigate as _Navigate } from '@tanstack/react-router'; import { onNavigate } from './spies.ts'; -import { normalizeFileRoutePath } from '../routing/path-utils.ts'; +import { isPathlessFileRouteId, normalizeFileRoutePath } from '../routing/path-utils.ts'; // Mock navigation hooks — backed by real implementations so they work in stories export const useNavigate = fn(_useNavigate).mockName('@tanstack/react-router::useNavigate'); @@ -83,17 +83,22 @@ export const Link = ({ * because the org `createFileRoute` doesn't set the path in the Route */ export function createFileRoute(path: string) { - const normalizedPath = normalizeFileRoutePath(path); - return (options: any) => { + // A pure-pathless id (`/_authed`, `/(group)`) yields an id-only route: a + // layout with `path: '/'` never matches its children and collides with a + // sibling index route (see routing/duplicate-tree.test.ts). An explicit + // `path` in the options keeps the route pathful and wins over the id. + const pathless = isPathlessFileRouteId(path) && options?.path == null; + const routePath = options?.path ?? normalizeFileRoutePath(path); return createRoute({ - path: normalizedPath, + ...(pathless ? { id: path } : { path: routePath }), ...options, isRoot: false, }).update({ + // routeTree.gen re-updates these later; set them here so route files + // imported without the generated tree still carry their identity id: path, - path: normalizedPath, - fullPath: normalizedPath, + ...(pathless ? {} : { path: routePath, fullPath: routePath }), // any because tanstack router does that } as any); }; diff --git a/code/frameworks/tanstack-react/src/export-mocks/start.test.ts b/code/frameworks/tanstack-react/src/export-mocks/start.test.ts new file mode 100644 index 000000000000..e91c20e2b49e --- /dev/null +++ b/code/frameworks/tanstack-react/src/export-mocks/start.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { createServerFn } from './start.ts'; + +type MockCreateServerFnBuilder = { + validator: (validator: (input: unknown) => unknown) => { + handler: (handlerFn: () => Promise) => () => Promise; + }; +}; + +describe('createServerFn', () => { + it('supports TanStack Start validator chain syntax', async () => { + const serverFn = (createServerFn() as unknown as MockCreateServerFnBuilder) + .validator((input: unknown) => input) + .handler(async () => 'ok'); + + await expect(serverFn()).resolves.toBe('ok'); + }); +}); diff --git a/code/frameworks/tanstack-react/src/export-mocks/start.ts b/code/frameworks/tanstack-react/src/export-mocks/start.ts index b6c5cfe5e0ad..74f27199d0eb 100644 --- a/code/frameworks/tanstack-react/src/export-mocks/start.ts +++ b/code/frameworks/tanstack-react/src/export-mocks/start.ts @@ -588,6 +588,10 @@ function createMockServerFnBuilder(): any { return createMockServerFnBuilder(); }; + builder.validator = () => { + return createMockServerFnBuilder(); + }; + builder.handler = (handlerFn?: (...args: any[]) => any) => { const mock = fn().mockName('@tanstack/start-client-core::createServerFn.handler()'); @@ -630,6 +634,9 @@ export const Navigate = ({ to }: { to: string }) => { return null; }; +export const Hydrate = ({ children }: { children?: React.ReactNode; [key: string]: unknown }) => + children ?? null; + export const notFound = () => { throw new Error('Not found'); }; diff --git a/code/frameworks/tanstack-react/src/plugins/server-code-elimination.test.ts b/code/frameworks/tanstack-react/src/plugins/server-code-elimination.test.ts index 927609d7700d..52b81a17d8ed 100644 --- a/code/frameworks/tanstack-react/src/plugins/server-code-elimination.test.ts +++ b/code/frameworks/tanstack-react/src/plugins/server-code-elimination.test.ts @@ -285,6 +285,62 @@ describe('serverCodeEliminationPlugin', () => { expect(result!.code).not.toContain('secret'); }); + it('keeps imports only referenced in JSX after stripping the server option', async () => { + const code = [ + `import { createFileRoute } from '@tanstack/react-router';`, + `import { ImportedPanel } from '../components/ImportedPanel';`, + `export const Route = createFileRoute('/demo')({`, + ` component: RouteComponent,`, + ` server: { handlers: { default: async () => new Response('ok') } },`, + `});`, + `function RouteComponent() {`, + ` return ;`, + `}`, + ].join('\n'); + const result = await transform(code, '/project/src/routes/demo.tsx'); + expect(result).not.toBeNull(); + expect(result!.code).not.toMatch(/\bserver:\s*\{/); + expect(result!.code).toMatch(/import\s*\{\s*ImportedPanel\s*\}\s*from/); + }); + + it('keeps same-file components referenced only from JSX', async () => { + const code = [ + `import { createFileRoute } from '@tanstack/react-router';`, + `import { createMiddleware } from '@tanstack/react-start';`, + `const noopMiddleware = createMiddleware().server(async ({ next }) => next());`, + `export const Route = createFileRoute('/fails')({`, + ` component: Component,`, + ` server: { middleware: [noopMiddleware] },`, + `});`, + `function Component() {`, + ` return ;`, + `}`, + `function NestedComponent() {`, + ` return
Nested Component
;`, + `}`, + ].join('\n'); + const result = await transform(code, '/project/src/routes/fails.tsx'); + expect(result).not.toBeNull(); + expect(result!.code).toContain('function NestedComponent'); + }); + + it('keeps imports referenced via a JSX member expression (Namespace.Child)', async () => { + const code = [ + `import { createFileRoute } from '@tanstack/react-router';`, + `import { UI } from '../components/ui';`, + `export const Route = createFileRoute('/demo')({`, + ` component: RouteComponent,`, + ` server: { handler: async () => ({}) },`, + `});`, + `function RouteComponent() {`, + ` return ;`, + `}`, + ].join('\n'); + const result = await transform(code, '/project/src/routes/demo.tsx'); + expect(result).not.toBeNull(); + expect(result!.code).toMatch(/import\s*\{\s*UI\s*\}\s*from/); + }); + it('keeps still-referenced imports from a partially-used declaration', async () => { const code = [ `import { createServerOnlyFn, useSomething } from '@tanstack/react-start';`, diff --git a/code/frameworks/tanstack-react/src/plugins/server-code-elimination.ts b/code/frameworks/tanstack-react/src/plugins/server-code-elimination.ts index 41d237e4ac21..2cb268710d1a 100644 --- a/code/frameworks/tanstack-react/src/plugins/server-code-elimination.ts +++ b/code/frameworks/tanstack-react/src/plugins/server-code-elimination.ts @@ -1,5 +1,5 @@ -import { transformSync, types as t, type NodePath } from 'storybook/internal/babel'; -import { type Statement } from '@babel/types'; +import { type JSXIdentifier, type JSXOpeningElement, type Statement } from '@babel/types'; +import { types as t, transformSync, type NodePath } from 'storybook/internal/babel'; import type { Plugin } from 'vite'; interface TransformState { @@ -387,6 +387,13 @@ function stripServerOption(options: import('@babel/types').ObjectExpression): bo return options.properties.length !== initialLength; } +function getJsxRootIdentifier(name: JSXOpeningElement['name']): JSXIdentifier | null { + while (t.isJSXMemberExpression(name)) { + name = name.object; + } + return t.isJSXIdentifier(name) ? name : null; +} + /** * Collect all non-binding identifier references in the program. * Excludes binding sites (declarations) and import specifiers. @@ -406,6 +413,12 @@ function collectReferencedIdentifiers( } referenced.add(node.name); }, + JSXOpeningElement(path) { + const root = getJsxRootIdentifier(path.node.name); + if (root) { + referenced.add(root.name); + } + }, }); return referenced; } diff --git a/code/frameworks/tanstack-react/src/routing/decorator.test.ts b/code/frameworks/tanstack-react/src/routing/decorator.test.ts new file mode 100644 index 000000000000..9eb6aeae77e2 --- /dev/null +++ b/code/frameworks/tanstack-react/src/routing/decorator.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { createRootRoute, createRoute } from '@tanstack/react-router'; + +import { createStoryRouter } from './decorator.tsx'; + +// Regression coverage for mounting a story directly on a pathless layout +// nested under a pathful ancestor. Cloned routes aren't `init()`ed until +// `createRouter()` runs, so `leaf.fullPath` is unusable for path inference at +// the point `createStoryRouter` needs it — the mount path must come from +// walking the cloned parent chain's options instead (`mountPathFor`). + +function fakeContext(route: unknown, extraRouterParams: Record = {}) { + return { + id: 'test--direct-pathless', + route: undefined, + parameters: { tanstack: { router: { route, ...extraRouterParams } } }, + } as any; +} + +describe('createStoryRouter with a pathless layout nested under a pathful ancestor', () => { + it('mounts at the pathful ancestor URL and matches the layout', async () => { + const root = createRootRoute(); + const products = createRoute({ path: '/products', getParentRoute: () => root }); + const layout = createRoute({ id: 'authed', getParentRoute: () => products }); + const settings = createRoute({ path: '/settings', getParentRoute: () => layout }); + layout.addChildren([settings]); + products.addChildren([layout]); + root.addChildren([products]); + + const router = createStoryRouter({ + Story: () => null, + context: fakeContext(layout), + }); + await router.load(); + + const ids = router.state.matches.map((m: any) => m.routeId).join(','); + expect(ids).toContain('authed'); + expect(router.state.location.pathname).toBe('/products'); + }); + + it('mounts a pathless layout directly under root at "/"', async () => { + const root = createRootRoute(); + const layout = createRoute({ id: 'authed', getParentRoute: () => root }); + const settings = createRoute({ path: '/settings', getParentRoute: () => layout }); + layout.addChildren([settings]); + root.addChildren([layout]); + + const router = createStoryRouter({ + Story: () => null, + context: fakeContext(layout), + }); + await router.load(); + + const ids = router.state.matches.map((m: any) => m.routeId).join(','); + expect(ids).toContain('authed'); + expect(router.state.location.pathname).toBe('/'); + }); +}); + +// A pathless layout that already has an index child (`path: '/'` — the common +// `_authed/index.tsx` shape) must reuse that child instead of attaching a +// synthetic sibling: both would derive the same generated id and +// `createRouter` throws `Duplicate routes found`. +describe('createStoryRouter with a pathless layout that already has an index child', () => { + function buildTree() { + const root = createRootRoute(); + const products = createRoute({ path: '/products', getParentRoute: () => root }); + const layout = createRoute({ id: 'authed', getParentRoute: () => products }); + const index = createRoute({ path: '/', getParentRoute: () => layout }); + const settings = createRoute({ path: '/settings', getParentRoute: () => layout }); + layout.addChildren([index, settings]); + products.addChildren([layout]); + root.addChildren([products]); + return layout; + } + + it('reuses the existing index child (no path param)', async () => { + const router = createStoryRouter({ + Story: () => null, + context: fakeContext(buildTree()), + }); + await router.load(); + + const ids = router.state.matches.map((m: any) => m.routeId).join(','); + expect(ids).toContain('authed'); + expect(router.state.location.pathname).toBe('/products'); + }); + + it('reuses the existing index child (explicit path param at the mount URL)', async () => { + const router = createStoryRouter({ + Story: () => null, + context: fakeContext(buildTree(), { path: '/products' }), + }); + await router.load(); + + const ids = router.state.matches.map((m: any) => m.routeId).join(','); + expect(ids).toContain('authed'); + expect(router.state.location.pathname).toBe('/products'); + }); +}); diff --git a/code/frameworks/tanstack-react/src/routing/decorator.tsx b/code/frameworks/tanstack-react/src/routing/decorator.tsx index 486a13766c13..08685f89229a 100644 --- a/code/frameworks/tanstack-react/src/routing/decorator.tsx +++ b/code/frameworks/tanstack-react/src/routing/decorator.tsx @@ -16,6 +16,7 @@ import type { RouterParameters } from './types.ts'; import { duplicateRouteTree, findRootRoute, + mountPathFor, resolveStoryLeaf, type DuplicatedTree, } from './duplicate-tree.ts'; @@ -76,7 +77,7 @@ function TanStackRouterStory({ Story, context }: TanStackRouterStoryProps) { ); } -function createStoryRouter({ +export function createStoryRouter({ Story, context, routerContext, @@ -85,20 +86,15 @@ function createStoryRouter({ 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 normalized `leaf.id` (defensive fallback: a leaf may carry an unnormalized id - // like `/(group)/page` with an empty `fullPath`, which would otherwise silently - // coerce to `/`). - // 4. The full path of the first child of the route tree, if it exists (e.g. the Story's parent route) - // 5. `/` as a last resort + // Infer the initial path for the router. Cloned routes are not yet + // `init()`ed here, so the `fullPath` getter is only usable for routes that + // already lived in an initialized router; `mountPathFor` walks the cloned + // parent chain's options instead and always yields a mountable URL. const inferredPath = routerParameters?.path || leaf.fullPath || (leaf.id ? normalizeFileRoutePath(leaf.id) : undefined) || - (routeTree.children as AnyRoute[] | undefined)?.[0]?.fullPath || - '/'; + mountPathFor(leaf); // Interpolate params into the path and append query/search params. let resolvedPath = interpolatePath({ @@ -128,6 +124,43 @@ function createStoryRouter({ }); } +/** + * A pathless layout (explicit `id`, no `path`) only matches when one of its + * children matches. If a story is bound directly to such a route, return an + * index child (`path: '/'`) under it so the layout becomes matchable and path + * inference resolves to a real URL: reuse the layout's existing index child + * when it has one (the common `_authed/index.tsx` shape — attaching a + * synthetic sibling would derive the same generated id and make + * `createRouter` throw `Duplicate routes found`), otherwise mount a synthetic + * bare index child. The story itself still renders at the layout's position — + * `injectStoryComponent` already replaced the layout's own `component`. + */ +function ensureMatchableLeaf(tree: DuplicatedTree, leaf: AnyRoute): AnyRoute { + const isPathlessLeaf = + leaf !== (tree.root as unknown as AnyRoute) && + !(leaf as any).options?.path && + (leaf as any).options?.id != null; + if (!isPathlessLeaf) { + return leaf; + } + const existingIndexChild = (((leaf as any).children as AnyRoute[] | undefined) ?? []).find( + (child) => (child as any).options?.path === '/' + ); + if (existingIndexChild) { + return existingIndexChild; + } + const syntheticLeaf = createRoute({ + path: '/', + component: () => null, + getParentRoute: () => leaf as any, + } as any); + (leaf as any).addChildren([ + ...(((leaf as any).children as AnyRoute[] | undefined) ?? []), + syntheticLeaf, + ]); + return syntheticLeaf as unknown as AnyRoute; +} + function injectStoryComponent( leaf: AnyRoute, Story: ComponentType, @@ -181,7 +214,8 @@ function resolveTree(Story: ComponentType, context: Parameters[1]): R }); injectStoryComponent(leaf, Story, routeOverrides, leaf.id); - return { tree, leaf }; + const renderLeaf = ensureMatchableLeaf(tree, leaf); + return { tree, leaf: renderLeaf }; } if (isRoute(routerParameterRoute)) { @@ -196,7 +230,8 @@ function resolveTree(Story: ComponentType, context: Parameters[1]): R const tree = duplicateRouteTree(syntheticRoot, { overrides: routeOverrides }); const leaf = tree.byId.get(routerParameterRoute.id) ?? tree.root; injectStoryComponent(leaf, Story, routeOverrides, leaf.id); - return { tree, leaf }; + const renderLeaf = ensureMatchableLeaf(tree, leaf); + return { tree, leaf: renderLeaf }; } // No route instance — build a synthetic root + child from plain options. diff --git a/code/frameworks/tanstack-react/src/routing/duplicate-tree.test.ts b/code/frameworks/tanstack-react/src/routing/duplicate-tree.test.ts new file mode 100644 index 000000000000..c9860e7120a4 --- /dev/null +++ b/code/frameworks/tanstack-react/src/routing/duplicate-tree.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router'; +import { fn } from 'storybook/test'; + +import { createFileRoute } from '../export-mocks/react-router.ts'; +import { duplicateRouteTree } from './duplicate-tree.ts'; + +async function matchedRouteIds(routeTree: any, path: string): Promise> { + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [path] }), + }); + await router.load(); + return router.state.matches.map((match) => match.routeId); +} + +/** Builds root -> `/_authed` (pathless layout) -> `/_authed/dashboard`. */ +function buildAuthedTree(layoutOptions: Record = {}) { + const root = createRootRoute(); + const layout = createFileRoute('/_authed')({ + getParentRoute: () => root, + ...layoutOptions, + }) as any; + const dashboard = createFileRoute('/_authed/dashboard')({ + getParentRoute: () => layout, + }) as any; + layout.addChildren([dashboard]); + root.addChildren([layout]); + return { root, layout, dashboard }; +} + +describe('duplicateRouteTree with pathless layout routes', () => { + it('preserves a code-based pathless layout (explicit id, no path)', async () => { + const root = createRootRoute(); + const layout = createRoute({ id: 'authed', getParentRoute: () => root }); + const dashboard = createRoute({ path: '/dashboard', getParentRoute: () => layout }); + layout.addChildren([dashboard]); + root.addChildren([layout]); + + const { root: cloned } = duplicateRouteTree(root as any); + const ids = await matchedRouteIds(cloned, '/dashboard'); + + expect(ids.join(',')).toContain('dashboard'); + }); + + it('preserves a file-based pathless layout from createFileRoute', async () => { + const { root } = buildAuthedTree(); + + const { root: cloned } = duplicateRouteTree(root as any); + const ids = await matchedRouteIds(cloned, '/dashboard'); + + expect(ids.join(',')).toContain('dashboard'); + }); + + it('does not let a pathless layout shadow a sibling index route', async () => { + const { root, layout } = buildAuthedTree(); + const index = createRoute({ path: '/', getParentRoute: () => root }); + root.addChildren([index, layout]); + + const { root: cloned } = duplicateRouteTree(root as any); + + expect((await matchedRouteIds(cloned, '/dashboard')).join(',')).toContain('dashboard'); + expect(await matchedRouteIds(cloned, '/')).toContain('/'); + }); + + it('lets routeOverrides give a pathless layout a real path without crashing', async () => { + const { root } = buildAuthedTree(); + + const { root: cloned } = duplicateRouteTree(root as any, { + overrides: { '/_authed': { path: '/authed' } } as any, + }); + + expect((await matchedRouteIds(cloned, '/authed/dashboard')).join(',')).toContain('dashboard'); + }); + + it('routes through a pathless layout nested under a pathful segment', async () => { + // Mirror routeTree.gen output: nested routes are re-updated with + // parent-relative ids/paths; the pathless layout keeps id only. + const root = createRootRoute(); + const posts = createFileRoute('/posts')({ getParentRoute: () => root }) as any; + const layout = ( + createFileRoute('/posts/_layout')({ getParentRoute: () => posts }) as any + ).update({ id: '/_layout', getParentRoute: () => posts } as any); + const settings = ( + createFileRoute('/posts/_layout/settings')({ getParentRoute: () => layout }) as any + ).update({ id: '/settings', path: '/settings', getParentRoute: () => layout } as any); + layout.addChildren([settings]); + posts.addChildren([layout]); + root.addChildren([posts]); + + const { root: cloned } = duplicateRouteTree(root as any); + + expect((await matchedRouteIds(cloned, '/posts/settings')).join(',')).toContain('settings'); + }); +}); + +describe('duplicateRouteTree matrix (code-based and file-based trees)', () => { + it('clones nested pathful layouts with an index child (code-based)', async () => { + const root = createRootRoute(); + const users = createRoute({ path: '/users', getParentRoute: () => root }); + const list = createRoute({ path: '/', getParentRoute: () => users }); + const detail = createRoute({ path: '/$userId', getParentRoute: () => users }); + users.addChildren([list, detail]); + root.addChildren([users]); + + const { root: cloned } = duplicateRouteTree(root as any); + + expect((await matchedRouteIds(cloned, '/users')).join(',')).toContain('/users'); + expect((await matchedRouteIds(cloned, '/users/42')).join(',')).toContain('$userId'); + }); + + it('exposes path params through the cloned tree', async () => { + const root = createRootRoute(); + const detail = createRoute({ path: '/users/$userId', getParentRoute: () => root }); + root.addChildren([detail]); + + const { root: cloned } = duplicateRouteTree(root as any); + const router = createRouter({ + routeTree: cloned, + history: createMemoryHistory({ initialEntries: ['/users/42'] }), + }); + await router.load(); + + const match = router.state.matches.find((m) => m.routeId.includes('$userId')); + expect(match?.params).toEqual({ userId: '42' }); + }); + + it('clones file-based trees mixing group, layout, and param segments', async () => { + // Mirror what routeTree.gen.ts emits: `createFileRoute` sets absolute + // normalized paths, then the generated tree rewrites nested routes to + // parent-relative ids/paths via `.update()`. + const root = createRootRoute(); + const group = createFileRoute('/(app)')({ getParentRoute: () => root }) as any; + const posts = (createFileRoute('/(app)/posts')({ getParentRoute: () => group }) as any).update({ + id: '/posts', + path: '/posts', + getParentRoute: () => group, + } as any); + const post = ( + createFileRoute('/(app)/posts/$postId')({ getParentRoute: () => posts }) as any + ).update({ + id: '/$postId', + path: '/$postId', + getParentRoute: () => posts, + } as any); + posts.addChildren([post]); + group.addChildren([posts]); + root.addChildren([group]); + + const { root: cloned } = duplicateRouteTree(root as any); + + expect((await matchedRouteIds(cloned, '/posts/7')).join(',')).toContain('$postId'); + }); + + it('applies routeOverrides to cloned pathless routes by original id', async () => { + const original = fn(); + const override = fn(); + const { root } = buildAuthedTree({ beforeLoad: original }); + + const { root: cloned } = duplicateRouteTree(root as any, { + overrides: { + '/_authed': { beforeLoad: override }, + } as any, + }); + await matchedRouteIds(cloned, '/dashboard'); + + expect(override).toHaveBeenCalled(); + expect(original).not.toHaveBeenCalled(); + }); +}); diff --git a/code/frameworks/tanstack-react/src/routing/duplicate-tree.ts b/code/frameworks/tanstack-react/src/routing/duplicate-tree.ts index aad658f0f22e..a6738d042c40 100644 --- a/code/frameworks/tanstack-react/src/routing/duplicate-tree.ts +++ b/code/frameworks/tanstack-react/src/routing/duplicate-tree.ts @@ -1,5 +1,10 @@ import type { AnyRootRoute, AnyRoute } from '@tanstack/react-router'; -import { createRoute, RootRoute, createRootRouteWithContext } from '@tanstack/react-router'; +import { + createRoute, + RootRoute, + createRootRouteWithContext, + joinPaths, +} from '@tanstack/react-router'; import type { RouteTreeOverrides } from './types.ts'; @@ -66,16 +71,22 @@ function cloneChild( // 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 { id: originalId, getParentRoute: _g, ...rest } = options; const override = getOverrideFor(overrides, oldRoute.id); + const merged = { ...rest, ...override }; // 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__`. + // A pathless route (explicit `id`, no `path` — checked AFTER overrides, so + // an override that adds a `path` re-derives identity from the path instead + // of crashing on TanStack's id+path invariant) has no path to derive an + // identity from; its explicit id IS its identity, so preserve it. The falsy + // check also treats `path: ''` as pathless. const cloned = createRoute({ - ...rest, - ...override, + ...(!merged.path && originalId != null ? { id: originalId } : {}), + ...merged, getParentRoute: () => parent as any, } as any); @@ -136,6 +147,30 @@ export function duplicateRouteTree( return { root: newRoot, byId }; } +/** + * URL contributed by a route's pathful ancestors (pathless segments contribute + * nothing). A bare `/` segment (an index route) likewise contributes nothing + * beyond its parent's own path — including it as a literal segment would + * leave a stray trailing slash once `joinPaths` collapses the rest. + * + * Cloned routes are not `init()`ed until `createRouter()` runs, so their + * `fullPath`/`id` getters are undefined at resolution time; this walk reads + * the cloned parent chain's options instead. + */ +export function mountPathFor(route: AnyRoute): string { + const segments: string[] = []; + let current: AnyRoute | undefined = route; + for (let i = 0; i < MAX_PARENT_WALK && current; i += 1) { + const routePath = (current as any).options?.path as string | undefined; + if (routePath && routePath !== '/') { + segments.unshift(routePath); + } + const getParent: (() => AnyRoute) | undefined = (current as any).options?.getParentRoute; + current = typeof getParent === 'function' ? getParent() : undefined; + } + return joinPaths(['/', ...segments]); +} + /** * Picks the route in the cloned tree that should host the ``. * diff --git a/code/frameworks/tanstack-react/src/routing/path-utils.ts b/code/frameworks/tanstack-react/src/routing/path-utils.ts index d92dce7af8ee..5c3c5a61af5e 100644 --- a/code/frameworks/tanstack-react/src/routing/path-utils.ts +++ b/code/frameworks/tanstack-react/src/routing/path-utils.ts @@ -26,3 +26,21 @@ export function normalizeFileRoutePath(path: string): string { const stripped = removeGroups(removeUnderscores(removeLayoutSegments(path)) ?? ''); return stripped || '/'; } + +function isPathlessSegment(segment: string): boolean { + return segment.startsWith('_') || (segment.startsWith('(') && segment.endsWith(')')); +} + +/** + * A file route whose final segment is pathless (`_layout`, `(group)`) defines + * a layout/group and contributes nothing to the URL itself; such a route is + * identified by `id` only. This covers pure-pathless ids (`/_authed`, + * `/(group)`) as well as pathless layouts nested under pathful segments + * (`/posts/_layout`). `/` and `''` are the root index, not pathless, and a + * trailing-underscore (un-nesting) segment like `posts_` stays pathful. + */ +export function isPathlessFileRouteId(id: string): boolean { + const segments = id.split('/').filter(Boolean); + const lastSegment = segments[segments.length - 1]; + return lastSegment != null && isPathlessSegment(lastSegment); +} diff --git a/code/frameworks/tanstack-react/template/stories/PathlessLayout.stories.tsx b/code/frameworks/tanstack-react/template/stories/PathlessLayout.stories.tsx new file mode 100644 index 000000000000..b37bd6897f7f --- /dev/null +++ b/code/frameworks/tanstack-react/template/stories/PathlessLayout.stories.tsx @@ -0,0 +1,150 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/tanstack-react'; + +import { Outlet, createRootRoute, createRoute } from '@tanstack/react-router'; +import { expect, fn, within } from 'storybook/test'; + +// Regression coverage for pathless layout routes (`_authed`-style underscore +// files and `(group)` directories). The layout must render and its +// `beforeLoad` must run, while contributing nothing to the URL: the story +// mounts at `/dashboard`, not `/_authed/dashboard`. + +function LeafContent() { + return

leaf rendered under a pathless layout

; +} + +const onBeforeLoad = fn(); + +const RootRoute = createRootRoute({ + component: () => ( +
+

root layout

+ +
+ ), +}); + +const PathlessLayout = createRoute({ + id: 'authed', + getParentRoute: () => RootRoute, + beforeLoad: onBeforeLoad, + component: () => ( +
+ +
+ ), +}); + +const DashboardRoute = createRoute({ + path: '/dashboard', + getParentRoute: () => PathlessLayout, + component: LeafContent, +}); + +PathlessLayout.addChildren([DashboardRoute]); + +// Regression coverage for a pathless layout nested under a pathful ancestor +// (e.g. `/products/_authed`): the layout's mount URL must be inferred from +// its pathful ancestor, not just '/'. + +const onNestedBeforeLoad = fn(); + +function ProductsLayout() { + return ( +
+ +
+ ); +} + +const ProductsRoute = createRoute({ + path: '/products', + getParentRoute: () => RootRoute, + component: ProductsLayout, +}); + +const NestedPathlessLayout = createRoute({ + id: 'nested-authed', + getParentRoute: () => ProductsRoute, + beforeLoad: onNestedBeforeLoad, + component: () => ( +
+ +
+ ), +}); + +const NestedSettingsRoute = createRoute({ + path: '/settings', + getParentRoute: () => NestedPathlessLayout, + component: LeafContent, +}); + +NestedPathlessLayout.addChildren([NestedSettingsRoute]); +ProductsRoute.addChildren([NestedPathlessLayout]); + +// A single `addChildren` call per parent: it replaces (not appends to) the +// children array, so `RootRoute`'s two top-level branches must be registered +// together. +RootRoute.addChildren([PathlessLayout, ProductsRoute]); + +const meta = { + component: LeafContent, + parameters: { layout: 'fullscreen' }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** The leaf mounts at the layout-free URL, with every ancestor layout rendered. */ +export const RendersLeafThroughPathlessLayout: Story = { + parameters: { + tanstack: { router: { route: DashboardRoute, path: '/dashboard' } }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId('pathless-root')).toBeInTheDocument(); + await expect(canvas.getByTestId('pathless-layout')).toBeInTheDocument(); + await expect(canvas.getByTestId('pathless-leaf')).toBeInTheDocument(); + await expect(onBeforeLoad).toHaveBeenCalled(); + }, +}; + +/** Binding the layout route itself (no path) mounts the story at the layout's position. */ +export const BoundDirectlyToPathlessLayout: Story = { + parameters: { + tanstack: { router: { route: PathlessLayout } }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // injectStoryComponent replaces the layout's own `component` with the + // story's render (LeafContent), so `pathless-layout` itself is gone — + // assert the root layout plus the leaf content that now renders in its place. + await expect(canvas.getByTestId('pathless-root')).toBeInTheDocument(); + await expect(canvas.getByTestId('pathless-leaf')).toBeInTheDocument(); + }, +}; + +/** + * A pathless layout nested under a pathful ancestor (`/products`). Binding + * the story directly to the nested layout must mount it at `/products` + * (inferred from the ancestor's path), with the root and the `/products` + * ancestor both rendered around the story's own content. + */ +export const BoundDirectlyToNestedPathlessLayout: Story = { + parameters: { + tanstack: { router: { route: NestedPathlessLayout } }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId('pathless-root')).toBeInTheDocument(); + await expect(canvas.getByTestId('products-layout')).toBeInTheDocument(); + // injectStoryComponent replaces the nested layout's own `component`, so + // `nested-pathless-layout` itself is gone — assert the leaf content that + // now renders in its place instead. + await expect(canvas.getByTestId('pathless-leaf')).toBeInTheDocument(); + await expect(onNestedBeforeLoad).toHaveBeenCalled(); + }, +}; diff --git a/code/package.json b/code/package.json index a1ec3c6d95c0..43e41a4fda4f 100644 --- a/code/package.json +++ b/code/package.json @@ -197,5 +197,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.5.2" } diff --git a/docs/get-started/frameworks/angular-vite.mdx b/docs/get-started/frameworks/angular-vite.mdx index 9e86f9267c87..406ad52a1cd3 100644 --- a/docs/get-started/frameworks/angular-vite.mdx +++ b/docs/get-started/frameworks/angular-vite.mdx @@ -195,7 +195,54 @@ export default config; ### TypeScript paths -Storybook reads your `tsconfig.json`'s `baseUrl` and `paths` settings and maps them into Vite's resolver, so TypeScript path aliases work without additional configuration. +Unlike the Webpack-based `@storybook/angular`, this framework does not automatically map your `tsconfig.json` `paths` aliases into Vite's module resolver. Vite resolves modules on disk and does not read the `paths` compiler option, so imports such as `@app/shared` will fail unless you register them yourself. + +The simplest fix is the [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths) plugin, which reads `baseUrl` and `paths` from your `tsconfig.json` and adds the matching aliases: + +```ts title=".storybook/main.ts" +import type { StorybookConfig } from '@storybook/angular-vite'; + +const config: StorybookConfig = { + framework: '@storybook/angular-vite', + async viteFinal(config) { + const { mergeConfig } = await import('vite'); + const { default: tsconfigPaths } = await import('vite-tsconfig-paths'); + return mergeConfig(config, { + plugins: [tsconfigPaths()], + }); + }, +}; + +export default config; +``` + +Install it as a dev dependency first: `npm install --save-dev vite-tsconfig-paths`. If you prefer not to add a plugin, you can instead declare the aliases explicitly under [`resolve.alias`](https://vite.dev/config/shared-options.html#resolve-alias) in the same `viteFinal` hook. + +### SCSS include paths + +Like `paths`, SCSS search paths from your application's `build` target in `angular.json` are not inherited. Configure them on the Storybook builder target instead, using `stylePreprocessorOptions`. Both the Angular-style `includePaths` and the dart-sass/Vite spelling `loadPaths` are accepted, and paths are resolved relative to the workspace root: + +```jsonc title="angular.json" +"storybook": { + "builder": "@storybook/angular-vite:start-storybook", + "options": { + "stylePreprocessorOptions": { + "includePaths": ["src/styles"], + }, + }, +}, +``` + +When running through the Storybook CLI (`storybook dev` / `storybook build`) rather than the Angular builders, there is no `angular.json` builder context to read from. In that case set the search paths directly in `.storybook/main.ts`: + +```ts title=".storybook/main.ts" +async viteFinal(config) { + const { mergeConfig } = await import('vite'); + return mergeConfig(config, { + css: { preprocessorOptions: { scss: { loadPaths: ['src/styles'] } } }, + }); +}, +``` ## Migration from `@storybook/angular`