Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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!
Expand Down
5 changes: 5 additions & 0 deletions code/addons/vitest/src/node/vitest-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions code/frameworks/angular-vite/build-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions code/frameworks/angular-vite/src/preset.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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();
});
});
11 changes: 9 additions & 2 deletions code/frameworks/angular-vite/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
7 changes: 7 additions & 0 deletions code/frameworks/angular-vite/start-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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('/');
});
});
17 changes: 11 additions & 6 deletions code/frameworks/tanstack-react/src/export-mocks/react-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
};
Expand Down
19 changes: 19 additions & 0 deletions code/frameworks/tanstack-react/src/export-mocks/start.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>) => () => Promise<string>;
};
};

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');
});
});
7 changes: 7 additions & 0 deletions code/frameworks/tanstack-react/src/export-mocks/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()');

Expand Down Expand Up @@ -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');
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ImportedPanel />;`,
`}`,
].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 <NestedComponent />;`,
`}`,
`function NestedComponent() {`,
` return <div>Nested Component</div>;`,
`}`,
].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 <UI.Panel />;`,
`}`,
].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';`,
Expand Down
Loading
Loading