diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml
index 226ee2b8ad91..ff5c96043417 100644
--- a/.github/workflows/nx.yml
+++ b/.github/workflows/nx.yml
@@ -21,13 +21,14 @@ env:
jobs:
nx:
if: >
- (github.event_name == 'pull_request' &&
- github.event.pull_request.head.repo.full_name == github.repository &&
- (contains(github.event.pull_request.labels.*.name, 'ci:normal') ||
- contains(github.event.pull_request.labels.*.name, 'ci:merged') ||
- contains(github.event.pull_request.labels.*.name, 'ci:daily'))
- ) || (github.event_name == 'push' && github.ref == 'refs/heads/next') ||
- (github.event_name == 'schedule')
+ github.repository == 'storybookjs/storybook' &&
+ ((github.event_name == 'pull_request' &&
+ github.event.pull_request.head.repo.full_name == github.repository &&
+ (contains(github.event.pull_request.labels.*.name, 'ci:normal') ||
+ contains(github.event.pull_request.labels.*.name, 'ci:merged') ||
+ contains(github.event.pull_request.labels.*.name, 'ci:daily'))
+ ) || (github.event_name == 'push' && github.ref == 'refs/heads/next') ||
+ (github.event_name == 'schedule'))
runs-on: ubuntu-latest
env:
diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md
index 505582cfd353..cc7aa61942e9 100644
--- a/CHANGELOG.prerelease.md
+++ b/CHANGELOG.prerelease.md
@@ -1,3 +1,13 @@
+## 10.3.0-alpha.8
+
+- A11y: Ensure popover dialogs have an ARIA label - [#33500](https://github.com/storybookjs/storybook/pull/33500), thanks @gayanMatch!
+- Addon-Vitest: Add channel API to programmatically trigger test runs - [#33206](https://github.com/storybookjs/storybook/pull/33206), thanks @JReinhold!
+- Builder-Vite: Centralize Vite plugins for builder-vite and addon-vitest - [#33819](https://github.com/storybookjs/storybook/pull/33819), thanks @valentinpalkovic!
+- Core: Revert Pull Request #33420 from Maelryn/fix/copy-button-overlap - [#33877](https://github.com/storybookjs/storybook/pull/33877), thanks @Sidnioulz!
+- Next.js-Vite: Fix failing postcss mutation - [#33879](https://github.com/storybookjs/storybook/pull/33879), thanks @valentinpalkovic!
+- React: Fix manifest stories empty when meta has no explicit title - [#33878](https://github.com/storybookjs/storybook/pull/33878), thanks @kasperpeulen!
+- UI: Fix Copy button overlapping code in portrait mode - [#33420](https://github.com/storybookjs/storybook/pull/33420), thanks @Maelryn!
+
## 10.3.0-alpha.7
- Core: Require token for websocket connections - [#33820](https://github.com/storybookjs/storybook/pull/33820), thanks @ghengeveld!
diff --git a/MIGRATION.md b/MIGRATION.md
index 0805c3682b8b..2b6ba2137b9c 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -627,6 +627,16 @@ The PopoverProvider component acts as a counterpoint to WithTooltip. When you wa
PopoverProvider is based on react-aria. It must have a single child that acts as a trigger. This child must have a pressable role (can be clicked or pressed) and must be able to receive React refs. Wrap your trigger component in `forwardRef` if you notice placement issues for your popover.
+##### Added: ariaLabel
+
+The `ariaLabel` prop was added in Storybook 10.3 to provide an accessible label for the popover dialog. This label is announced by screen readers when the popover opens. `ariaLabel` will become mandatory in Storybook 11.
+
+```tsx
+}>
+
+
+```
+
#### WithTooltip Component API Changes
The WithTooltip component has been reimplemented from the ground up, under the new name `TooltipProvider`. The new implementation will replace `WithTooltip` entirely in Storybook 11. Below is a summary of the changes between both APIs, which will take full effect in Storybook 11.
diff --git a/code/addons/a11y/build-config.ts b/code/addons/a11y/build-config.ts
index 318e527e3469..6313a6e261f6 100644
--- a/code/addons/a11y/build-config.ts
+++ b/code/addons/a11y/build-config.ts
@@ -23,6 +23,11 @@ const config: BuildEntries = {
entryPoint: './src/postinstall.ts',
dts: false,
},
+ {
+ exportEntries: ['./preset'],
+ entryPoint: './src/preset.ts',
+ dts: false,
+ },
],
},
};
diff --git a/code/addons/a11y/package.json b/code/addons/a11y/package.json
index 4337551f2bc3..ec11c64e135b 100644
--- a/code/addons/a11y/package.json
+++ b/code/addons/a11y/package.json
@@ -43,6 +43,7 @@
"./manager": "./dist/manager.js",
"./package.json": "./package.json",
"./postinstall": "./dist/postinstall.js",
+ "./preset": "./dist/preset.js",
"./preview": {
"types": "./dist/preview.d.ts",
"code": "./src/preview.tsx",
diff --git a/code/addons/a11y/preset.js b/code/addons/a11y/preset.js
new file mode 100644
index 000000000000..4bd63d324002
--- /dev/null
+++ b/code/addons/a11y/preset.js
@@ -0,0 +1 @@
+export * from './dist/preset.js';
diff --git a/code/addons/a11y/src/components/A11yContext.tsx b/code/addons/a11y/src/components/A11yContext.tsx
index 48d43fa92225..90c6f2f3f328 100644
--- a/code/addons/a11y/src/components/A11yContext.tsx
+++ b/code/addons/a11y/src/components/A11yContext.tsx
@@ -27,7 +27,7 @@ import { convert, themes } from 'storybook/theming';
import { getFriendlySummaryForAxeResult, getTitleForAxeResult } from '../axeRuleMappingHelper';
import { ADDON_ID, EVENTS, STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST } from '../constants';
import type { A11yParameters } from '../params';
-import type { A11YReport, EnhancedResult, EnhancedResults, Status } from '../types';
+import type { A11yReport, EnhancedResult, EnhancedResults, Status } from '../types';
import { RuleType } from '../types';
import type { TestDiscrepancy } from './TestDiscrepancyMessage';
@@ -244,7 +244,7 @@ export const A11yContextProvider: FC = (props) => {
const handleReport = useCallback(
({ reporters }: StoryFinishedPayload) => {
- const a11yReport = reporters.find((r) => r.type === 'a11y') as Report | undefined;
+ const a11yReport = reporters.find((r) => r.type === 'a11y') as Report | undefined;
if (a11yReport) {
if ('error' in a11yReport.result) {
diff --git a/code/addons/a11y/src/index.ts b/code/addons/a11y/src/index.ts
index 5f447f93eb29..d85e433a1fc0 100644
--- a/code/addons/a11y/src/index.ts
+++ b/code/addons/a11y/src/index.ts
@@ -5,6 +5,6 @@ import type { A11yTypes } from './types';
export { PARAM_KEY } from './constants';
export * from './params';
-export type { A11yGlobals, A11yTypes } from './types';
+export type { A11yGlobals, A11yTypes, A11yReport } from './types';
export default () => definePreviewAddon(addonAnnotations);
diff --git a/code/addons/a11y/src/preset.ts b/code/addons/a11y/src/preset.ts
new file mode 100644
index 000000000000..040808d5d523
--- /dev/null
+++ b/code/addons/a11y/src/preset.ts
@@ -0,0 +1,3 @@
+// enables other addons/presets to detect if a11y is enabled and adjust their behavior accordingly
+// using await presets.apply('isAddonA11yEnabled', false);
+export const isAddonA11yEnabled = true;
diff --git a/code/addons/a11y/src/types.ts b/code/addons/a11y/src/types.ts
index c8be7acf7d0e..73a010e48138 100644
--- a/code/addons/a11y/src/types.ts
+++ b/code/addons/a11y/src/types.ts
@@ -2,7 +2,7 @@ import type { AxeResults, NodeResult, Result } from 'axe-core';
import type { A11yParameters as A11yParams } from './params';
-export type A11YReport = EnhancedResults | { error: Error };
+export type A11yReport = EnhancedResults | { error: Error };
export interface A11yParameters {
/**
diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx
index 1be3d2075744..66a723c592ad 100644
--- a/code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx
+++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx
@@ -203,6 +203,7 @@ const ArgSummary: FC = ({ value, initialExpandedArgs }) => {
return (
{
diff --git a/code/addons/docs/src/blocks/controls/Color.tsx b/code/addons/docs/src/blocks/controls/Color.tsx
index 3c3610c21621..0758f7b8611c 100644
--- a/code/addons/docs/src/blocks/controls/Color.tsx
+++ b/code/addons/docs/src/blocks/controls/Color.tsx
@@ -393,6 +393,7 @@ export const ColorControl: FC = ({
placeholder="Choose color..."
/>
color && addPreset(color)}
diff --git a/code/addons/vitest/build-config.ts b/code/addons/vitest/build-config.ts
index db187f466942..9e5f242e6329 100644
--- a/code/addons/vitest/build-config.ts
+++ b/code/addons/vitest/build-config.ts
@@ -24,6 +24,10 @@ const config: BuildEntries = {
},
],
node: [
+ {
+ exportEntries: ['./constants'],
+ entryPoint: './src/constants.ts',
+ },
{
exportEntries: ['./preset'],
entryPoint: './src/preset.ts',
diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json
index 8358acdc5c0b..be2be1eea930 100644
--- a/code/addons/vitest/package.json
+++ b/code/addons/vitest/package.json
@@ -43,6 +43,11 @@
"code": "./src/index.ts",
"default": "./dist/index.js"
},
+ "./constants": {
+ "types": "./dist/constants.d.ts",
+ "code": "./src/constants.ts",
+ "default": "./dist/constants.js"
+ },
"./internal/coverage-reporter": "./dist/node/coverage-reporter.js",
"./internal/global-setup": "./dist/vitest-plugin/global-setup.js",
"./internal/setup-file": "./dist/vitest-plugin/setup-file.js",
@@ -74,6 +79,7 @@
"@storybook/icons": "^2.0.1"
},
"devDependencies": {
+ "@storybook/addon-a11y": "workspace:*",
"@types/istanbul-lib-report": "^3.0.3",
"@types/micromatch": "^4.0.0",
"@types/node": "^22.19.1",
diff --git a/code/addons/vitest/src/constants.ts b/code/addons/vitest/src/constants.ts
index 0a8fb9d3a9ad..e49ef528030e 100644
--- a/code/addons/vitest/src/constants.ts
+++ b/code/addons/vitest/src/constants.ts
@@ -1,6 +1,6 @@
import type { StoreOptions } from 'storybook/internal/types';
-import type { RunTrigger, StoreState } from './types';
+import type { CurrentRun, RunTrigger, StoreState } from './types';
export { PANEL_ID as COMPONENT_TESTING_PANEL_ID } from '../../../core/src/component-testing/constants';
export {
@@ -28,7 +28,7 @@ export const storeOptions = {
watching: false,
cancelling: false,
fatalError: undefined,
- indexUrl: undefined,
+ index: { entries: {}, v: 5 },
previewAnnotations: [],
currentRun: {
triggeredBy: undefined,
@@ -36,6 +36,9 @@ export const storeOptions = {
coverage: false,
a11y: false,
},
+ componentTestStatuses: [],
+ a11yStatuses: [],
+ a11yReports: {},
componentTestCount: {
success: 0,
error: 0,
@@ -63,3 +66,26 @@ export const TEST_PROVIDER_STORE_CHANNEL_EVENT_NAME = 'UNIVERSAL_STORE:storybook
export const STATUS_TYPE_ID_COMPONENT_TEST = 'storybook/component-test';
export const STATUS_TYPE_ID_A11Y = 'storybook/a11y';
+
+// Channel event names for programmatic test triggering
+export const TRIGGER_TEST_RUN_REQUEST = `${ADDON_ID}/trigger-test-run-request`;
+export const TRIGGER_TEST_RUN_RESPONSE = `${ADDON_ID}/trigger-test-run-response`;
+
+export type TriggerTestRunRequestPayload = {
+ requestId: string;
+ actor: string;
+ storyIds?: string[];
+ config?: Partial;
+};
+
+export type TestRunResult = CurrentRun;
+
+export type TriggerTestRunResponsePayload = {
+ requestId: string;
+ status: 'completed' | 'error' | 'cancelled';
+ result?: TestRunResult;
+ error?: {
+ message: string;
+ error?: import('./types').ErrorLike;
+ };
+};
diff --git a/code/addons/vitest/src/manager.tsx b/code/addons/vitest/src/manager.tsx
index 14e4e0815f2d..1be9319fdc4d 100644
--- a/code/addons/vitest/src/manager.tsx
+++ b/code/addons/vitest/src/manager.tsx
@@ -42,14 +42,8 @@ addons.register(ADDON_ID, (api) => {
},
});
});
- store.untilReady().then(() => {
- store.setState((state) => ({
- ...state,
- indexUrl: new URL('index.json', window.location.href).toString(),
- }));
- store.subscribe('TEST_RUN_COMPLETED', ({ payload }) => {
- api.emit(STORYBOOK_ADDON_TEST_CHANNEL, { type: 'test-run-completed', payload });
- });
+ store.subscribe('TEST_RUN_COMPLETED', ({ payload }) => {
+ api.emit(STORYBOOK_ADDON_TEST_CHANNEL, { type: 'test-run-completed', payload });
});
addons.add(TEST_PROVIDER_ID, {
diff --git a/code/addons/vitest/src/node/test-manager.test.ts b/code/addons/vitest/src/node/test-manager.test.ts
index 3a5668e645f3..736e688ab6fc 100644
--- a/code/addons/vitest/src/node/test-manager.test.ts
+++ b/code/addons/vitest/src/node/test-manager.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { TestResult } from 'vitest/node';
-import { Channel, type ChannelTransport } from 'storybook/internal/channels';
import { Tag, experimental_MockUniversalStore } from 'storybook/internal/core-server';
import type {
Options,
@@ -52,16 +52,78 @@ vi.mock('vitest/node', async (importOriginal) => ({
// Use the mock function directly
const createVitest = mockCreateVitest;
-const transport = { setHandler: vi.fn(), send: vi.fn() } satisfies ChannelTransport;
-
beforeEach(() => {
createVitest.mockResolvedValue(vitest);
});
-const mockChannel = new Channel({ transport });
+
+const mockIndex = {
+ v: 5,
+ entries: {
+ 'story--one': {
+ type: 'story',
+ subtype: 'story',
+ id: 'story--one',
+ name: 'One',
+ title: 'story/one',
+ importPath: 'path/to/file',
+ tags: [Tag.TEST],
+ },
+ 'another--one': {
+ type: 'story',
+ subtype: 'story',
+ id: 'another--one',
+ name: 'One',
+ title: 'another/one',
+ importPath: 'path/to/another/file',
+ tags: [Tag.TEST],
+ },
+ 'story--two': {
+ type: 'story',
+ subtype: 'story',
+ id: 'story--two',
+ name: 'Two',
+ title: 'story/two',
+ importPath: 'path/to/file',
+ tags: [Tag.TEST],
+ },
+ 'another--two': {
+ type: 'story',
+ subtype: 'story',
+ id: 'another--two',
+ name: 'Two B',
+ title: 'another/two',
+ importPath: 'path/to/another/file',
+ tags: [Tag.TEST],
+ },
+ 'parent--story': {
+ type: 'story',
+ subtype: 'story',
+ id: 'parent--story',
+ name: 'Parent story',
+ title: 'parent/story',
+ importPath: 'path/to/parent/file',
+ tags: [Tag.TEST],
+ },
+ 'parent--story:test': {
+ type: 'story',
+ subtype: Tag.TEST,
+ id: 'parent--story:test',
+ name: 'Test name',
+ title: 'parent/story',
+ parent: 'parent--story',
+ importPath: 'path/to/parent/file',
+ tags: [Tag.TEST, Tag.TEST_FN],
+ },
+ },
+} as StoryIndex;
+
const mockStore = new experimental_MockUniversalStore(
{
...storeOptions,
- initialState: { ...storeOptions.initialState, indexUrl: 'http://localhost:6006/index.json' },
+ initialState: {
+ ...storeOptions.initialState,
+ index: mockIndex,
+ },
},
vi
);
@@ -102,54 +164,6 @@ const tests = [
},
];
-global.fetch = vi.fn().mockResolvedValue({
- json: () =>
- new Promise((resolve) =>
- resolve({
- v: 5,
- entries: {
- 'story--one': {
- type: 'story',
- subtype: 'story',
- id: 'story--one',
- name: 'One',
- title: 'story/one',
- importPath: 'path/to/file',
- tags: [Tag.TEST],
- },
- 'another--one': {
- type: 'story',
- subtype: 'story',
- id: 'another--one',
- name: 'One',
- title: 'another/one',
- importPath: 'path/to/another/file',
- tags: [Tag.TEST],
- },
- 'parent--story': {
- type: 'story',
- subtype: 'story',
- id: 'parent--story',
- name: 'Parent story',
- title: 'parent/story',
- importPath: 'path/to/parent/file',
- tags: [Tag.TEST],
- },
- 'parent--story:test': {
- type: 'story',
- subtype: Tag.TEST,
- id: 'parent--story:test',
- name: 'Test name',
- title: 'parent/story',
- parent: 'parent--story',
- importPath: 'path/to/parent/file',
- tags: [Tag.TEST, Tag.TEST_FN],
- },
- },
- } as StoryIndex)
- ),
-});
-
const options: TestManagerOptions = {
store: mockStore,
componentTestStatusStore: mockComponentTestStatusStore,
@@ -262,6 +276,111 @@ describe('TestManager', () => {
expect(setTestNamePattern).toHaveBeenCalledWith(new RegExp(`^Parent story${DOUBLE_SPACES}`));
});
+ it('should trigger only selected stories in the same file', async () => {
+ vitest.globTestSpecifications.mockImplementation(() => tests);
+ const testManager = await TestManager.start(options);
+
+ await testManager.handleTriggerRunEvent({
+ type: 'TRIGGER_RUN',
+ payload: {
+ storyIds: ['story--one', 'story--two'],
+ triggeredBy: 'global',
+ },
+ });
+
+ expect(vitest.runTestSpecifications).toHaveBeenCalledWith(tests.slice(0, 1), true);
+
+ const regex = setTestNamePattern.mock.calls.find(([arg]) => arg instanceof RegExp)?.[0] as
+ | RegExp
+ | undefined;
+
+ expect(regex).toBeDefined();
+ expect(regex?.test('One')).toBe(true);
+ expect(regex?.test('Two')).toBe(true);
+ expect(regex?.test('Parent story Test name')).toBe(false);
+ });
+
+ it('should trigger only selected stories across multiple files', async () => {
+ vitest.globTestSpecifications.mockImplementation(() => tests);
+ const testManager = await TestManager.start(options);
+
+ await testManager.handleTriggerRunEvent({
+ type: 'TRIGGER_RUN',
+ payload: {
+ storyIds: ['story--one', 'another--two'],
+ triggeredBy: 'global',
+ },
+ });
+
+ expect(vitest.runTestSpecifications).toHaveBeenCalledWith(tests, true);
+
+ const regex = setTestNamePattern.mock.calls.find(([arg]) => arg instanceof RegExp)?.[0] as
+ | RegExp
+ | undefined;
+
+ expect(regex).toBeDefined();
+ expect(regex?.test('One')).toBe(true);
+ expect(regex?.test('Two B')).toBe(true);
+ expect(regex?.test('Two')).toBe(false);
+ });
+
+ it('should ignore non-requested same-name story results after run', async () => {
+ const testManager = await TestManager.start(options);
+ const passedResult = { state: 'passed', errors: [] } as unknown as TestResult;
+
+ await testManager.runTestsWithState({
+ storyIds: ['story--one', 'another--two'],
+ triggeredBy: 'global',
+ callback: async () => {
+ testManager.onTestCaseResult({
+ storyId: 'story--one',
+ testResult: passedResult,
+ });
+ testManager.onTestCaseResult({
+ storyId: 'another--one',
+ testResult: passedResult,
+ });
+ testManager.onTestRunEnd({
+ totalTestCount: 2,
+ unhandledErrors: [],
+ });
+ },
+ });
+
+ expect(mockComponentTestStatusStore.set).toHaveBeenCalledWith(
+ expect.arrayContaining([expect.objectContaining({ storyId: 'story--one' })])
+ );
+ expect(mockComponentTestStatusStore.set).not.toHaveBeenCalledWith(
+ expect.arrayContaining([expect.objectContaining({ storyId: 'another--one' })])
+ );
+ expect(mockStore.getState().currentRun.totalTestCount).toBe(1);
+ });
+
+ it('should keep child test results when parent story is requested', async () => {
+ const testManager = await TestManager.start(options);
+ const passedResult = { state: 'passed', errors: [] } as unknown as TestResult;
+
+ await testManager.runTestsWithState({
+ storyIds: ['parent--story'],
+ triggeredBy: 'global',
+ callback: async () => {
+ testManager.onTestCaseResult({
+ storyId: 'parent--story:test',
+ testResult: passedResult,
+ });
+ testManager.onTestRunEnd({
+ totalTestCount: 1,
+ unhandledErrors: [],
+ });
+ },
+ });
+
+ expect(mockComponentTestStatusStore.set).toHaveBeenCalledWith(
+ expect.arrayContaining([expect.objectContaining({ storyId: 'parent--story:test' })])
+ );
+ expect(mockStore.getState().currentRun.totalTestCount).toBe(1);
+ });
+
it('should restart Vitest before a test run if coverage is enabled', async () => {
const testManager = await TestManager.start(options);
expect(createVitest).toHaveBeenCalledTimes(1);
diff --git a/code/addons/vitest/src/node/test-manager.ts b/code/addons/vitest/src/node/test-manager.ts
index f66270dfc091..7f11a90f2599 100644
--- a/code/addons/vitest/src/node/test-manager.ts
+++ b/code/addons/vitest/src/node/test-manager.ts
@@ -8,11 +8,20 @@ import type {
TestProviderStoreById,
} from 'storybook/internal/types';
+import type { A11yReport } from '@storybook/addon-a11y';
+
import { throttle } from 'es-toolkit/function';
import type { Report } from 'storybook/preview-api';
import { STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST, storeOptions } from '../constants';
-import type { RunTrigger, StoreEvent, StoreState, TriggerRunEvent, VitestError } from '../types';
+import type {
+ CurrentRun,
+ RunTrigger,
+ StoreEvent,
+ StoreState,
+ TriggerRunEvent,
+ VitestError,
+} from '../types';
import { errorToErrorLike } from '../utils';
import { VitestManager } from './vitest-manager';
@@ -82,6 +91,7 @@ export class TestManager {
await this.runTestsWithState({
storyIds: event.payload.storyIds,
triggeredBy: event.payload.triggeredBy,
+ configOverride: event.payload.configOverride,
callback: async () => {
try {
await this.vitestManager.vitestRestartPromise;
@@ -114,15 +124,19 @@ export class TestManager {
async runTestsWithState({
storyIds,
triggeredBy,
+ configOverride,
callback,
}: {
storyIds?: string[];
triggeredBy: RunTrigger;
+ configOverride?: StoreState['config'];
callback: () => Promise;
}) {
this.componentTestStatusStore.unset(storyIds);
this.a11yStatusStore.unset(storyIds);
+ const runConfig = configOverride ?? this.store.getState().config;
+
this.store.setState((s) => ({
...s,
currentRun: {
@@ -130,12 +144,12 @@ export class TestManager {
triggeredBy,
startedAt: Date.now(),
storyIds: storyIds,
- config: s.config,
+ config: runConfig,
},
}));
// set the config at the start of a test run,
// so that changing the config during the test run does not affect the currently running test run
- process.env.VITEST_STORYBOOK_CONFIG = JSON.stringify(this.store.getState().config);
+ process.env.VITEST_STORYBOOK_CONFIG = JSON.stringify(runConfig);
await this.testProviderStore.runWithState(async () => {
await callback();
@@ -165,10 +179,26 @@ export class TestManager {
return;
}
+ const requestedStoryIds = this.store.getState().currentRun.storyIds;
+ if (requestedStoryIds && !this.isRequestedStoryOrChild(storyId, requestedStoryIds)) {
+ // In focused runs, Vitest name filtering can still pick up same-named tests in other files.
+ // Drop those results here so status stores and run summaries only reflect requested stories.
+ return;
+ }
+
this.batchedTestCaseResults.push({ storyId, testResult, reports });
this.throttledFlushTestCaseResults();
}
+ private isRequestedStoryOrChild(storyId: string, requestedStoryIds: string[]) {
+ if (requestedStoryIds.includes(storyId)) {
+ return true;
+ }
+
+ const entry = this.store.getState().index.entries[storyId];
+ return entry?.type === 'story' && !!entry.parent && requestedStoryIds.includes(entry.parent);
+ }
+
/**
* Throttled function to process batched test case results.
*
@@ -187,6 +217,42 @@ export class TestManager {
const testCaseResultsToFlush = this.batchedTestCaseResults;
this.batchedTestCaseResults = [];
+ const componentTestStatuses = testCaseResultsToFlush.map(({ storyId, testResult }) => ({
+ storyId,
+ typeId: STATUS_TYPE_ID_COMPONENT_TEST,
+ value: testStateToStatusValueMap[testResult.state],
+ title: 'Component tests',
+ description: testResult.errors?.map((error) => error.stack || error.message).join('\n') ?? '',
+ sidebarContextMenu: false,
+ }));
+
+ this.componentTestStatusStore.set(componentTestStatuses);
+
+ const a11yReportsByStoryId: CurrentRun['a11yReports'] = {};
+ const a11yStatuses: typeof componentTestStatuses = [];
+
+ for (const { storyId, reports } of testCaseResultsToFlush) {
+ const storyA11yReports = reports?.filter((r) => r.type === 'a11y');
+ if (!storyA11yReports?.length) {
+ continue;
+ }
+ a11yReportsByStoryId[storyId] = storyA11yReports.map((r) => r.result) as A11yReport[];
+ for (const a11yReport of storyA11yReports) {
+ a11yStatuses.push({
+ storyId,
+ typeId: STATUS_TYPE_ID_A11Y,
+ value: testStateToStatusValueMap[a11yReport.status],
+ title: 'Accessibility tests',
+ description: '',
+ sidebarContextMenu: false,
+ });
+ }
+ }
+
+ if (a11yStatuses.length > 0) {
+ this.a11yStatusStore.set(a11yStatuses);
+ }
+
this.store.setState((s) => {
let { success: ctSuccess, error: ctError } = s.currentRun.componentTestCount;
let { success: a11ySuccess, warning: a11yWarning, error: a11yError } = s.currentRun.a11yCount;
@@ -216,6 +282,12 @@ export class TestManager {
...s.currentRun,
componentTestCount: { success: ctSuccess, error: ctError },
a11yCount: { success: a11ySuccess, warning: a11yWarning, error: a11yError },
+ componentTestStatuses: s.currentRun.componentTestStatuses.concat(componentTestStatuses),
+ a11yStatuses: s.currentRun.a11yStatuses.concat(a11yStatuses),
+ a11yReports: {
+ ...s.currentRun.a11yReports,
+ ...a11yReportsByStoryId,
+ },
// in some cases successes and errors can exceed the anticipated totalTestCount
// e.g. when testing more tests than the stories we know about upfront
// in those cases, we set the totalTestCount to the sum of successes and errors
@@ -226,52 +298,26 @@ export class TestManager {
},
};
});
-
- const componentTestStatuses = testCaseResultsToFlush.map(({ storyId, testResult }) => ({
- storyId,
- typeId: STATUS_TYPE_ID_COMPONENT_TEST,
- value: testStateToStatusValueMap[testResult.state],
- title: 'Component tests',
- description: testResult.errors?.map((error) => error.stack || error.message).join('\n') ?? '',
- sidebarContextMenu: false,
- }));
-
- this.componentTestStatusStore.set(componentTestStatuses);
-
- const a11yStatuses = testCaseResultsToFlush
- .flatMap(({ storyId, reports }) =>
- reports
- ?.filter((r) => r.type === 'a11y')
- .map((a11yReport) => ({
- storyId,
- typeId: STATUS_TYPE_ID_A11Y,
- value: testStateToStatusValueMap[a11yReport.status],
- title: 'Accessibility tests',
- description: '',
- sidebarContextMenu: false,
- }))
- )
- .filter((a11yStatus) => a11yStatus !== undefined);
-
- if (a11yStatuses.length > 0) {
- this.a11yStatusStore.set(a11yStatuses);
- }
}, 500);
onTestRunEnd(endResult: { totalTestCount: number; unhandledErrors: VitestError[] }) {
this.throttledFlushTestCaseResults.flush();
- this.store.setState((s) => ({
- ...s,
- currentRun: {
- ...s.currentRun,
- // when the test run is finished, we can set the totalTestCount to the actual number of tests run
- // this number can be lower than the total number of tests we anticipated upfront
- // e.g. when some tests where skipped without us knowing about it upfront
- totalTestCount: endResult.totalTestCount,
- unhandledErrors: endResult.unhandledErrors,
- finishedAt: Date.now(),
- },
- }));
+ this.store.setState((s) => {
+ const focusedRunTotal =
+ s.currentRun.componentTestCount.success + s.currentRun.componentTestCount.error;
+
+ return {
+ ...s,
+ currentRun: {
+ ...s.currentRun,
+ // For focused runs, keep totals aligned with filtered case results.
+ // For full runs, use Vitest's reported total.
+ totalTestCount: s.currentRun.storyIds ? focusedRunTotal : endResult.totalTestCount,
+ unhandledErrors: endResult.unhandledErrors,
+ finishedAt: Date.now(),
+ },
+ };
+ });
}
onCoverageCollected(coverageSummary: StoreState['currentRun']['coverageSummary']) {
diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts
index f787556ed4b4..018165da983c 100644
--- a/code/addons/vitest/src/node/vitest-manager.ts
+++ b/code/addons/vitest/src/node/vitest-manager.ts
@@ -10,10 +10,11 @@ import type {
import { getProjectRoot, resolvePathInStorybookCache } from 'storybook/internal/common';
import { Tag } from 'storybook/internal/core-server';
-import type { StoryId, StoryIndex, StoryIndexEntry } from 'storybook/internal/types';
+import type { StoryId, StoryIndexEntry } from 'storybook/internal/types';
import * as find from 'empathic/find';
import * as walk from 'empathic/walk';
+import { escapeRegExp } from 'es-toolkit/string';
import path, { dirname, join, normalize, resolve } from 'pathe';
// eslint-disable-next-line depend/ban-dependencies
import slash from 'slash';
@@ -103,7 +104,7 @@ export class VitestManager {
for (const file of configFiles) {
const maybe = find.any([file], { cwd: location, last: getProjectRoot() });
if (maybe && existsSync(maybe)) {
- firstVitestConfig ??= maybe;
+ firstVitestConfig ??= dirname(maybe);
const content = readFileSync(maybe, 'utf8');
if (content.includes('storybookTest') || content.includes('@storybook/addon-vitest')) {
vitestWorkspaceConfig = dirname(maybe);
@@ -218,24 +219,91 @@ export class VitestManager {
});
}
- private async fetchStories(requestStoryIds?: string[]): Promise {
- const indexUrl = this.testManager.store.getState().indexUrl;
- if (!indexUrl) {
- throw new Error(
- 'Tried to fetch stories to test, but the index URL was not set in the store yet.'
- );
+ private getStories(requestStoryIds?: string[]): StoryIndexEntry[] {
+ const index = this.testManager.store.getState().index;
+ if (requestStoryIds) {
+ const stories: StoryIndexEntry[] = [];
+ for (const id of requestStoryIds) {
+ const entry = index.entries[id];
+ if (entry?.type === 'story') {
+ stories.push(entry);
+ }
+ }
+ return stories;
}
- try {
- const index = (await Promise.race([
- fetch(indexUrl).then((res) => res.json()),
- new Promise((_, reject) => setTimeout(reject, 3000, new Error('Request took too long'))),
- ])) as StoryIndex;
- const storyIds = requestStoryIds || Object.keys(index.entries);
- return storyIds.map((id) => index.entries[id]).filter((story) => story.type === 'story');
- } catch (e: any) {
- log('Failed to fetch story index: ' + e.message);
- return [];
+ return Object.values(index.entries).filter((entry) => entry.type === 'story');
+ }
+
+ /**
+ * Builds the exact Vitest name-pattern fragment for one selected Storybook entry.
+ *
+ * The pattern differs by entry type:
+ *
+ * - Component entry (has child stories/tests): match the whole describe block prefix
+ * - Story-test entry (has parent): match "parent describe + test name" exactly
+ * - Regular story entry: match the story name exactly
+ */
+ private buildStoryTestNamePattern(
+ story: StoryIndexEntry,
+ allStories: StoryIndexEntry[],
+ storiesById: Record
+ ) {
+ const isParentStory = allStories.some((candidate) => story.id === candidate.parent);
+
+ if (isParentStory) {
+ return `^${escapeRegExp(getTestName(story.name))}`;
}
+
+ if (story.parent) {
+ const parentStory = storiesById[story.parent];
+ if (!parentStory) {
+ throw new Error(`Parent story not found for story ${story.id}`);
+ }
+
+ return `^${escapeRegExp(getTestName(parentStory.name))} ${escapeRegExp(story.name)}$`;
+ }
+
+ return `^${escapeRegExp(story.name)}$`;
+ }
+
+ /**
+ * Combines multiple per-story patterns into one global regex so Vitest can run an exact subset of
+ * tests across one or more files in a single invocation.
+ */
+ private buildTestNamePatternForStories(
+ selectedStories: StoryIndexEntry[],
+ allStories: StoryIndexEntry[]
+ ) {
+ const storiesById = Object.fromEntries(allStories.map((story) => [story.id, story])) as Record<
+ StoryId,
+ StoryIndexEntry
+ >;
+
+ const storyPatterns = [
+ ...new Set(
+ selectedStories.map((story) =>
+ this.buildStoryTestNamePattern(story, allStories, storiesById)
+ )
+ ),
+ ];
+
+ if (!storyPatterns.length) {
+ return undefined;
+ }
+
+ if (storyPatterns.length === 1) {
+ return new RegExp(storyPatterns[0]);
+ }
+
+ // Build one "OR" expression across all selected stories.
+ // Example when storyPatterns are "^One$" and "^Parent Child$":
+ // /(?:(?:^One$)|(?:^Parent Child$))/
+ //
+ // Why wrap each pattern with (?:...)?
+ // - Keeps each full, already-anchored pattern isolated as one alternative.
+ // - Prevents precedence issues when joining with `|`.
+ // - Uses non-capturing groups to avoid unnecessary capture groups.
+ return new RegExp(`(?:${storyPatterns.map((pattern) => `(?:${pattern})`).join('|')})`);
}
private filterTestSpecifications(
@@ -314,45 +382,17 @@ export class VitestManager {
await this.cancelCurrentRun();
const testSpecifications = await this.getStorybookTestSpecifications();
- const allStories = await this.fetchStories();
+ const allStories = this.getStories();
const filteredStories = runPayload.storyIds
? allStories.filter((story) => runPayload.storyIds?.includes(story.id))
: allStories;
- const isSingleStoryRun = runPayload.storyIds?.length === 1;
- if (isSingleStoryRun) {
- const selectedStory = filteredStories.find((story) => story.id === runPayload.storyIds?.[0]);
- if (!selectedStory) {
- throw new Error(`Story ${runPayload.storyIds?.[0]} not found`);
- }
-
- const storyName = selectedStory.name;
- let regex: RegExp;
-
- const isParentStory = allStories.some((story) => selectedStory.id === story.parent);
- const hasParentStory = allStories.some((story) => selectedStory.parent === story.id);
-
- if (isParentStory) {
- // Use case 1: "Single" story run on a story with tests
- // -> run all tests of that story, as storyName is a describe block
- const parentName = getTestName(selectedStory.name);
- regex = new RegExp(`^${parentName}`);
- } else if (hasParentStory) {
- // Use case 2: Single story run on a specific story test
- // in this case the regex pattern should be the story parentName + space + story.name
- const parentStory = allStories.find((story) => story.id === selectedStory.parent);
- if (!parentStory) {
- throw new Error(`Parent story not found for story ${selectedStory.id}`);
- }
-
- const parentName = getTestName(parentStory.name);
- regex = new RegExp(`^${parentName} ${storyName}$`);
- } else {
- // Use case 3: Single story run on a story without tests, should be exact match of story name
- regex = new RegExp(`^${storyName}$`);
+ if (runPayload.storyIds?.length) {
+ const regex = this.buildTestNamePatternForStories(filteredStories, allStories);
+ if (regex) {
+ this.vitest!.setGlobalTestNamePattern(regex);
}
- this.vitest!.setGlobalTestNamePattern(regex);
}
const { filteredTestSpecifications, filteredStoryIds } = this.filterTestSpecifications(
@@ -429,7 +469,7 @@ export class VitestManager {
previewAnnotationSpecifications.concat(setupFilesSpecifications);
const testSpecifications = await this.getStorybookTestSpecifications();
- const allStories = await this.fetchStories();
+ const allStories = this.getStories();
let affectsGlobalFiles = false;
diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts
index c95d534cd96b..bb0f035959c7 100644
--- a/code/addons/vitest/src/preset.ts
+++ b/code/addons/vitest/src/preset.ts
@@ -1,4 +1,3 @@
-import { readFileSync } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import type { Channel } from 'storybook/internal/channels';
@@ -9,9 +8,11 @@ import {
resolvePathInStorybookCache,
} from 'storybook/internal/common';
import {
+ type StoryIndexGenerator,
experimental_UniversalStore,
experimental_getTestProviderStore,
} from 'storybook/internal/core-server';
+import { logger } from 'storybook/internal/node-logger';
import { cleanPaths, oneWayHash, sanitizeError, telemetry } from 'storybook/internal/telemetry';
import type {
Options,
@@ -29,6 +30,10 @@ import {
COVERAGE_DIRECTORY,
STORE_CHANNEL_EVENT_NAME,
STORYBOOK_ADDON_TEST_CHANNEL,
+ TRIGGER_TEST_RUN_REQUEST,
+ TRIGGER_TEST_RUN_RESPONSE,
+ type TriggerTestRunRequestPayload,
+ type TriggerTestRunResponsePayload,
storeOptions,
} from './constants';
import { log } from './logger';
@@ -77,6 +82,9 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti
return channel;
}
+ const storyIndexGenerator =
+ await options.presets.apply>('storyIndexGenerator');
+
const fsCache = createFileSystemCache({
basePath: resolvePathInStorybookCache(ADDON_ID.replace('/', '-')),
ns: 'storybook',
@@ -94,6 +102,7 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti
initialState: {
...storeOptions.initialState,
previewAnnotations: (previewAnnotations ?? []).concat(previewPath ?? []),
+ index: await storyIndexGenerator.getIndex(),
...selectCachedState(cachedState),
},
leader: true,
@@ -105,6 +114,16 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti
});
const testProviderStore = experimental_getTestProviderStore(ADDON_ID);
+ storyIndexGenerator.onInvalidated(async () => {
+ try {
+ const index = await storyIndexGenerator.getIndex();
+ store.setState((s) => ({ ...s, index }));
+ } catch (error) {
+ logger.debug('Failed to update story index after invalidation, Error:');
+ logger.debug(error);
+ }
+ });
+
store.subscribe('TRIGGER_RUN', (event, eventInfo) => {
testProviderStore.setState('test-provider-state:running');
store.setState((s) => ({
@@ -184,6 +203,60 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti
}));
});
+ // Programmatic test run trigger API
+ channel.on(TRIGGER_TEST_RUN_REQUEST, async (payload: TriggerTestRunRequestPayload) => {
+ const { requestId, actor, storyIds, config: configOverride } = payload;
+
+ const sendResponse = (response: Omit) => {
+ channel.emit(TRIGGER_TEST_RUN_RESPONSE, { requestId, ...response });
+ };
+
+ await store.untilReady();
+
+ const {
+ currentRun: { startedAt, finishedAt },
+ config,
+ } = store.getState();
+ if (startedAt && !finishedAt) {
+ sendResponse({
+ status: 'error',
+ error: { message: 'Tests are already running' },
+ });
+ return;
+ }
+
+ store.send({
+ type: 'TRIGGER_RUN',
+ payload: {
+ storyIds,
+ triggeredBy: `external:${actor}`,
+ ...(configOverride && {
+ configOverride: { ...config, ...configOverride },
+ }),
+ },
+ });
+
+ const unsubscribe = store.subscribe((event) => {
+ switch (event.type) {
+ case 'TEST_RUN_COMPLETED': {
+ unsubscribe();
+ sendResponse({ status: 'completed', result: event.payload });
+ return;
+ }
+ case 'FATAL_ERROR': {
+ unsubscribe();
+ sendResponse({ status: 'error', error: event.payload });
+ return;
+ }
+ case 'CANCEL_RUN': {
+ unsubscribe();
+ sendResponse({ status: 'cancelled' });
+ return;
+ }
+ }
+ });
+ });
+
if (!core.disableTelemetry) {
const enableCrashReports = core.enableCrashReports || options.enableCrashReports;
diff --git a/code/addons/vitest/src/types.ts b/code/addons/vitest/src/types.ts
index b6eca96f92de..e1f4d64ccdd7 100644
--- a/code/addons/vitest/src/types.ts
+++ b/code/addons/vitest/src/types.ts
@@ -1,7 +1,11 @@
import type { experimental_UniversalStore } from 'storybook/internal/core-server';
-import type { PreviewAnnotation, StoryId } from 'storybook/internal/types';
+import type { PreviewAnnotation, Status, StoryId, StoryIndex } from 'storybook/internal/types';
import type { API_HashEntry } from 'storybook/internal/types';
+// import type { A11yReport } from '@storybook/addon-a11y';
+// TODO: There's a type error in axe-core that makes this error during production builds
+type A11yReport = any;
+
export interface VitestError extends Error {
VITEST_TEST_PATH?: string;
VITEST_TEST_NAME?: string;
@@ -20,7 +24,40 @@ export type ErrorLike = {
cause?: ErrorLike;
};
-export type RunTrigger = 'run-all' | 'global' | 'watch' | Extract;
+export type RunTrigger =
+ | 'run-all'
+ | 'global'
+ | 'watch'
+ | Extract
+ | `external:${string}`;
+
+export type CurrentRun = {
+ triggeredBy: RunTrigger | undefined;
+ config: StoreState['config'];
+ componentTestStatuses: Status[];
+ a11yStatuses: Status[];
+ componentTestCount: {
+ success: number;
+ error: number;
+ };
+ a11yCount: {
+ success: number;
+ warning: number;
+ error: number;
+ };
+ a11yReports: Record;
+ totalTestCount: number | undefined;
+ storyIds: StoryId[] | undefined;
+ startedAt: number | undefined;
+ finishedAt: number | undefined;
+ unhandledErrors: VitestError[];
+ coverageSummary:
+ | {
+ status: 'positive' | 'warning' | 'negative' | 'unknown';
+ percentage: number;
+ }
+ | undefined;
+};
export type StoreState = {
config: {
@@ -29,9 +66,7 @@ export type StoreState = {
};
watching: boolean;
cancelling: boolean;
- // TODO: Avoid needing to do a fetch request server-side to retrieve the index
- // e.g. http://localhost:6006/index.json
- indexUrl: string | undefined;
+ index: StoryIndex;
previewAnnotations: PreviewAnnotation[];
fatalError:
| {
@@ -39,30 +74,7 @@ export type StoreState = {
error: ErrorLike;
}
| undefined;
- currentRun: {
- triggeredBy: RunTrigger | undefined;
- config: StoreState['config'];
- componentTestCount: {
- success: number;
- error: number;
- };
- a11yCount: {
- success: number;
- warning: number;
- error: number;
- };
- totalTestCount: number | undefined;
- storyIds: StoryId[] | undefined;
- startedAt: number | undefined;
- finishedAt: number | undefined;
- unhandledErrors: VitestError[];
- coverageSummary:
- | {
- status: 'positive' | 'warning' | 'negative' | 'unknown';
- percentage: number;
- }
- | undefined;
- };
+ currentRun: CurrentRun;
};
export type CachedState = Pick;
@@ -72,6 +84,7 @@ export type TriggerRunEvent = {
payload: {
storyIds?: string[] | undefined;
triggeredBy: RunTrigger;
+ configOverride?: StoreState['config'];
};
};
diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts
index b07edb2bfd00..f472b46cc97d 100644
--- a/code/addons/vitest/src/vitest-plugin/index.ts
+++ b/code/addons/vitest/src/vitest-plugin/index.ts
@@ -30,8 +30,9 @@ import path from 'pathe';
import picocolors from 'picocolors';
import sirv from 'sirv';
import { dedent } from 'ts-dedent';
+import type { PluginOption } from 'vite';
-// ! Relative import to prebundle it without needing to depend on the Vite builder
+// Shared plugins from builder-vite (relative import to prebundle without adding a package dependency)
import { withoutVitePlugins } from '../../../../builders/builder-vite/src/utils/without-vite-plugins';
import type { InternalOptions, UserOptions } from './types';
@@ -194,27 +195,25 @@ export const storybookTest = async (options?: UserOptions): Promise =>
const stories = await presets.apply('stories', []);
- // We can probably add more config here. See code/builders/builder-vite/src/vite-config.ts
- // This one is specifically needed for code/builders/builder-vite/src/preset.ts
const commonConfig = { root: resolve(finalOptions.configDir, '..') };
const [
+ corePlugins,
{ storiesGlobs },
framework,
viteConfigFromStorybook,
staticDirs,
previewLevelTags,
core,
- extraOptimizeDeps,
features,
] = await Promise.all([
+ presets.apply('viteCorePlugins', []),
getStoryGlobsAndFiles(presets, directories),
presets.apply('framework', undefined),
presets.apply<{ plugins?: Plugin[]; root: string }>('viteFinal', commonConfig),
presets.apply('staticDirs', []),
extractTagsFromPreview(finalOptions.configDir),
presets.apply('core'),
- presets.apply('optimizeViteDeps', []),
presets.apply('features', {}),
]);
@@ -230,7 +229,10 @@ export const storybookTest = async (options?: UserOptions): Promise =>
}
// filter out plugins that we know are unnecesary for tests, eg. docgen plugins
- const plugins = await withoutVitePlugins(viteConfigFromStorybook.plugins ?? [], pluginsToIgnore);
+ const plugins: Plugin[] = [
+ ...(corePlugins as Plugin[]),
+ ...(await withoutVitePlugins(viteConfigFromStorybook.plugins ?? [], pluginsToIgnore)),
+ ];
if (finalOptions.disableAddonDocs) {
plugins.push(mdxStubPlugin);
@@ -382,26 +384,8 @@ export const storybookTest = async (options?: UserOptions): Promise =>
},
},
- envPrefix: Array.from(
- new Set([...(nonMutableInputConfig.envPrefix || []), 'STORYBOOK_', 'VITE_'])
- ),
-
- resolve: {
- conditions: [
- 'storybook',
- 'stories',
- 'test',
- // copying straight from https://github.com/vitejs/vite/blob/main/packages/vite/src/node/constants.ts#L60
- // to avoid having to maintain Vite as a dependency just for this
- 'module',
- 'browser',
- 'development|production',
- ],
- },
-
optimizeDeps: {
include: [
- ...extraOptimizeDeps,
'@storybook/addon-vitest/internal/setup-file',
'@storybook/addon-vitest/internal/global-setup',
'@storybook/addon-vitest/internal/test-utils',
@@ -419,11 +403,7 @@ export const storybookTest = async (options?: UserOptions): Promise =>
},
};
- // Merge config from storybook with the plugin config
- const config: Omit = mergeConfig(
- baseConfig,
- viteConfigFromStorybook
- );
+ const config = mergeConfig(baseConfig, viteConfigFromStorybook);
// alert the user of problems
if ((nonMutableInputConfig.test?.include?.length ?? 0) > 0) {
diff --git a/code/builders/builder-vite/src/build.ts b/code/builders/builder-vite/src/build.ts
index f25f14857b61..1f419e038f09 100644
--- a/code/builders/builder-vite/src/build.ts
+++ b/code/builders/builder-vite/src/build.ts
@@ -4,7 +4,6 @@ import type { Options } from 'storybook/internal/types';
import { dedent } from 'ts-dedent';
import type { InlineConfig } from 'vite';
-import { sanitizeEnvVars } from './envs';
import { createViteLogger } from './logger';
import type { WebpackStatsPlugin } from './plugins';
import { hasVitePlugins } from './utils/has-vite-plugins';
@@ -90,7 +89,7 @@ export async function build(options: Options) {
finalConfig.customLogger ??= await createViteLogger();
- await viteBuild(await sanitizeEnvVars(options, finalConfig));
+ await viteBuild(finalConfig);
const statsPlugin = findPlugin(
finalConfig,
diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts
index bac19386fcf9..af2dce770d19 100644
--- a/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts
+++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts
@@ -2,15 +2,10 @@ import { describe, expect, it } from 'vitest';
import { generateModernIframeScriptCodeFromPreviews } from './codegen-modern-iframe-script';
-const projectRoot = 'projectRoot';
-
describe('generateModernIframeScriptCodeFromPreviews', () => {
it('handle one annotation', async () => {
const result = await generateModernIframeScriptCodeFromPreviews({
- previewAnnotations: ['/user/.storybook/preview'],
- projectRoot,
frameworkName: 'frameworkName',
- isCsf4: false,
});
expect(result).toMatchInlineSnapshot(`
"import { setup } from 'storybook/internal/preview/runtime';
@@ -19,18 +14,10 @@ describe('generateModernIframeScriptCodeFromPreviews', () => {
setup();
- import { composeConfigs, PreviewWeb } from 'storybook/preview-api';
- import { isPreview } from 'storybook/internal/csf';
+ import { PreviewWeb } from 'storybook/preview-api';
import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js';
-
- import * as preview_2408 from "/user/.storybook/preview";
- const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => {
- const configs = [
- hmrPreviewAnnotationModules[0] ?? preview_2408
- ]
- return composeConfigs(configs);
- }
-
+ import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js';
+
window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations);
window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore;
@@ -44,21 +31,13 @@ describe('generateModernIframeScriptCodeFromPreviews', () => {
// importFn has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn });
});
-
- import.meta.hot.accept(["/user/.storybook/preview"], (previewAnnotationModules) => {
- // getProjectAnnotations has changed so we need to patch the new one in
- window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) });
- });
};"
`);
});
it('handle one annotation CSF4', async () => {
const result = await generateModernIframeScriptCodeFromPreviews({
- previewAnnotations: ['/user/.storybook/preview'],
- projectRoot,
frameworkName: 'frameworkName',
- isCsf4: true,
});
expect(result).toMatchInlineSnapshot(`
"import { setup } from 'storybook/internal/preview/runtime';
@@ -67,16 +46,10 @@ describe('generateModernIframeScriptCodeFromPreviews', () => {
setup();
- import { composeConfigs, PreviewWeb } from 'storybook/preview-api';
- import { isPreview } from 'storybook/internal/csf';
+ import { PreviewWeb } from 'storybook/preview-api';
import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js';
-
- import * as preview_2408 from "/user/.storybook/preview";
- const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => {
- const preview = hmrPreviewAnnotationModules[0] ?? preview_2408;
- return preview.default.composed;
- }
-
+ import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js';
+
window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations);
window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore;
@@ -90,21 +63,13 @@ describe('generateModernIframeScriptCodeFromPreviews', () => {
// importFn has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn });
});
-
- import.meta.hot.accept(["/user/.storybook/preview"], (previewAnnotationModules) => {
- // getProjectAnnotations has changed so we need to patch the new one in
- window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) });
- });
};"
`);
});
it('handle multiple annotations', async () => {
const result = await generateModernIframeScriptCodeFromPreviews({
- previewAnnotations: ['/user/previewAnnotations1', '/user/.storybook/preview'],
- projectRoot,
frameworkName: 'frameworkName',
- isCsf4: false,
});
expect(result).toMatchInlineSnapshot(`
"import { setup } from 'storybook/internal/preview/runtime';
@@ -113,20 +78,10 @@ describe('generateModernIframeScriptCodeFromPreviews', () => {
setup();
- import { composeConfigs, PreviewWeb } from 'storybook/preview-api';
- import { isPreview } from 'storybook/internal/csf';
+ import { PreviewWeb } from 'storybook/preview-api';
import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js';
-
- import * as previewAnnotations1_2526 from "/user/previewAnnotations1";
- import * as preview_2408 from "/user/.storybook/preview";
- const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => {
- const configs = [
- hmrPreviewAnnotationModules[0] ?? previewAnnotations1_2526,
- hmrPreviewAnnotationModules[1] ?? preview_2408
- ]
- return composeConfigs(configs);
- }
-
+ import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js';
+
window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations);
window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore;
@@ -140,21 +95,13 @@ describe('generateModernIframeScriptCodeFromPreviews', () => {
// importFn has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn });
});
-
- import.meta.hot.accept(["/user/previewAnnotations1","/user/.storybook/preview"], (previewAnnotationModules) => {
- // getProjectAnnotations has changed so we need to patch the new one in
- window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) });
- });
};"
`);
});
it('handle multiple annotations CSF4', async () => {
const result = await generateModernIframeScriptCodeFromPreviews({
- previewAnnotations: ['/user/previewAnnotations1', '/user/.storybook/preview'],
- projectRoot,
frameworkName: 'frameworkName',
- isCsf4: true,
});
expect(result).toMatchInlineSnapshot(`
"import { setup } from 'storybook/internal/preview/runtime';
@@ -163,16 +110,10 @@ describe('generateModernIframeScriptCodeFromPreviews', () => {
setup();
- import { composeConfigs, PreviewWeb } from 'storybook/preview-api';
- import { isPreview } from 'storybook/internal/csf';
+ import { PreviewWeb } from 'storybook/preview-api';
import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js';
-
- import * as preview_2408 from "/user/.storybook/preview";
- const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => {
- const preview = hmrPreviewAnnotationModules[0] ?? preview_2408;
- return preview.default.composed;
- }
-
+ import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js';
+
window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations);
window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore;
@@ -186,11 +127,6 @@ describe('generateModernIframeScriptCodeFromPreviews', () => {
// importFn has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn });
});
-
- import.meta.hot.accept(["/user/.storybook/preview"], (previewAnnotationModules) => {
- // getProjectAnnotations has changed so we need to patch the new one in
- window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) });
- });
};"
`);
});
diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts
index e063c3504c64..e8a615db5b59 100644
--- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts
+++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts
@@ -1,84 +1,24 @@
-import { getFrameworkName, loadPreviewOrConfigFile } from 'storybook/internal/common';
+import { getFrameworkName } from 'storybook/internal/common';
import { STORY_HOT_UPDATED } from 'storybook/internal/core-events';
-import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools';
-import type { Options, PreviewAnnotation } from 'storybook/internal/types';
+import type { Options } from 'storybook/internal/types';
-import { genArrayFromRaw, genImport, genSafeVariableName } from 'knitwork';
-import { filename } from 'pathe/utils';
import { dedent } from 'ts-dedent';
-import { processPreviewAnnotation } from './utils/process-preview-annotation';
+import { VIRTUAL_ID as PROJECT_ANNOTATIONS_VIRTUAL_ID } from './plugins/storybook-project-annotations-plugin';
import { SB_VIRTUAL_FILES } from './virtual-file-names';
-export async function generateModernIframeScriptCode(options: Options, projectRoot: string) {
- const { presets, configDir } = options;
+export async function generateModernIframeScriptCode(options: Options) {
const frameworkName = await getFrameworkName(options);
- const previewOrConfigFile = loadPreviewOrConfigFile({ configDir });
- const previewConfig = previewOrConfigFile ? await readConfig(previewOrConfigFile) : undefined;
- const isCsf4 = previewConfig ? isCsfFactoryPreview(previewConfig) : false;
-
- const previewAnnotations = await presets.apply(
- 'previewAnnotations',
- [],
- options
- );
return generateModernIframeScriptCodeFromPreviews({
- previewAnnotations: [...previewAnnotations, previewOrConfigFile],
- projectRoot,
frameworkName,
- isCsf4,
});
}
export async function generateModernIframeScriptCodeFromPreviews(options: {
- previewAnnotations: (PreviewAnnotation | undefined)[];
- projectRoot: string;
frameworkName: string;
- isCsf4: boolean;
}) {
- const { projectRoot, frameworkName } = options;
- const previewAnnotationURLs = options.previewAnnotations
- .filter((path) => path !== undefined)
- .map((path) => processPreviewAnnotation(path, projectRoot));
-
- const variables: string[] = [];
- const imports: string[] = [];
- for (const previewAnnotation of previewAnnotationURLs) {
- const variable =
- genSafeVariableName(filename(previewAnnotation)).replace(/_(45|46|47)/g, '_') +
- '_' +
- hash(previewAnnotation);
- variables.push(variable);
- imports.push(genImport(previewAnnotation, { name: '*', as: variable }));
- }
-
- const previewFileURL = previewAnnotationURLs[previewAnnotationURLs.length - 1];
- const previewFileVariable = variables[variables.length - 1];
- const previewFileImport = imports[imports.length - 1];
-
- // This is pulled out to a variable because it is reused in both the initial page load
- // and the HMR handler.
- // The `hmrPreviewAnnotationModules` parameter is used to pass the updated modules from HMR.
- // However, only the changed modules are provided, the rest are null.
- const getPreviewAnnotationsFunction = options.isCsf4
- ? dedent`
- const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => {
- const preview = hmrPreviewAnnotationModules[0] ?? ${previewFileVariable};
- return preview.default.composed;
- }`
- : dedent`
- const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => {
- const configs = ${genArrayFromRaw(
- variables.map(
- (previewAnnotation, index) =>
- // Prefer the updated module from an HMR update, otherwise the original module
- `hmrPreviewAnnotationModules[${index}] ?? ${previewAnnotation}`
- ),
- ' '
- )}
- return composeConfigs(configs);
- }`;
+ const { frameworkName } = options;
const generateHMRHandler = (): string => {
// Web components are not compatible with HMR, so disable HMR, reload page instead.
@@ -99,11 +39,6 @@ export async function generateModernIframeScriptCodeFromPreviews(options: {
// importFn has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn });
});
-
- import.meta.hot.accept(${JSON.stringify(options.isCsf4 ? [previewFileURL] : previewAnnotationURLs)}, (previewAnnotationModules) => {
- // getProjectAnnotations has changed so we need to patch the new one in
- window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) });
- });
}`.trim();
};
@@ -122,13 +57,10 @@ export async function generateModernIframeScriptCodeFromPreviews(options: {
setup();
- import { composeConfigs, PreviewWeb } from 'storybook/preview-api';
- import { isPreview } from 'storybook/internal/csf';
+ import { PreviewWeb } from 'storybook/preview-api';
import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}';
-
- ${options.isCsf4 ? previewFileImport : imports.join('\n')}
- ${getPreviewAnnotationsFunction}
-
+ import { getProjectAnnotations } from '${PROJECT_ANNOTATIONS_VIRTUAL_ID}';
+
window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations);
window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore;
@@ -138,6 +70,3 @@ export async function generateModernIframeScriptCodeFromPreviews(options: {
`.trim();
return code;
}
-function hash(value: string) {
- return value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
-}
diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts
new file mode 100644
index 000000000000..9d13c9390ec9
--- /dev/null
+++ b/code/builders/builder-vite/src/codegen-project-annotations.ts
@@ -0,0 +1,110 @@
+import { getFrameworkName, loadPreviewOrConfigFile } from 'storybook/internal/common';
+import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools';
+import type { Options, PreviewAnnotation } from 'storybook/internal/types';
+
+import { genArrayFromRaw, genImport, genSafeVariableName } from 'knitwork';
+import { filename } from 'pathe/utils';
+import { dedent } from 'ts-dedent';
+
+import { processPreviewAnnotation } from './utils/process-preview-annotation';
+
+/** Generates the code for the `PROJECT_ANNOTATIONS_FILE` virtual module. */
+export async function generateProjectAnnotationsCode(options: Options, projectRoot: string) {
+ const { presets, configDir } = options;
+ const frameworkName = await getFrameworkName(options);
+
+ const previewOrConfigFile = loadPreviewOrConfigFile({ configDir });
+ const previewConfig = previewOrConfigFile ? await readConfig(previewOrConfigFile) : undefined;
+ const isCsf4 = previewConfig ? isCsfFactoryPreview(previewConfig) : false;
+
+ const previewAnnotations = await presets.apply(
+ 'previewAnnotations',
+ [],
+ options
+ );
+
+ return generateProjectAnnotationsCodeFromPreviews({
+ previewAnnotations: [...previewAnnotations, previewOrConfigFile],
+ projectRoot,
+ frameworkName,
+ isCsf4,
+ });
+}
+
+export function generateProjectAnnotationsCodeFromPreviews(options: {
+ previewAnnotations: (PreviewAnnotation | undefined)[];
+ projectRoot: string;
+ frameworkName: string;
+ isCsf4: boolean;
+}) {
+ const { projectRoot } = options;
+ const previewAnnotationURLs = options.previewAnnotations
+ .filter((path) => path !== undefined)
+ .map((path) => processPreviewAnnotation(path, projectRoot));
+
+ const variables: string[] = [];
+ const imports: string[] = [];
+ for (const previewAnnotation of previewAnnotationURLs) {
+ const variable =
+ genSafeVariableName(filename(previewAnnotation)).replace(/_(45|46|47)/g, '_') +
+ '_' +
+ hash(previewAnnotation);
+ variables.push(variable);
+ imports.push(genImport(previewAnnotation, { name: '*', as: variable }));
+ }
+
+ const previewFileURL = previewAnnotationURLs[previewAnnotationURLs.length - 1];
+ const previewFileVariable = variables[variables.length - 1];
+ const previewFileImport = imports[imports.length - 1];
+
+ if (options.isCsf4) {
+ return dedent`
+ ${previewFileImport}
+
+ export function getProjectAnnotations(hmrPreviewAnnotationModules = []) {
+ const preview = hmrPreviewAnnotationModules[0] ?? ${previewFileVariable};
+ return preview.default.composed;
+ }
+
+ if (import.meta.hot) {
+ import.meta.hot.accept([${JSON.stringify(previewFileURL)}], (previewAnnotationModules) => {
+ // getProjectAnnotations has changed so we need to patch the new one in
+ window?.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({
+ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules),
+ });
+ });
+ }
+ `.trim();
+ }
+
+ return dedent`
+ import { composeConfigs } from 'storybook/preview-api';
+
+ ${imports.join('\n')}
+
+ export function getProjectAnnotations(hmrPreviewAnnotationModules = []) {
+ const configs = ${genArrayFromRaw(
+ variables.map(
+ (previewAnnotation, index) =>
+ // Prefer the updated module from an HMR update, otherwise the original module
+ `hmrPreviewAnnotationModules[${index}] ?? ${previewAnnotation}`
+ ),
+ ' '
+ )};
+ return composeConfigs(configs);
+ }
+
+ if (import.meta.hot) {
+ import.meta.hot.accept(${JSON.stringify(previewAnnotationURLs)}, (previewAnnotationModules) => {
+ // getProjectAnnotations has changed so we need to patch the new one in
+ window?.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({
+ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules),
+ });
+ });
+ }
+ `.trim();
+}
+
+function hash(value: string) {
+ return value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
+}
diff --git a/code/builders/builder-vite/src/envs.ts b/code/builders/builder-vite/src/envs.ts
index 41716949b81b..bbf0aecef781 100644
--- a/code/builders/builder-vite/src/envs.ts
+++ b/code/builders/builder-vite/src/envs.ts
@@ -1,5 +1,5 @@
import { stringifyEnvs } from 'storybook/internal/common';
-import type { Builder_EnvsRaw, Options } from 'storybook/internal/types';
+import type { Builder_EnvsRaw } from 'storybook/internal/types';
import type { UserConfig as ViteConfig } from 'vite';
@@ -39,22 +39,3 @@ export function stringifyProcessEnvs(raw: Builder_EnvsRaw, envPrefix: ViteConfig
return envs;
}
-
-// Sanitize environment variables if needed
-export async function sanitizeEnvVars(options: Options, config: ViteConfig) {
- const { presets } = options;
- const envsRaw = await presets.apply>('env');
- let { define } = config;
- if (Object.keys(envsRaw).length) {
- // Stringify env variables after getting `envPrefix` from the config
- const envs = stringifyProcessEnvs(envsRaw, config.envPrefix);
- define = {
- ...define,
- ...envs,
- };
- }
- return {
- ...config,
- define,
- } as ViteConfig;
-}
diff --git a/code/builders/builder-vite/src/optimizeDeps.ts b/code/builders/builder-vite/src/optimizeDeps.ts
index 61e5c1ed5b56..09c8b51b581e 100644
--- a/code/builders/builder-vite/src/optimizeDeps.ts
+++ b/code/builders/builder-vite/src/optimizeDeps.ts
@@ -1,10 +1,6 @@
-import type { StoryIndexGenerator } from 'storybook/internal/core-server';
-import type { Options, StoryIndex } from 'storybook/internal/types';
-
import { type UserConfig, type InlineConfig as ViteInlineConfig, resolveConfig } from 'vite';
import { INCLUDE_CANDIDATES } from './constants';
-import { getUniqueImportPaths } from './utils/unique-import-paths';
/**
* Helper function which allows us to `filter` with an async predicate. Uses Promise.all for
@@ -15,14 +11,7 @@ const asyncFilter = async (arr: string[], predicate: (val: string) => Promise('storyIndexGenerator'),
- ]);
-
- const index: StoryIndex = await storyIndexGenerator.getIndex();
-
+export async function getOptimizeDeps(config: ViteInlineConfig) {
// TODO: check if resolveConfig takes a lot of time, possible optimizations here
const resolvedConfig = await resolveConfig(config, 'serve', 'development');
@@ -31,13 +20,11 @@ export async function getOptimizeDeps(config: ViteInlineConfig, options: Options
const resolve = resolvedConfig.createResolver({ asSrc: false });
const include = await asyncFilter(INCLUDE_CANDIDATES, async (id) => Boolean(await resolve(id)));
- const optimizeDeps: UserConfig['optimizeDeps'] = {
- ...config.optimizeDeps,
- entries: getUniqueImportPaths(index),
+ const optimizeDeps = {
// We need Vite to precompile these dependencies, because they contain non-ESM code that would break
// if we served it directly to the browser.
- include: [...include, ...extraOptimizeDeps, ...(config.optimizeDeps?.include || [])],
- };
+ include: [...include, ...(config.optimizeDeps?.include || [])],
+ } satisfies UserConfig['optimizeDeps'];
return optimizeDeps;
}
diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts
index 2af433d0c90b..50a163e9ff9c 100644
--- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts
+++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts
@@ -17,10 +17,9 @@ import {
getResolvedVirtualModuleId,
} from '../virtual-file-names';
-export function codeGeneratorPlugin(options: Options): Plugin {
+export function codeGeneratorPlugin(options: Options) {
const iframePath = fileURLToPath(importMetaResolve('@storybook/builder-vite/input/iframe.html'));
let iframeId: string;
- let projectRoot: string;
const storyIndexGeneratorPromise: Promise =
options.presets.apply('storyIndexGenerator');
@@ -53,7 +52,6 @@ export function codeGeneratorPlugin(options: Options): Plugin {
}
},
configResolved(config) {
- projectRoot = config.root;
iframeId = `${config.root}/iframe.html`;
},
resolveId(source) {
@@ -78,7 +76,7 @@ export function codeGeneratorPlugin(options: Options): Plugin {
return generateAddonSetupCode();
}
case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE): {
- return generateModernIframeScriptCode(options, projectRoot);
+ return generateModernIframeScriptCode(options);
}
case iframeId: {
return readFileSync(
@@ -94,5 +92,5 @@ export function codeGeneratorPlugin(options: Options): Plugin {
}
return transformIframeHtml(html, options);
},
- };
+ } satisfies Plugin;
}
diff --git a/code/builders/builder-vite/src/plugins/index.ts b/code/builders/builder-vite/src/plugins/index.ts
index bc72dc8755d5..078886a86b4a 100644
--- a/code/builders/builder-vite/src/plugins/index.ts
+++ b/code/builders/builder-vite/src/plugins/index.ts
@@ -1,6 +1,12 @@
-export * from './inject-export-order-plugin';
-export * from './strip-story-hmr-boundaries';
-export * from './code-generator-plugin';
-export * from './csf-plugin';
-export * from './external-globals-plugin';
-export * from './webpack-stats-plugin';
+// Builder-internal plugins (used by vite-config.ts to assemble the builder's plugin stack)
+export { storybookOptimizeDepsPlugin } from './storybook-optimize-deps-plugin';
+export { storybookEntryPlugin } from './storybook-entry-plugin';
+export { pluginWebpackStats } from './webpack-stats-plugin';
+export type { WebpackStatsPlugin } from './webpack-stats-plugin';
+
+// Lower-level plugins re-exported for internal use and tests
+export { injectExportOrderPlugin } from './inject-export-order-plugin';
+export { stripStoryHMRBoundary } from './strip-story-hmr-boundaries';
+export { codeGeneratorPlugin } from './code-generator-plugin';
+export { csfPlugin } from './csf-plugin';
+export { storybookExternalGlobalsPlugin } from './storybook-external-globals-plugin';
diff --git a/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts b/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts
index 01b835ff6187..91091a86811b 100644
--- a/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts
+++ b/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts
@@ -1,5 +1,6 @@
import { parse } from 'es-module-lexer';
import MagicString from 'magic-string';
+import type { Plugin } from 'vite';
export async function injectExportOrderPlugin() {
const { createFilter } = await import('vite');
@@ -35,5 +36,5 @@ export async function injectExportOrderPlugin() {
map: s.generateMap({ hires: true, source: id }),
};
},
- };
+ } satisfies Plugin;
}
diff --git a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts
new file mode 100644
index 000000000000..7152f47f4cf4
--- /dev/null
+++ b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts
@@ -0,0 +1,62 @@
+import { isPreservingSymlinks } from 'storybook/internal/common';
+
+import { type Plugin } from 'vite';
+
+export interface StorybookConfigPluginOptions {
+ configDir: string;
+}
+
+/**
+ * A Vite plugin that provides the base Storybook configuration.
+ *
+ * This handles:
+ *
+ * - Adding Storybook resolve conditions (`storybook`, `stories`, `test`)
+ * - Setting up environment variable prefixes (`VITE_`, `STORYBOOK_`)
+ * - Allowing the Storybook config directory in Vite's filesystem restrictions
+ * - Preserving symlinks when applicable
+ */
+export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Plugin[] {
+ return [
+ {
+ name: 'storybook:config-plugin',
+ enforce: 'pre',
+ async config(config) {
+ const { defaultClientConditions = [] } = await import('vite');
+
+ const existingEnvPrefix = config.envPrefix;
+ // If an envPrefix is specified in the user's vite config, add STORYBOOK_ to it.
+ // Otherwise, add both VITE_ and STORYBOOK_ so that Vite doesn't lose its default.
+ const mergedEnvPrefix = existingEnvPrefix
+ ? Array.from(
+ new Set([
+ ...(Array.isArray(existingEnvPrefix) ? existingEnvPrefix : [existingEnvPrefix]),
+ 'STORYBOOK_',
+ ])
+ )
+ : ['VITE_', 'STORYBOOK_'];
+
+ return {
+ resolve: {
+ conditions: ['storybook', 'stories', 'test', ...defaultClientConditions],
+ preserveSymlinks: isPreservingSymlinks(),
+ },
+ envPrefix: mergedEnvPrefix,
+ };
+ },
+ },
+ {
+ name: 'storybook:allow-storybook-dir',
+ enforce: 'post',
+ config(config) {
+ // If there is NO allow list then Vite allows anything in the root directory.
+ // If there IS an allow list then Vite only allows the listed directories.
+ // We add the storybook config directory only if there's already an allow list,
+ // to avoid disallowing the root unless it's already restricted.
+ if (config?.server?.fs?.allow) {
+ config.server.fs.allow.push(options.configDir);
+ }
+ },
+ },
+ ];
+}
diff --git a/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts
new file mode 100644
index 000000000000..f4c0ba3d38d3
--- /dev/null
+++ b/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts
@@ -0,0 +1,22 @@
+import type { Options } from 'storybook/internal/types';
+
+import type { Plugin } from 'vite';
+
+import { codeGeneratorPlugin } from './code-generator-plugin';
+import { injectExportOrderPlugin } from './inject-export-order-plugin';
+import { stripStoryHMRBoundary } from './strip-story-hmr-boundaries';
+
+/**
+ * A composite Vite plugin that manages the generation and injection of virtual entry points for
+ * Storybook stories. This is builder-specific and NOT shared with addon-vitest.
+ */
+export async function storybookEntryPlugin(options: Options): Promise {
+ return [
+ // Pre-enforcement: handles virtual module resolution and loading (must run first)
+ codeGeneratorPlugin(options),
+ // Post-enforcement: injects __namedExportsOrder after TypeScript transpilation
+ await injectExportOrderPlugin(),
+ // Post-enforcement: removes import.meta.hot.accept() from story files
+ await stripStoryHMRBoundary(),
+ ];
+}
diff --git a/code/builders/builder-vite/src/plugins/external-globals-plugin.test.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts
similarity index 95%
rename from code/builders/builder-vite/src/plugins/external-globals-plugin.test.ts
rename to code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts
index 67b62690da43..4b5c001f0e3d 100644
--- a/code/builders/builder-vite/src/plugins/external-globals-plugin.test.ts
+++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts
@@ -1,6 +1,6 @@
import { expect, it } from 'vitest';
-import { rewriteImport } from './external-globals-plugin';
+import { rewriteImport } from './storybook-external-globals-plugin';
const packageName = '@storybook/package';
const globals = { [packageName]: '_STORYBOOK_PACKAGE_' };
diff --git a/code/builders/builder-vite/src/plugins/external-globals-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts
similarity index 89%
rename from code/builders/builder-vite/src/plugins/external-globals-plugin.ts
rename to code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts
index 242fb98ff3dc..54bb15a28c96 100644
--- a/code/builders/builder-vite/src/plugins/external-globals-plugin.ts
+++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts
@@ -2,6 +2,9 @@ import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
+import { globalsNameReferenceMap } from 'storybook/internal/preview/globals';
+import type { Options } from 'storybook/internal/types';
+
import * as pkg from 'empathic/package';
import { init, parse } from 'es-module-lexer';
import MagicString from 'magic-string';
@@ -38,7 +41,17 @@ const replacementMap = new Map([
* https://github.com/eight04/rollup-plugin-external-globals, but simplified to meet our simple
* needs.
*/
-export async function externalGlobalsPlugin(externals: Record): Promise {
+
+export async function storybookExternalGlobalsPlugin(options: Options): Promise {
+ const build = await options.presets.apply('build');
+
+ const externals: typeof globalsNameReferenceMap & Record =
+ globalsNameReferenceMap;
+
+ if (build?.test?.disableBlocks) {
+ externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__';
+ }
+
await init;
const { mergeAlias } = await import('vite');
diff --git a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts
new file mode 100644
index 000000000000..ccbd5e4a9a4e
--- /dev/null
+++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts
@@ -0,0 +1,41 @@
+import type { StoryIndexGenerator } from 'storybook/internal/core-server';
+import type { Options, StoryIndex } from 'storybook/internal/types';
+
+import { type Plugin } from 'vite';
+
+import { getUniqueImportPaths } from '../utils/unique-import-paths';
+
+/** A Vite plugin that configures dependency optimization for Storybook's dev server. */
+export function storybookOptimizeDepsPlugin(options: Options): Plugin {
+ return {
+ name: 'storybook:optimize-deps-plugin',
+ async config(config, { command }) {
+ // optimizeDeps only applies to the dev server, not production builds
+ if (command !== 'serve') {
+ return;
+ }
+
+ const [extraOptimizeDeps, storyIndexGenerator] = await Promise.all([
+ options.presets.apply('optimizeViteDeps', []),
+ options.presets.apply('storyIndexGenerator'),
+ ]);
+
+ const index: StoryIndex = await storyIndexGenerator.getIndex();
+
+ return {
+ optimizeDeps: {
+ // Story file paths as entry points for the optimizer
+ entries: [
+ ...(typeof config.optimizeDeps?.entries === 'string'
+ ? [config.optimizeDeps.entries]
+ : []),
+ ...getUniqueImportPaths(index),
+ ],
+ // Known CJS dependencies that need to be pre-compiled to ESM,
+ // plus any extra deps from Storybook presets.
+ include: [...extraOptimizeDeps, ...(config.optimizeDeps?.include || [])],
+ },
+ };
+ },
+ };
+}
diff --git a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts
new file mode 100644
index 000000000000..beb879493f64
--- /dev/null
+++ b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts
@@ -0,0 +1,40 @@
+import type { Options } from 'storybook/internal/types';
+
+import type { Plugin } from 'vite';
+
+import { generateProjectAnnotationsCode } from '../codegen-project-annotations';
+import { getResolvedVirtualModuleId } from '../virtual-file-names';
+
+export const VIRTUAL_ID = 'virtual:/@storybook/builder-vite/project-annotations.js';
+const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID);
+
+/**
+ * A Vite plugin that serves the project annotations virtual module.
+ *
+ * The virtual module can be imported as:
+ *
+ * ```ts
+ * import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js';
+ * ```
+ */
+export function storybookProjectAnnotationsPlugin(options: Options): Plugin {
+ let projectRoot: string;
+
+ return {
+ name: 'storybook:project-annotations-plugin',
+ enforce: 'pre',
+ configResolved(config) {
+ projectRoot = config.root;
+ },
+ resolveId(source) {
+ if (source === VIRTUAL_ID) {
+ return RESOLVED_VIRTUAL_ID;
+ }
+ },
+ async load(id) {
+ if (id === RESOLVED_VIRTUAL_ID) {
+ return generateProjectAnnotationsCode(options, projectRoot);
+ }
+ },
+ };
+}
diff --git a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts
new file mode 100644
index 000000000000..a60d66eace7e
--- /dev/null
+++ b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts
@@ -0,0 +1,31 @@
+import type { Builder_EnvsRaw } from 'storybook/internal/types';
+import type { Options } from 'storybook/internal/types';
+
+import type { Plugin } from 'vite';
+
+import { stringifyProcessEnvs } from '../envs';
+
+export interface StorybookRuntimePluginOptions {
+ externals: Record;
+ envs?: Builder_EnvsRaw;
+}
+
+/** A composite Vite plugin that injects environment variables for Storybook's runtime. */
+export async function storybookSanitizeEnvs(options: Options): Promise {
+ const plugins: Plugin[] = [];
+ const envs = await options.presets.apply('env');
+
+ if (envs && Object.keys(envs).length > 0) {
+ plugins.push({
+ name: 'storybook:env-plugin',
+ config(config) {
+ const envDefines = stringifyProcessEnvs(envs, config.envPrefix);
+ return {
+ define: envDefines,
+ };
+ },
+ });
+ }
+
+ return plugins;
+}
diff --git a/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts b/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts
index 509a7d06adbd..baa433d1afed 100644
--- a/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts
+++ b/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts
@@ -6,7 +6,7 @@ import type { Plugin } from 'vite';
* boundaries, but vite has a bug which causes them to be treated as boundaries
* (https://github.com/vitejs/vite/issues/9869).
*/
-export async function stripStoryHMRBoundary(): Promise {
+export async function stripStoryHMRBoundary() {
const { createFilter } = await import('vite');
const filter = createFilter(/\.stories\.(tsx?|jsx?|svelte|vue)$/);
@@ -26,5 +26,5 @@ export async function stripStoryHMRBoundary(): Promise {
map: s.generateMap({ hires: true, source: id }),
};
},
- };
+ } satisfies Plugin;
}
diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts
index b309970c3314..0f33305a8c20 100644
--- a/code/builders/builder-vite/src/preset.ts
+++ b/code/builders/builder-vite/src/preset.ts
@@ -1,34 +1,39 @@
import { findConfigFile } from 'storybook/internal/common';
import type { Options } from 'storybook/internal/types';
-import type { UserConfig } from 'vite';
+import type { PluginOption } from 'vite';
+import { storybookConfigPlugin } from './plugins/storybook-config-plugin';
+import { storybookOptimizeDepsPlugin } from './plugins/storybook-optimize-deps-plugin';
+import { storybookProjectAnnotationsPlugin } from './plugins/storybook-project-annotations-plugin';
+import { storybookSanitizeEnvs } from './plugins/storybook-runtime-plugin';
import { viteInjectMockerRuntime } from './plugins/vite-inject-mocker/plugin';
import { viteMockPlugin } from './plugins/vite-mock/plugin';
-// This preset defines currently mocking plugins for Vite
-// It is defined as a viteFinal preset so that @storybook/addon-vitest can use it as well and that it doesn't have to be duplicated in addon-vitest.
-// The main vite configuration is defined in `./vite-config.ts`.
-export async function viteFinal(existing: UserConfig, options: Options) {
+/**
+ * Preset that provides the core Storybook Vite plugins shared between `@storybook/builder-vite` and
+ * `@storybook/addon-vitest`.
+ */
+export async function viteCorePlugins(
+ _: PluginOption[],
+ options: Options
+): Promise {
const previewConfigPath = findConfigFile('preview', options.configDir);
- // If there's no preview file, there's nothing to mock.
- if (!previewConfigPath) {
- return existing;
- }
-
- const coreOptions = await options.presets.apply('core');
-
- return {
- ...existing,
- plugins: [
- ...(existing.plugins ?? []),
- ...(previewConfigPath
- ? [
- viteInjectMockerRuntime({ previewConfigPath }),
- viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }),
- ]
- : []),
- ],
- };
+ return [
+ storybookProjectAnnotationsPlugin(options),
+ ...storybookConfigPlugin({ configDir: options.configDir }),
+ storybookOptimizeDepsPlugin(options),
+ ...(await storybookSanitizeEnvs(options)),
+ ...(previewConfigPath
+ ? [
+ viteInjectMockerRuntime({ previewConfigPath }),
+ viteMockPlugin({
+ previewConfigPath,
+ coreOptions: await options.presets.apply('core'),
+ configDir: options.configDir,
+ }),
+ ]
+ : []),
+ ];
}
diff --git a/code/builders/builder-vite/src/vite-config.test.ts b/code/builders/builder-vite/src/vite-config.test.ts
index 3097c069431a..9bb9a0b6913d 100644
--- a/code/builders/builder-vite/src/vite-config.test.ts
+++ b/code/builders/builder-vite/src/vite-config.test.ts
@@ -1,9 +1,11 @@
import { describe, expect, it, vi } from 'vitest';
+import { Channel } from 'storybook/internal/channels';
import type { Options, Presets } from 'storybook/internal/types';
import { loadConfigFromFile } from 'vite';
+import { storybookConfigPlugin } from './plugins/storybook-config-plugin';
import { commonConfig } from './vite-config';
vi.mock('vite', async (importOriginal) => ({
@@ -17,6 +19,7 @@ const dummyOptions: Options = {
configType: 'DEVELOPMENT',
configDir: '',
packageJson: {},
+ channel: new Channel({}),
presets: {
apply: async (key: string) =>
({
@@ -34,7 +37,7 @@ const dummyOptions: Options = {
};
describe('commonConfig', () => {
- it('should preserve default envPrefix', async () => {
+ it('should set configFile to false and include plugins', async () => {
loadConfigFromFileMock.mockReturnValueOnce(
Promise.resolve({
config: {},
@@ -43,30 +46,45 @@ describe('commonConfig', () => {
})
);
const config = await commonConfig(dummyOptions, 'development');
- expect(config.envPrefix).toStrictEqual(['VITE_', 'STORYBOOK_']);
+ expect(config.configFile).toBe(false);
+ expect(config.plugins).toBeDefined();
});
+});
- it('should preserve custom envPrefix string', async () => {
- loadConfigFromFileMock.mockReturnValueOnce(
- Promise.resolve({
- config: { envPrefix: 'SECRET_' },
- path: '',
- dependencies: [],
- })
- );
- const config = await commonConfig(dummyOptions, 'development');
- expect(config.envPrefix).toStrictEqual(['SECRET_', 'STORYBOOK_']);
+describe('storybookConfigPlugin', () => {
+ it('should set default envPrefix when no user envPrefix is set', async () => {
+ const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' });
+ const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!;
+
+ // The config hook receives the current Vite config and returns partial config to merge
+ const result = await (configPlugin.config as Function)({}, {});
+ expect(result.envPrefix).toStrictEqual(['VITE_', 'STORYBOOK_']);
});
- it('should preserve custom envPrefix array', async () => {
- loadConfigFromFileMock.mockReturnValueOnce(
- Promise.resolve({
- config: { envPrefix: ['SECRET_', 'VUE_'] },
- path: '',
- dependencies: [],
- })
- );
- const config = await commonConfig(dummyOptions, 'development');
- expect(config.envPrefix).toStrictEqual(['SECRET_', 'VUE_', 'STORYBOOK_']);
+ it('should include storybook resolve conditions', async () => {
+ const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' });
+ const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!;
+
+ const result = await (configPlugin.config as Function)({}, {});
+ expect(result.resolve.conditions).toContain('storybook');
+ expect(result.resolve.conditions).toContain('stories');
+ expect(result.resolve.conditions).toContain('test');
+ });
+
+ it('should not set base when not provided', async () => {
+ const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' });
+ const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!;
+
+ const result = await (configPlugin.config as Function)({}, {});
+ expect(result.base).toBeUndefined();
+ });
+
+ it('should allow storybook dir when server fs allow list exists', () => {
+ const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' });
+ const allowPlugin = plugins.find((p) => p.name === 'storybook:allow-storybook-dir')!;
+
+ const config = { server: { fs: { allow: ['/some/path'] } } };
+ (allowPlugin.config as Function)(config);
+ expect(config.server.fs.allow).toContain('/test/.storybook');
});
});
diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts
index 9cb4e86042f2..807f50152219 100644
--- a/code/builders/builder-vite/src/vite-config.ts
+++ b/code/builders/builder-vite/src/vite-config.ts
@@ -1,11 +1,6 @@
import { resolve } from 'node:path';
-import {
- getBuilderOptions,
- isPreservingSymlinks,
- resolvePathInStorybookCache,
-} from 'storybook/internal/common';
-import { globalsNameReferenceMap } from 'storybook/internal/preview/globals';
+import { getBuilderOptions, resolvePathInStorybookCache } from 'storybook/internal/common';
import type { Options } from 'storybook/internal/types';
import type {
@@ -17,13 +12,12 @@ import type {
} from 'vite';
import {
- codeGeneratorPlugin,
csfPlugin,
- externalGlobalsPlugin,
- injectExportOrderPlugin,
pluginWebpackStats,
- stripStoryHMRBoundary,
+ storybookEntryPlugin,
+ storybookExternalGlobalsPlugin,
} from './plugins';
+import { viteCorePlugins as corePlugins } from './preset';
import type { BuilderOptions } from './types';
export type PluginConfigType = 'build' | 'development';
@@ -46,7 +40,7 @@ export async function commonConfig(
_type: PluginConfigType
): Promise {
const configEnv = _type === 'development' ? configEnvServe : configEnvBuild;
- const { loadConfigFromFile, mergeConfig, defaultClientConditions = [] } = await import('vite');
+ const { loadConfigFromFile, mergeConfig } = await import('vite');
const { viteConfigPath } = await getBuilderOptions(options);
@@ -58,22 +52,20 @@ export async function commonConfig(
const { config: { build: buildProperty = undefined, ...userConfig } = {} } =
(await loadConfigFromFile(configEnv, viteConfigPath, projectRoot)) ?? {};
- // This is the main Vite config that is used by Storybook.
- // Some shared vite plugins are defined in the `./preset.ts` file so that it can be shared between the @storybook/builder-vite and @storybook/addon-vitest package.
+ // Storybook's Vite config is assembled from self-contained plugins.
+ // The config plugin handles base settings (root, cacheDir, resolve conditions, etc.),
+ // while other plugins handle entry points, docgen, and runtime globals.
+ // Shared vite plugins for mocking are defined in `./preset.ts` so that they can be
+ // shared between @storybook/builder-vite and @storybook/addon-vitest.
const sbConfig: InlineConfig = {
configFile: false,
- cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey),
+ plugins: await pluginConfig(options),
root: projectRoot,
- // Allow storybook deployed as subfolder. See https://github.com/storybookjs/builder-vite/issues/238
+ // Allow storybook deployed as subfolder. See https://github.com/storybookjs/builder-vite/issues/238
base: './',
- plugins: await pluginConfig(options),
- resolve: {
- conditions: ['storybook', 'stories', 'test', ...defaultClientConditions],
- preserveSymlinks: isPreservingSymlinks(),
- },
- // If an envPrefix is specified in the vite config, add STORYBOOK_ to it,
- // otherwise, add VITE_ and STORYBOOK_ so that vite doesn't lose its default.
- envPrefix: userConfig.envPrefix ? ['STORYBOOK_'] : ['VITE_', 'STORYBOOK_'],
+ ...(options.cacheKey
+ ? { cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey) }
+ : {}),
// Pass build.target option from user's vite config
build: {
target: buildProperty?.target,
@@ -86,33 +78,14 @@ export async function commonConfig(
}
export async function pluginConfig(options: Options) {
- const build = await options.presets.apply('build');
-
- const externals: Record = globalsNameReferenceMap;
-
- if (build?.test?.disableBlocks) {
- externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__';
- }
-
const plugins = [
- codeGeneratorPlugin(options),
+ // Shared core plugins (resolve conditions, envPrefix, fs.allow, externals, env vars, etc.)
+ ...(await corePlugins([], options)),
+ await storybookExternalGlobalsPlugin(options),
await csfPlugin(options),
- await injectExportOrderPlugin(),
- await stripStoryHMRBoundary(),
- {
- name: 'storybook:allow-storybook-dir',
- enforce: 'post',
- config(config) {
- // if there is NO allow list then Vite allows anything in the root directory
- // if there is an allow list then Vite only allows anything in the listed directories
- // add storybook specific directories only if there's an allow list so that we don't end up
- // disallowing the root unless root is already disallowed
- if (config?.server?.fs?.allow) {
- config.server.fs.allow.push(options.configDir);
- }
- },
- },
- await externalGlobalsPlugin(externals),
+ // Entry plugin: virtual modules for stories, addon setup, and main app entry
+ ...(await storybookEntryPlugin(options)),
+ // Builder-specific: webpack-compatible stats for turbosnap/chromatic
pluginWebpackStats({ workingDir: process.cwd() }),
] as PluginOption[];
diff --git a/code/builders/builder-vite/src/vite-server.ts b/code/builders/builder-vite/src/vite-server.ts
index 30e712c5a0cf..9c694e94e410 100644
--- a/code/builders/builder-vite/src/vite-server.ts
+++ b/code/builders/builder-vite/src/vite-server.ts
@@ -5,7 +5,6 @@ import type { Server } from 'http';
import { dedent } from 'ts-dedent';
import type { InlineConfig, ServerOptions } from 'vite';
-import { sanitizeEnvVars } from './envs';
import { createViteLogger } from './logger';
import { getOptimizeDeps } from './optimizeDeps';
import { commonConfig } from './vite-config';
@@ -15,9 +14,15 @@ export async function createViteServer(options: Options, devServer: Server) {
const commonCfg = await commonConfig(options, 'development');
+ const optimizeDeps = await getOptimizeDeps(commonCfg);
+
const config: InlineConfig & { server: ServerOptions } = {
...commonCfg,
// Set up dev server
+ optimizeDeps: {
+ ...commonCfg.optimizeDeps,
+ include: [...(commonCfg.optimizeDeps?.include || []), ...optimizeDeps.include],
+ },
server: {
middlewareMode: true,
hmr: {
@@ -29,7 +34,6 @@ export async function createViteServer(options: Options, devServer: Server) {
},
},
appType: 'custom' as const,
- optimizeDeps: await getOptimizeDeps(commonCfg, options),
};
// '0.0.0.0' binds to all interfaces, which is useful for Docker and other containerized environments.
@@ -51,5 +55,5 @@ export async function createViteServer(options: Options, devServer: Server) {
const { createServer } = await import('vite');
finalConfig.customLogger ??= await createViteLogger();
- return createServer(await sanitizeEnvVars(options, finalConfig));
+ return createServer(finalConfig);
}
diff --git a/code/core/src/channels/index.ts b/code/core/src/channels/index.ts
index c6979045e6e4..f7a36226ee3a 100644
--- a/code/core/src/channels/index.ts
+++ b/code/core/src/channels/index.ts
@@ -50,4 +50,10 @@ export function createBrowserChannel({ page, extraTransports = [] }: Options): C
return channel;
}
-export type { Listener, ChannelEvent, ChannelTransport, ChannelHandler } from './types';
+export type {
+ Listener,
+ ChannelEvent,
+ ChannelTransport,
+ ChannelHandler,
+ ChannelLike,
+} from './types';
diff --git a/code/core/src/channels/main.ts b/code/core/src/channels/main.ts
index 62bebb4dd54b..ec4119b3c7db 100644
--- a/code/core/src/channels/main.ts
+++ b/code/core/src/channels/main.ts
@@ -3,6 +3,7 @@ import type {
ChannelArgsMulti,
ChannelArgsSingle,
ChannelEvent,
+ ChannelLike,
ChannelTransport,
EventsKeyValue,
Listener,
@@ -18,7 +19,7 @@ const generateRandomId = () => {
return Math.random().toString(16).slice(2);
};
-export class Channel {
+export class Channel implements ChannelLike {
readonly isAsync: boolean;
private sender = generateRandomId();
diff --git a/code/core/src/channels/types.ts b/code/core/src/channels/types.ts
index f2904700cf1d..49ba640a0665 100644
--- a/code/core/src/channels/types.ts
+++ b/code/core/src/channels/types.ts
@@ -29,6 +29,25 @@ export interface EventsKeyValue {
[key: string]: Listener[];
}
+/**
+ * Structural interface for Channel, used in type declarations to avoid nominal incompatibility
+ * between source and dist Channel class declarations.
+ */
+export interface ChannelLike {
+ readonly isAsync: boolean;
+ readonly hasTransport: boolean;
+ addListener(eventName: string, listener: Listener): void;
+ emit(eventName: string, ...args: any): void;
+ last(eventName: string): any;
+ eventNames(): string[];
+ listenerCount(eventName: string): number;
+ listeners(eventName: string): Listener[] | undefined;
+ once(eventName: string, listener: Listener): void;
+ removeAllListeners(eventName?: string): void;
+ removeListener(eventName: string, listener: Listener): void;
+ on(eventName: string, listener: Listener): void;
+ off(eventName: string, listener: Listener): void;
+}
export type ChannelArgs = ChannelArgsSingle | ChannelArgsMulti;
export interface ChannelArgsSingle {
transport?: ChannelTransport;
diff --git a/code/core/src/cli/buildIndex.ts b/code/core/src/cli/buildIndex.ts
index 890cf3b56203..7cb6cd5a7ce3 100644
--- a/code/core/src/cli/buildIndex.ts
+++ b/code/core/src/cli/buildIndex.ts
@@ -1,3 +1,4 @@
+import { Channel } from 'storybook/internal/channels';
import { cache } from 'storybook/internal/common';
import { buildIndexStandalone, withTelemetry } from 'storybook/internal/core-server';
import type { BuilderOptions, CLIBaseOptions } from 'storybook/internal/types';
@@ -23,6 +24,7 @@ export const buildIndex = async (
...options,
corePresets: [],
overridePresets: [],
- };
+ channel: new Channel({}),
+ } as unknown as Parameters[1]['presetOptions'];
await withTelemetry('index', { cliOptions, presetOptions }, () => buildIndexStandalone(options));
};
diff --git a/code/core/src/common/presets.ts b/code/core/src/common/presets.ts
index cb340f7cb6b2..38298c4b22a3 100644
--- a/code/core/src/common/presets.ts
+++ b/code/core/src/common/presets.ts
@@ -15,6 +15,7 @@ import type {
import { join, parse, resolve } from 'pathe';
import { dedent } from 'ts-dedent';
+import type { ChannelLike } from '../channels';
import { importModule, safeResolveModule } from '../shared/utils/module';
import { getInterpretedFile } from './utils/interpret-files';
import { stripAbsNodeModulesPath } from './utils/strip-abs-node-modules-path';
@@ -335,6 +336,7 @@ export async function loadAllPresets(
/** Whether preset failures should be critical or not */
isCritical?: boolean;
build?: StorybookConfigRaw['build'];
+ channel: ChannelLike;
}
) {
const { corePresets = [], overridePresets = [], ...restOptions } = options;
diff --git a/code/core/src/components/components/Popover/PopoverProvider.stories.tsx b/code/core/src/components/components/Popover/PopoverProvider.stories.tsx
index c6a4f4f68cb5..0db176a23c44 100644
--- a/code/core/src/components/components/Popover/PopoverProvider.stories.tsx
+++ b/code/core/src/components/components/Popover/PopoverProvider.stories.tsx
@@ -28,6 +28,7 @@ const meta = preview.meta({
title: 'Overlay/PopoverProvider',
component: PopoverProvider,
args: {
+ ariaLabel: 'Sample popover',
hasChrome: true,
offset: 8,
placement: 'top',
diff --git a/code/core/src/components/components/Popover/PopoverProvider.tsx b/code/core/src/components/components/Popover/PopoverProvider.tsx
index 7b953613c32a..d597c8d0f494 100644
--- a/code/core/src/components/components/Popover/PopoverProvider.tsx
+++ b/code/core/src/components/components/Popover/PopoverProvider.tsx
@@ -1,5 +1,7 @@
import type { DOMAttributes, ReactElement, ReactNode } from 'react';
-import React, { useCallback, useState } from 'react';
+import React, { cloneElement, useCallback, useState } from 'react';
+
+import { deprecate } from 'storybook/internal/client-logger';
import { Pressable } from '@react-aria/interactions';
import { DialogTrigger } from 'react-aria-components/patched-dist/Dialog';
@@ -9,6 +11,12 @@ import { type PopperPlacement, convertToReactAriaPlacement } from '../shared/ove
import { Popover } from './Popover';
export interface PopoverProviderProps {
+ /**
+ * An accessible label for the popover dialog, announced by screen readers. This prop will become
+ * mandatory in Storybook 11. Provide a concise description of the popover's purpose.
+ */
+ ariaLabel?: string;
+
/** Whether to display the Popover in a prestyled container. True by default. */
hasChrome?: boolean;
@@ -53,6 +61,7 @@ export interface PopoverProviderProps {
}
export const PopoverProvider = ({
+ ariaLabel,
placement: placementProp = 'bottom-start',
hasChrome = true,
hasCloseButton = false,
@@ -66,6 +75,12 @@ export const PopoverProvider = ({
onVisibleChange,
...props
}: PopoverProviderProps) => {
+ if (!ariaLabel) {
+ deprecate(
+ "The 'ariaLabel' prop on 'PopoverProvider' will become mandatory in Storybook 11. Provide a concise, accessible label describing the popover's purpose."
+ );
+ }
+
// Map Popper.js placement to react-aria placement best we can.
const placement = convertToReactAriaPlacement(placementProp);
@@ -86,8 +101,22 @@ export const PopoverProvider = ({
onOpenChange={onOpenChange}
{...props}
>
- {children}
-
+
+ {
+ /* React-aria does not inject aria-haspopup='dialog' to support legacy screen readers, so we do it ourselves. */
+ cloneElement(
+ children,
+ // @ts-expect-error aria-haspopup is a valid ARIA attribute but cloneElement types are too strict
+ { 'aria-haspopup': 'dialog' }
+ )
+ }
+
+
(
height: '300px',
}}
>
-
+
diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts
index 2f9f02502af1..27fbd87539f0 100644
--- a/code/core/src/core-server/build-dev.ts
+++ b/code/core/src/core-server/build-dev.ts
@@ -26,11 +26,14 @@ import { dedent } from 'ts-dedent';
import { detectPnp } from '../cli/detect';
import { resolvePackageDir } from '../shared/utils/module';
import { storybookDevServer } from './dev-server';
+import { getWsToken } from './presets/wsToken';
import { buildOrThrow } from './utils/build-or-throw';
import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders';
+import { getServerChannel } from './utils/get-server-channel';
import { outputStartupInformation } from './utils/output-startup-information';
import { outputStats } from './utils/output-stats';
import { getServerChannelUrl, getServerPort } from './utils/server-address';
+import { getServer } from './utils/server-init';
import { stripCommentsAndStrings } from './utils/strip-comments-and-strings';
import { updateCheck } from './utils/update-check';
import { warnOnIncompatibleAddons } from './utils/warnOnIncompatibleAddons';
@@ -142,6 +145,9 @@ export async function buildDevStandalone(
await warnWhenUsingArgTypesRegex(previewConfigPath, config);
} catch (e) {}
+ const server = await getServer(options);
+ const channel = getServerChannel(server, getWsToken());
+
// Load first pass: We need to determine the builder
// We need to do this because builders might introduce 'overridePresets' which we need to take into account
// We hope to remove this in SB8
@@ -152,6 +158,7 @@ export async function buildDevStandalone(
],
...options,
isCritical: true,
+ channel,
});
const { renderer, builder, disableTelemetry } = await presets.apply('core', {});
@@ -209,19 +216,22 @@ export async function buildDevStandalone(
import.meta.resolve('storybook/internal/core-server/presets/common-override-preset'),
],
...options,
+ channel,
});
const features = await presets.apply('features');
global.FEATURES = features;
+ await presets.apply('experimental_serverChannel', channel);
const fullOptions: Options = {
...options,
presets,
features,
+ channel,
};
const { address, networkAddress, managerResult, previewResult } = await buildOrThrow(async () =>
- storybookDevServer(fullOptions)
+ storybookDevServer(fullOptions, server)
);
const previewTotalTime = previewResult?.totalTime;
diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts
index 3375f84edcd8..8feef56617e4 100644
--- a/code/core/src/core-server/build-static.ts
+++ b/code/core/src/core-server/build-static.ts
@@ -1,11 +1,11 @@
import { cp, mkdir } from 'node:fs/promises';
import { rm } from 'node:fs/promises';
+import { Channel } from 'storybook/internal/channels';
import {
loadAllPresets,
loadMainConfig,
logConfig,
- normalizeStories,
resolveAddonName,
} from 'storybook/internal/common';
import { logger } from 'storybook/internal/node-logger';
@@ -68,16 +68,25 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption
.resolve('storybook/internal/core-server/presets/common-override-preset');
logger.step('Loading presets');
+
+ // no-op channel, as it's only relevant in dev mode
+ const channel = new Channel({});
let presets = await loadAllPresets({
corePresets: [commonPreset, ...corePresets],
overridePresets: [commonOverridePreset],
isCritical: true,
+ channel,
...options,
});
const { renderer } = await presets.apply('core', {});
const build = await presets.apply('build', {});
- const [previewBuilder, managerBuilder] = await getBuilders({ ...options, presets, build });
+ const [previewBuilder, managerBuilder] = await getBuilders({
+ ...options,
+ presets,
+ build,
+ channel,
+ });
const resolvedRenderer = renderer
? resolveAddonName(options.configDir, renderer, options)
@@ -91,8 +100,9 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption
...corePresets,
],
overridePresets: [...(previewBuilder.overridePresets || []), commonOverridePreset],
- ...options,
build,
+ channel,
+ ...options,
});
const [features, core, staticDirs] = await Promise.all([
@@ -110,6 +120,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption
const fullOptions: Options = {
...options,
+ channel,
presets,
features,
build,
diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts
index 1f3d5329fcbd..accc93fdcca6 100644
--- a/code/core/src/core-server/dev-server.ts
+++ b/code/core/src/core-server/dev-server.ts
@@ -4,7 +4,6 @@ import { MissingBuilderError } from 'storybook/internal/server-errors';
import type { Options } from 'storybook/internal/types';
import compression from '@polka/compression';
-import assert from 'assert';
import polka from 'polka';
import invariant from 'tiny-invariant';
@@ -13,7 +12,6 @@ import { type StoryIndexGenerator } from './utils/StoryIndexGenerator';
import { doTelemetry } from './utils/doTelemetry';
import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders';
import { getCachingMiddleware } from './utils/get-caching-middleware';
-import { getServerChannel } from './utils/get-server-channel';
import { getAccessControlMiddleware } from './utils/getAccessControlMiddleware';
import { registerIndexJsonRoute } from './utils/index-json';
import { registerManifests } from './utils/manifests/manifests';
@@ -21,20 +19,17 @@ import { useStorybookMetadata } from './utils/metadata';
import { getMiddleware } from './utils/middleware';
import { openInBrowser } from './utils/open-browser/open-in-browser';
import { getServerAddresses } from './utils/server-address';
-import { getServer } from './utils/server-init';
+import type { getServer } from './utils/server-init';
import { useStatics } from './utils/server-statics';
import { summarizeIndex } from './utils/summarizeIndex';
-export async function storybookDevServer(options: Options) {
- const [server, core] = await Promise.all([getServer(options), options.presets.apply('core')]);
- const app = polka({ server });
-
- assert(core?.channelOptions?.wsToken, 'wsToken is required for securing the server channel');
+export async function storybookDevServer(
+ options: Options,
+ server: Awaited>
+) {
+ const core = await options.presets.apply('core');
- const serverChannel = await options.presets.apply(
- 'experimental_serverChannel',
- getServerChannel(server, core.channelOptions.wsToken)
- );
+ const app = polka({ server });
const workingDir = process.cwd();
const configDir = options.configDir;
@@ -62,7 +57,7 @@ export async function storybookDevServer(options: Options) {
app,
storyIndexGeneratorPromise,
normalizedStories,
- serverChannel,
+ channel: options.channel,
workingDir,
configDir,
});
@@ -110,7 +105,7 @@ export async function storybookDevServer(options: Options) {
options,
router: app,
server,
- channel: serverChannel,
+ channel: options.channel,
});
let previewResult: Awaited> =
@@ -124,7 +119,7 @@ export async function storybookDevServer(options: Options) {
options,
router: app,
server,
- channel: serverChannel,
+ channel: options.channel,
})
.catch(async (e: any) => {
logger.error('Failed to build the preview');
diff --git a/code/core/src/core-server/load.ts b/code/core/src/core-server/load.ts
index 8ac8ca7d19f3..0c0d716dce37 100644
--- a/code/core/src/core-server/load.ts
+++ b/code/core/src/core-server/load.ts
@@ -1,3 +1,4 @@
+import { Channel } from 'storybook/internal/channels';
import {
getProjectRoot,
loadAllPresets,
@@ -48,6 +49,9 @@ export async function loadStorybook(
// We need to do this because builders might introduce 'overridePresets' which we need to take into account
// We hope to remove this in SB8
+ // no-op channel, as it's only relevant in dev mode
+ const channel = new Channel({});
+
let presets = await loadAllPresets({
corePresets,
overridePresets: [
@@ -55,6 +59,7 @@ export async function loadStorybook(
],
...options,
isCritical: true,
+ channel,
});
const { renderer, builder } = await presets.apply('core', {});
@@ -77,6 +82,7 @@ export async function loadStorybook(
overridePresets: [
import.meta.resolve('storybook/internal/core-server/presets/common-override-preset'),
],
+ channel,
...options,
});
diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts
index bc1e172fa390..22387945616a 100644
--- a/code/core/src/core-server/presets/common-preset.ts
+++ b/code/core/src/core-server/presets/common-preset.ts
@@ -1,4 +1,3 @@
-import { randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
@@ -41,6 +40,7 @@ import { defaultFavicon, defaultStaticDirs } from '../utils/constants';
import { initializeSaveStory } from '../utils/save-story/save-story';
import { parseStaticDir } from '../utils/server-statics';
import { type OptionsWithRequiredCache, initializeWhatsNew } from '../utils/whats-new';
+import { getWsToken } from './wsToken';
const interpolate = (string: string, data: Record = {}) =>
Object.entries(data).reduce((acc, [k, v]) => acc.replace(new RegExp(`%${k}%`, 'g'), v), string);
@@ -191,12 +191,11 @@ export const experimental_serverAPI = (extension: Record, opti
* ...existing, someConfig })`, just overwriting everything and not merging with the existing
* values.
*/
-const wsToken = randomUUID();
export const core = async (existing: CoreConfig, options: Options): Promise => ({
...existing,
channelOptions: {
...(existing?.channelOptions ?? {}),
- ...(options.configType === 'DEVELOPMENT' ? { wsToken } : {}),
+ ...(options.configType === 'DEVELOPMENT' ? { wsToken: getWsToken() } : {}),
},
disableTelemetry: options.disableTelemetry === true,
enableCrashReports:
diff --git a/code/core/src/core-server/presets/wsToken.ts b/code/core/src/core-server/presets/wsToken.ts
new file mode 100644
index 000000000000..16491ee498d4
--- /dev/null
+++ b/code/core/src/core-server/presets/wsToken.ts
@@ -0,0 +1,22 @@
+import { randomUUID } from 'crypto';
+
+/**
+ * This function generates a WebSocket token and stores it in the global scope, ensuring it is a
+ * singleton.
+ *
+ * This is because otherwise there is a cyclical dependency between the server channel and the
+ * presets:
+ *
+ * 1. Token is needed to create the server channel
+ * 2. Initial loading of all presets needs the server channel
+ * 3. Core preset needs to have the token in its channel options
+ *
+ * By making the token a shared singleton, we can ensure that both the server channel and the
+ * presets have access to the same token without creating this circular dependency.
+ */
+export const getWsToken = () => {
+ if (!globalThis.STORYBOOK_WEBSOCKET_TOKEN) {
+ globalThis.STORYBOOK_WEBSOCKET_TOKEN = randomUUID();
+ }
+ return globalThis.STORYBOOK_WEBSOCKET_TOKEN;
+};
diff --git a/code/core/src/core-server/utils/doTelemetry.ts b/code/core/src/core-server/utils/doTelemetry.ts
index 2e6464dcfb96..447a96a6962c 100644
--- a/code/core/src/core-server/utils/doTelemetry.ts
+++ b/code/core/src/core-server/utils/doTelemetry.ts
@@ -28,7 +28,11 @@ export async function doTelemetry(
}
sendTelemetryError(err, 'dev', {
cliOptions: options,
- presetOptions: { ...options, corePresets: [], overridePresets: [] },
+ presetOptions: {
+ ...options,
+ corePresets: [],
+ overridePresets: [],
+ },
});
return;
}
diff --git a/code/core/src/core-server/utils/index-json.test.ts b/code/core/src/core-server/utils/index-json.test.ts
index f9a1ba45ce5c..a12d58bfec3e 100644
--- a/code/core/src/core-server/utils/index-json.test.ts
+++ b/code/core/src/core-server/utils/index-json.test.ts
@@ -96,7 +96,7 @@ describe('registerIndexJsonRoute', () => {
const mockServerChannel = { emit: vi.fn() } as any as ServerChannel;
registerIndexJsonRoute({
app,
- serverChannel: mockServerChannel,
+ channel: mockServerChannel,
workingDir,
normalizedStories,
storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(),
@@ -496,7 +496,7 @@ describe('registerIndexJsonRoute', () => {
registerIndexJsonRoute({
app,
- serverChannel: mockServerChannel,
+ channel: mockServerChannel,
workingDir,
normalizedStories,
storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(),
@@ -527,7 +527,7 @@ describe('registerIndexJsonRoute', () => {
const mockServerChannel = { emit: vi.fn() } as any as ServerChannel;
registerIndexJsonRoute({
app,
- serverChannel: mockServerChannel,
+ channel: mockServerChannel,
workingDir,
normalizedStories,
storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(),
@@ -564,7 +564,7 @@ describe('registerIndexJsonRoute', () => {
const mockServerChannel = { emit: vi.fn() } as any as ServerChannel;
registerIndexJsonRoute({
app,
- serverChannel: mockServerChannel,
+ channel: mockServerChannel,
workingDir,
normalizedStories,
storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(),
@@ -610,7 +610,7 @@ describe('registerIndexJsonRoute', () => {
const mockServerChannel = { emit: vi.fn() } as any as ServerChannel;
registerIndexJsonRoute({
app,
- serverChannel: mockServerChannel,
+ channel: mockServerChannel,
workingDir,
normalizedStories,
storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(),
diff --git a/code/core/src/core-server/utils/index-json.ts b/code/core/src/core-server/utils/index-json.ts
index 5c098805db14..daf33d195a62 100644
--- a/code/core/src/core-server/utils/index-json.ts
+++ b/code/core/src/core-server/utils/index-json.ts
@@ -1,6 +1,7 @@
import { writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
+import type { ChannelLike } from 'storybook/internal/channels';
import { STORY_INDEX_INVALIDATED } from 'storybook/internal/core-events';
import type { NormalizedStoriesSpecifier } from 'storybook/internal/types';
@@ -8,7 +9,6 @@ import { debounce } from 'es-toolkit/function';
import type { Polka } from 'polka';
import type { StoryIndexGenerator } from './StoryIndexGenerator';
-import type { ServerChannel } from './get-server-channel';
import { watchStorySpecifiers } from './watch-story-specifiers';
import { watchConfig } from './watchConfig';
@@ -28,17 +28,17 @@ export function registerIndexJsonRoute({
storyIndexGeneratorPromise,
workingDir = process.cwd(),
configDir,
- serverChannel,
+ channel,
normalizedStories,
}: {
app: Polka;
storyIndexGeneratorPromise: Promise;
- serverChannel: ServerChannel;
+ channel: ChannelLike;
workingDir?: string;
configDir?: string;
normalizedStories: NormalizedStoriesSpecifier[];
}) {
- const maybeInvalidate = debounce(() => serverChannel.emit(STORY_INDEX_INVALIDATED), DEBOUNCE, {
+ const maybeInvalidate = debounce(() => channel.emit(STORY_INDEX_INVALIDATED), DEBOUNCE, {
edges: ['leading', 'trailing'],
});
watchStorySpecifiers(normalizedStories, { workingDir }, async (path, removed) => {
diff --git a/code/core/src/core-server/utils/whats-new.ts b/code/core/src/core-server/utils/whats-new.ts
index c63bf60086cc..b9ab06edd29e 100644
--- a/code/core/src/core-server/utils/whats-new.ts
+++ b/code/core/src/core-server/utils/whats-new.ts
@@ -101,7 +101,11 @@ export function initializeWhatsNew(
if (isTelemetryEnabled) {
await sendTelemetryError(error, 'core-config', {
cliOptions: options,
- presetOptions: { ...options, corePresets: [], overridePresets: [] },
+ presetOptions: {
+ ...options,
+ corePresets: [],
+ overridePresets: [],
+ },
skipPrompt: true,
});
}
@@ -115,7 +119,11 @@ export function initializeWhatsNew(
if (isTelemetryEnabled) {
await sendTelemetryError(error, 'browser', {
cliOptions: options,
- presetOptions: { ...options, corePresets: [], overridePresets: [] },
+ presetOptions: {
+ ...options,
+ corePresets: [],
+ overridePresets: [],
+ },
skipPrompt: true,
});
}
diff --git a/code/core/src/manager/components/preview/tools/share.tsx b/code/core/src/manager/components/preview/tools/share.tsx
index caa46f7c78d4..63c688b0d4af 100644
--- a/code/core/src/manager/components/preview/tools/share.tsx
+++ b/code/core/src/manager/components/preview/tools/share.tsx
@@ -146,6 +146,7 @@ export const shareTool: Addon_BaseType = {
{({ api, storyId, refId }) =>
storyId ? (
{
{
{loaded && (
(
diff --git a/code/core/src/manager/components/sidebar/ContextMenu.tsx b/code/core/src/manager/components/sidebar/ContextMenu.tsx
index 55bd6b9f34da..93894cec3dd9 100644
--- a/code/core/src/manager/components/sidebar/ContextMenu.tsx
+++ b/code/core/src/manager/components/sidebar/ContextMenu.tsx
@@ -196,6 +196,7 @@ export const useContextMenu = (context: API_HashEntry, links: Link[], api: API)
onMouseEnter: handlers.onMouseEnter,
node: shouldRender ? (
= ({ menu, isHighlighted, onClick
return (
}
diff --git a/code/core/src/manager/components/sidebar/RefBlocks.tsx b/code/core/src/manager/components/sidebar/RefBlocks.tsx
index e908249eb068..9ef2564ab2e4 100644
--- a/code/core/src/manager/components/sidebar/RefBlocks.tsx
+++ b/code/core/src/manager/components/sidebar/RefBlocks.tsx
@@ -127,6 +127,7 @@ export const ErrorBlock: FC<{ error: Error }> = ({ error }) => {
Oh no! Something went wrong loading this Storybook.
(
diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx
index 31d49b8c0e8f..6c8dd4667ba6 100644
--- a/code/core/src/manager/components/sidebar/TagsFilter.tsx
+++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx
@@ -212,6 +212,7 @@ export const TagsFilter = ({ api, indexJson, tagPresets }: TagsFilterProps) => {
return (
{
key="tags"
ariaLabel="Tag filters"
ariaDescription="Filter the items shown in a sidebar based on the tags applied to them."
- aria-haspopup="dialog"
variant="ghost"
padding="small"
isHighlighted={tagsActive}
diff --git a/code/core/src/manager/container/Menu.stories.tsx b/code/core/src/manager/container/Menu.stories.tsx
index 5c81f339a7c9..0368fbf4d89a 100644
--- a/code/core/src/manager/container/Menu.stories.tsx
+++ b/code/core/src/manager/container/Menu.stories.tsx
@@ -21,7 +21,13 @@ export default {
height: '300px',
}}
>
-
+
diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts
index 2f9330515d27..582966f5bf52 100644
--- a/code/core/src/types/modules/core-common.ts
+++ b/code/core/src/types/modules/core-common.ts
@@ -1,4 +1,5 @@
// should be node:http, but that caused the ui/manager to fail to build, might be able to switch this back once ui/manager is in the core
+import type { ChannelLike } from 'storybook/internal/channels';
import type { FileSystemCache } from 'storybook/internal/common';
import { type StoryIndexGenerator } from 'storybook/internal/core-server';
import { type CsfFile } from 'storybook/internal/csf-tools';
@@ -19,10 +20,6 @@ import type { SupportedRenderer } from './renderers';
export type BuilderName = 'webpack5' | '@storybook/builder-webpack5' | string;
export type RendererName = string;
-interface ServerChannel {
- emit(type: string, args?: any): void;
-}
-
export interface CoreConfig {
builder?:
| BuilderName
@@ -221,6 +218,7 @@ export interface BuilderOptions {
export interface StorybookConfigOptions {
presets: Presets;
presetsList?: LoadedPreset[];
+ channel: ChannelLike;
}
export type Options = LoadOptions &
@@ -259,7 +257,7 @@ export interface Builder {
startTime: ReturnType;
router: ServerApp;
server: HttpServer;
- channel: ServerChannel;
+ channel: ChannelLike;
}) => Promise;
diff --git a/code/core/src/typings.d.ts b/code/core/src/typings.d.ts
index b82e449d0c25..512933868d83 100644
--- a/code/core/src/typings.d.ts
+++ b/code/core/src/typings.d.ts
@@ -6,6 +6,8 @@ declare var LOGLEVEL: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' |
declare var REFS: any;
declare var VERSIONCHECK: any;
+declare var STORYBOOK_WEBSOCKET_TOKEN: string;
+
declare var STORYBOOK_ADDON_STATE: Record;
declare var STORYBOOK_BUILDER: import('./types/modules/builders').SupportedBuilder | undefined;
declare var STORYBOOK_FRAMEWORK:
diff --git a/code/frameworks/angular/src/builders/build-storybook/index.ts b/code/frameworks/angular/src/builders/build-storybook/index.ts
index 2f34d7bc3aba..3401d2aaea2c 100644
--- a/code/frameworks/angular/src/builders/build-storybook/index.ts
+++ b/code/frameworks/angular/src/builders/build-storybook/index.ts
@@ -31,6 +31,7 @@ import { errorSummary, printErrorDetails } from '../utils/error-handler';
import { runCompodoc } from '../utils/run-compodoc';
import type { StandaloneOptions } from '../utils/standalone-options';
import { VERSION } from '@angular/core';
+import { Channel } from 'storybook/internal/channels';
addToGlobalContext('cliVersion', versions.storybook);
@@ -192,7 +193,12 @@ async function runInstance(options: StandaloneBuildOptions) {
'build',
{
cliOptions: options,
- presetOptions: { ...options, corePresets: [], overridePresets: [] },
+ presetOptions: {
+ ...options,
+ corePresets: [],
+ overridePresets: [],
+ channel: new Channel({}),
+ },
printError: printErrorDetails,
},
async () => {
diff --git a/code/frameworks/angular/src/builders/start-storybook/index.ts b/code/frameworks/angular/src/builders/start-storybook/index.ts
index 977dd4cc717f..b79002708863 100644
--- a/code/frameworks/angular/src/builders/start-storybook/index.ts
+++ b/code/frameworks/angular/src/builders/start-storybook/index.ts
@@ -32,6 +32,7 @@ import { errorSummary, printErrorDetails } from '../utils/error-handler';
import { runCompodoc } from '../utils/run-compodoc';
import type { StandaloneOptions } from '../utils/standalone-options';
import { VERSION } from '@angular/core';
+import { Channel } from 'storybook/internal/channels';
addToGlobalContext('cliVersion', versions.storybook);
@@ -238,7 +239,12 @@ async function runInstance(options: StandaloneOptions) {
'dev',
{
cliOptions: options,
- presetOptions: { ...options, corePresets: [], overridePresets: [] },
+ presetOptions: {
+ ...options,
+ corePresets: [],
+ overridePresets: [],
+ channel: new Channel({}),
+ },
printError: printErrorDetails,
},
() => {
diff --git a/code/frameworks/angular/src/server/framework-preset-angular-cli.test.ts b/code/frameworks/angular/src/server/framework-preset-angular-cli.test.ts
index d702777f2b86..c91ad6df7ac6 100644
--- a/code/frameworks/angular/src/server/framework-preset-angular-cli.test.ts
+++ b/code/frameworks/angular/src/server/framework-preset-angular-cli.test.ts
@@ -7,6 +7,7 @@ import { logging } from '@angular-devkit/core';
import { getBuilderOptions } from './framework-preset-angular-cli';
import type { PresetOptions } from './preset-options';
+import { Channel } from 'storybook/internal/channels';
// Mock all dependencies
vi.mock('storybook/internal/node-logger', () => ({
@@ -89,6 +90,7 @@ describe('framework-preset-angular-cli', () => {
apply: vi.fn(),
} as any,
angularBrowserTarget: 'test-project:build:development',
+ channel: new Channel({}),
};
await getBuilderOptions(options, mockBuilderContext);
@@ -119,6 +121,7 @@ describe('framework-preset-angular-cli', () => {
} as any,
angularBrowserTarget: 'test-project:build',
angularBuilderOptions: storybookOptions,
+ channel: new Channel({}),
};
const result = await getBuilderOptions(options, mockBuilderContext);
@@ -139,6 +142,7 @@ describe('framework-preset-angular-cli', () => {
apply: vi.fn(),
} as any,
tsConfig: '/custom/tsconfig.json',
+ channel: new Channel({}),
};
const result = await getBuilderOptions(options, mockBuilderContext);
@@ -156,6 +160,7 @@ describe('framework-preset-angular-cli', () => {
presets: {
apply: vi.fn(),
} as any,
+ channel: new Channel({}),
};
const result = await getBuilderOptions(options, mockBuilderContext);
@@ -182,6 +187,7 @@ describe('framework-preset-angular-cli', () => {
apply: vi.fn(),
} as any,
angularBrowserTarget: 'test-project:build',
+ channel: new Channel({}),
};
const result = await getBuilderOptions(options, mockBuilderContext);
@@ -196,6 +202,7 @@ describe('framework-preset-angular-cli', () => {
presets: {
apply: vi.fn(),
} as any,
+ channel: new Channel({}),
};
const result = await getBuilderOptions(options, mockBuilderContext);
@@ -218,6 +225,7 @@ describe('framework-preset-angular-cli', () => {
apply: vi.fn(),
} as any,
angularBrowserTarget: 'test-project:build',
+ channel: new Channel({}),
};
const result = await getBuilderOptions(options, mockBuilderContext);
@@ -238,6 +246,7 @@ describe('framework-preset-angular-cli', () => {
apply: vi.fn(),
} as any,
angularBrowserTarget: 'test-project:build:production',
+ channel: new Channel({}),
};
const result = await getBuilderOptions(options, mockBuilderContext);
@@ -262,6 +271,7 @@ describe('framework-preset-angular-cli', () => {
} as any,
angularBrowserTarget: 'test-project:build',
angularBuilderOptions: {},
+ channel: new Channel({}),
};
const result = await getBuilderOptions(options, mockBuilderContext);
@@ -287,6 +297,7 @@ describe('framework-preset-angular-cli', () => {
apply: vi.fn(),
} as any,
angularBrowserTarget: 'test-project:build',
+ channel: new Channel({}),
};
const result = await getBuilderOptions(options, mockBuilderContext);
diff --git a/code/package.json b/code/package.json
index 6e851fb208c4..b4df7d5be045 100644
--- a/code/package.json
+++ b/code/package.json
@@ -220,5 +220,6 @@
"Dependency Upgrades"
]
]
- }
+ },
+ "deferredNextVersion": "10.3.0-alpha.8"
}
diff --git a/code/playwright.config.ts b/code/playwright.config.ts
index fdd61266912d..af6f071f3c5c 100644
--- a/code/playwright.config.ts
+++ b/code/playwright.config.ts
@@ -47,7 +47,7 @@ export default defineConfig({
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
- trace: 'on-first-retry',
+ trace: 'retain-on-failure',
},
/* Configure projects for major browsers */
diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts
index 1bf32627f73d..f170d0d01887 100644
--- a/code/renderers/react/src/componentManifest/generator.test.ts
+++ b/code/renderers/react/src/componentManifest/generator.test.ts
@@ -575,6 +575,89 @@ test('should create component manifest when only attached-mdx docs have manifest
`);
});
+test('stories are populated when meta has no explicit title', async () => {
+ vol.fromJSON(
+ {
+ ['./package.json']: JSON.stringify({ name: 'some-package' }),
+ ['./src/stories/Card.stories.ts']: dedent`
+ import type { Meta, StoryObj } from '@storybook/react';
+ import { Card } from './Card';
+
+ const meta: Meta = {
+ component: Card,
+ };
+ export default meta;
+ type Story = StoryObj;
+
+ export const Default: Story = { args: { label: 'Click me' } };
+ export const Large: Story = { args: { label: 'Big button', size: 'large' } };
+ `,
+ ['./src/stories/Card.tsx']: dedent`
+ import React from 'react';
+ export interface CardProps {
+ label: string;
+ size?: 'small' | 'large';
+ }
+
+ /** A simple card component */
+ export const Card = ({ label, size }: CardProps) => {
+ return {label}
;
+ };
+ `,
+ },
+ '/app'
+ );
+
+ const manifestEntries = [
+ {
+ type: 'story',
+ subtype: 'story',
+ id: 'card--default',
+ name: 'Default',
+ title: 'Card',
+ importPath: './src/stories/Card.stories.ts',
+ componentPath: './src/stories/Card.tsx',
+ tags: [Tag.DEV, Tag.TEST, Tag.MANIFEST],
+ exportName: 'Default',
+ },
+ {
+ type: 'story',
+ subtype: 'story',
+ id: 'card--large',
+ name: 'Large',
+ title: 'Card',
+ importPath: './src/stories/Card.stories.ts',
+ componentPath: './src/stories/Card.tsx',
+ tags: [Tag.DEV, Tag.TEST, Tag.MANIFEST],
+ exportName: 'Large',
+ },
+ ];
+
+ const result = await manifests(undefined, { manifestEntries } as any);
+ const component = result?.components?.components?.['card'];
+
+ // When no explicit title is in the meta, stories should still be populated
+ // because the generator should use the index entry's title as fallback
+ expect(component?.stories).toMatchInlineSnapshot(`
+ [
+ {
+ "description": undefined,
+ "id": "card--default",
+ "name": "Default",
+ "snippet": "const Default = () => ;",
+ "summary": undefined,
+ },
+ {
+ "description": undefined,
+ "id": "card--large",
+ "name": "Large",
+ "snippet": "const Large = () => ;",
+ "summary": undefined,
+ },
+ ]
+ `);
+});
+
test('should extract story description and summary from JSDoc comments', async () => {
const code = withCSF3(dedent`
/**
diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts
index 1c2115c3a63b..7c98a2e97050 100644
--- a/code/renderers/react/src/componentManifest/generator.ts
+++ b/code/renderers/react/src/componentManifest/generator.ts
@@ -130,7 +130,7 @@ export const manifests: PresetPropertyFn<
(entry as DocsIndexEntry).storiesImports[0];
const absoluteImportPath = path.join(process.cwd(), storyFilePath);
const storyFile = cachedReadFileSync(absoluteImportPath, 'utf-8') as string;
- const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse();
+ const csf = loadCsf(storyFile, { makeTitle: () => entry.title }).parse();
const componentName = csf._meta?.component;
const id = entry.id.split('--')[0];
diff --git a/docs/versions/next.json b/docs/versions/next.json
index 575b2ea5f85c..e1ef8669414a 100644
--- a/docs/versions/next.json
+++ b/docs/versions/next.json
@@ -1 +1 @@
-{"version":"10.3.0-alpha.7","info":{"plain":"- Core: Require token for websocket connections - [#33820](https://github.com/storybookjs/storybook/pull/33820), thanks @ghengeveld!\n- Next.js: Handle legacyBehavior prop in Link mock component - [#33862](https://github.com/storybookjs/storybook/pull/33862), thanks @yatishgoel!\n- Preact: Support inferring props from component types - [#33828](https://github.com/storybookjs/storybook/pull/33828), thanks @JoviDeCroock!"}}
\ No newline at end of file
+{"version":"10.3.0-alpha.8","info":{"plain":"- A11y: Ensure popover dialogs have an ARIA label - [#33500](https://github.com/storybookjs/storybook/pull/33500), thanks @gayanMatch!\n- Addon-Vitest: Add channel API to programmatically trigger test runs - [#33206](https://github.com/storybookjs/storybook/pull/33206), thanks @JReinhold!\n- Builder-Vite: Centralize Vite plugins for builder-vite and addon-vitest - [#33819](https://github.com/storybookjs/storybook/pull/33819), thanks @valentinpalkovic!\n- Core: Revert Pull Request #33420 from Maelryn/fix/copy-button-overlap - [#33877](https://github.com/storybookjs/storybook/pull/33877), thanks @Sidnioulz!\n- Next.js-Vite: Fix failing postcss mutation - [#33879](https://github.com/storybookjs/storybook/pull/33879), thanks @valentinpalkovic!\n- React: Fix manifest stories empty when meta has no explicit title - [#33878](https://github.com/storybookjs/storybook/pull/33878), thanks @kasperpeulen!\n- UI: Fix Copy button overlapping code in portrait mode - [#33420](https://github.com/storybookjs/storybook/pull/33420), thanks @Maelryn!"}}
\ No newline at end of file
diff --git a/scripts/ci/sandboxes.ts b/scripts/ci/sandboxes.ts
index 760b9299add3..fc1ebec3fbb0 100644
--- a/scripts/ci/sandboxes.ts
+++ b/scripts/ci/sandboxes.ts
@@ -97,6 +97,10 @@ function defineSandboxJob_dev({
},
},
artifact.persist(join(LINUX_ROOT_DIR, WORKING_DIR, 'test-results'), 'test-results'),
+ artifact.persist(
+ join(LINUX_ROOT_DIR, WORKING_DIR, 'code', 'playwright-results'),
+ 'playwright-results'
+ ),
testResults.persist(join(LINUX_ROOT_DIR, WORKING_DIR, 'test-results')),
]
: [
@@ -289,6 +293,10 @@ export function defineSandboxFlow(key: Key) {
},
},
artifact.persist(join(LINUX_ROOT_DIR, WORKING_DIR, 'test-results'), 'test-results'),
+ artifact.persist(
+ join(LINUX_ROOT_DIR, WORKING_DIR, 'code', 'playwright-results'),
+ 'playwright-results'
+ ),
testResults.persist(join(LINUX_ROOT_DIR, WORKING_DIR, 'test-results')),
],
}),
diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts
index 6e26c07b7bac..87bcc1442af1 100644
--- a/scripts/tasks/sandbox-parts.ts
+++ b/scripts/tasks/sandbox-parts.ts
@@ -497,8 +497,7 @@ export async function setupVitest(details: TemplateDetails, options: PassedOptio
if (shouldUseCsf4) {
await writeFile(
setupFilePath,
- dedent`import { beforeAll } from 'vitest'
- import { setProjectAnnotations } from '${storybookPackage}'
+ dedent`import { setProjectAnnotations } from '${storybookPackage}'
import projectAnnotations from './preview'
// setProjectAnnotations still kept to support non-CSF4 story tests
@@ -508,7 +507,7 @@ export async function setupVitest(details: TemplateDetails, options: PassedOptio
} else {
await writeFile(
setupFilePath,
- dedent`import { beforeAll } from 'vitest'
+ dedent`
import { setProjectAnnotations } from '${storybookPackage}'
import * as rendererDocsAnnotations from '${template.expected.renderer}/entry-preview-docs'
import * as addonA11yAnnotations from '@storybook/addon-a11y/preview'
diff --git a/yarn.lock b/yarn.lock
index ab9fa385dbad..60717fa42593 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7728,6 +7728,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@storybook/addon-vitest@workspace:code/addons/vitest"
dependencies:
+ "@storybook/addon-a11y": "workspace:*"
"@storybook/global": "npm:^5.0.0"
"@storybook/icons": "npm:^2.0.1"
"@types/istanbul-lib-report": "npm:^3.0.3"