From 72eeb82a585a48a73589d88182c271d58c6bca7f Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 28 Aug 2025 19:40:17 +0200 Subject: [PATCH 01/81] initial triggerTestRun function --- code/addons/vitest/src/index.ts | 56 +++++++++++++++++++++++++++++++- code/addons/vitest/src/preset.ts | 1 + code/addons/vitest/src/types.ts | 7 +++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/code/addons/vitest/src/index.ts b/code/addons/vitest/src/index.ts index ed9c6eedc4b4..11f489de9d01 100644 --- a/code/addons/vitest/src/index.ts +++ b/code/addons/vitest/src/index.ts @@ -1,3 +1,57 @@ -import { definePreviewAddon } from 'storybook/internal/csf'; +import { type StoryId, definePreviewAddon } from 'storybook/internal/csf'; + +import type { Store } from './types'; export default () => definePreviewAddon({}); + +export async function triggerTestRun(actor: string, storyIds?: StoryId[]) { + const store: Store | undefined = globalThis.__STORYBOOK_ADDON_VITEST_STORE__; + if (!store) { + throw new Error('store not ready yet'); + } + + await store.untilReady(); + + const { + currentRun: { startedAt, finishedAt }, + } = store.getState(); + if (startedAt && !finishedAt) { + throw new Error('tests are already running'); + } + + store.send({ + type: 'TRIGGER_RUN', + payload: { + storyIds, + triggeredBy: `external:${actor}`, + }, + }); + + return new Promise((resolve, reject) => { + const unsubscribe = store.subscribe((event) => { + switch (event.type) { + case 'TEST_RUN_COMPLETED': { + console.log('Completed!'); + console.dir(event.payload, { depth: 5 }); + unsubscribe(); + resolve(event.payload); + return; + } + case 'FATAL_ERROR': { + console.log('ERROR!'); + console.dir(event.payload, { depth: 5 }); + unsubscribe(); + reject(event.payload); + return; + } + case 'CANCEL_RUN': { + console.log('CANCEL!'); + console.dir(event, { depth: 5 }); + unsubscribe(); + reject('cancelled'); + return; + } + } + }); + }); +} diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index b96623a7ea27..c07106388e49 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -98,6 +98,7 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti fsCache.set('state', selectCachedState(state)); } }); + globalThis.__STORYBOOK_ADDON_VITEST_STORE__ = store; const testProviderStore = experimental_getTestProviderStore(ADDON_ID); store.subscribe('TRIGGER_RUN', (event, eventInfo) => { diff --git a/code/addons/vitest/src/types.ts b/code/addons/vitest/src/types.ts index b6eca96f92de..3392e6e74cf9 100644 --- a/code/addons/vitest/src/types.ts +++ b/code/addons/vitest/src/types.ts @@ -20,7 +20,12 @@ 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 StoreState = { config: { From a6a01d67440e95396938096ab67460d91587cf19 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 29 Aug 2025 21:02:15 +0200 Subject: [PATCH 02/81] add story statuses to trigger api, take screenshots --- code/addons/vitest/build-config.ts | 4 ++ code/addons/vitest/package.json | 4 ++ code/addons/vitest/preview.js | 1 + code/addons/vitest/src/constants.ts | 1 + code/addons/vitest/src/index.ts | 13 ++++++- code/addons/vitest/src/node/test-manager.ts | 30 ++++++++++++++- code/addons/vitest/src/node/vitest.ts | 2 + code/addons/vitest/src/preview.ts | 41 +++++++++++++++++++++ 8 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 code/addons/vitest/preview.js create mode 100644 code/addons/vitest/src/preview.ts diff --git a/code/addons/vitest/build-config.ts b/code/addons/vitest/build-config.ts index db187f466942..989ec342a74d 100644 --- a/code/addons/vitest/build-config.ts +++ b/code/addons/vitest/build-config.ts @@ -12,6 +12,10 @@ const config: BuildEntries = { entryPoint: './src/manager.tsx', dts: false, }, + { + exportEntries: ['./preview'], + entryPoint: './src/preview.ts', + }, { exportEntries: ['./internal/setup-file'], entryPoint: './src/vitest-plugin/setup-file.ts', diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json index 61ef99947b04..937efab539aa 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -50,6 +50,10 @@ "./package.json": "./package.json", "./postinstall": "./dist/postinstall.js", "./preset": "./dist/preset.js", + "./preview": { + "types": "./dist/preview.d.ts", + "default": "./dist/preview.js" + }, "./vitest": "./dist/node/vitest.js", "./vitest-plugin": { "types": "./dist/vitest-plugin/index.d.ts", diff --git a/code/addons/vitest/preview.js b/code/addons/vitest/preview.js new file mode 100644 index 000000000000..542d45f8cf26 --- /dev/null +++ b/code/addons/vitest/preview.js @@ -0,0 +1 @@ +export * from './dist/preview.js'; diff --git a/code/addons/vitest/src/constants.ts b/code/addons/vitest/src/constants.ts index 7ef81afb73d3..6734b2c8e4be 100644 --- a/code/addons/vitest/src/constants.ts +++ b/code/addons/vitest/src/constants.ts @@ -75,3 +75,4 @@ 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'; +export const STATUS_TYPE_ID_SCREENSHOT = 'storybook/screenshot'; diff --git a/code/addons/vitest/src/index.ts b/code/addons/vitest/src/index.ts index 11f489de9d01..5706500adc08 100644 --- a/code/addons/vitest/src/index.ts +++ b/code/addons/vitest/src/index.ts @@ -1,10 +1,14 @@ +import { experimental_getStatusStore } from 'storybook/internal/core-server'; import { type StoryId, definePreviewAddon } from 'storybook/internal/csf'; +import { STATUS_TYPE_ID_COMPONENT_TEST } from './constants'; import type { Store } from './types'; export default () => definePreviewAddon({}); export async function triggerTestRun(actor: string, storyIds?: StoryId[]) { + // TODO: there must be a smarter way to share the store here + // while still lazy initializing it in the experimental_serverChannel preset const store: Store | undefined = globalThis.__STORYBOOK_ADDON_VITEST_STORE__; if (!store) { throw new Error('store not ready yet'); @@ -34,7 +38,14 @@ export async function triggerTestRun(actor: string, storyIds?: StoryId[]) { console.log('Completed!'); console.dir(event.payload, { depth: 5 }); unsubscribe(); - resolve(event.payload); + + const storyStatuses = Object.values( + experimental_getStatusStore(STATUS_TYPE_ID_COMPONENT_TEST).getAll() + ).filter((statusByTypeId) => + event.payload.storyIds?.includes(statusByTypeId[STATUS_TYPE_ID_COMPONENT_TEST].storyId) + ); + + resolve({ ...event.payload, storyStatuses }); return; } case 'FATAL_ERROR': { diff --git a/code/addons/vitest/src/node/test-manager.ts b/code/addons/vitest/src/node/test-manager.ts index ce25486b7910..f8bbd605e314 100644 --- a/code/addons/vitest/src/node/test-manager.ts +++ b/code/addons/vitest/src/node/test-manager.ts @@ -11,7 +11,12 @@ import type { 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 { + STATUS_TYPE_ID_A11Y, + STATUS_TYPE_ID_COMPONENT_TEST, + STATUS_TYPE_ID_SCREENSHOT, + storeOptions, +} from '../constants'; import type { RunTrigger, StoreEvent, StoreState, TriggerRunEvent, VitestError } from '../types'; import { errorToErrorLike } from '../utils'; import { VitestManager } from './vitest-manager'; @@ -21,6 +26,7 @@ export type TestManagerOptions = { store: experimental_UniversalStore; componentTestStatusStore: StatusStoreByTypeId; a11yStatusStore: StatusStoreByTypeId; + screenshotStatusStore: StatusStoreByTypeId; testProviderStore: TestProviderStoreById; onError?: (message: string, error: Error) => void; onReady?: () => void; @@ -43,6 +49,8 @@ export class TestManager { private a11yStatusStore: TestManagerOptions['a11yStatusStore']; + private screenshotStatusStore: TestManagerOptions['screenshotStatusStore']; + private testProviderStore: TestManagerOptions['testProviderStore']; private onReady?: TestManagerOptions['onReady']; @@ -59,6 +67,7 @@ export class TestManager { this.store = options.store; this.componentTestStatusStore = options.componentTestStatusStore; this.a11yStatusStore = options.a11yStatusStore; + this.screenshotStatusStore = options.screenshotStatusStore; this.testProviderStore = options.testProviderStore; this.onReady = options.onReady; this.storybookOptions = options.storybookOptions; @@ -256,6 +265,25 @@ export class TestManager { if (a11yStatuses.length > 0) { this.a11yStatusStore.set(a11yStatuses); } + + const screenshotStatuses = testCaseResultsToFlush + .flatMap(({ storyId, reports }) => + reports + ?.filter((r) => r.type === 'screenshot') + .map((screenshotReport) => ({ + storyId, + typeId: STATUS_TYPE_ID_SCREENSHOT, + value: testStateToStatusValueMap[screenshotReport.status], + title: 'Screenshot', + description: screenshotReport.result, + sidebarContextMenu: false, + })) + ) + .filter((screenshotStatus) => screenshotStatus !== undefined); + + if (screenshotStatuses.length > 0) { + this.screenshotStatusStore.set(screenshotStatuses); + } }, 500); onTestRunEnd(endResult: { totalTestCount: number; unhandledErrors: VitestError[] }) { diff --git a/code/addons/vitest/src/node/vitest.ts b/code/addons/vitest/src/node/vitest.ts index 92d5871ff485..cf8470cde4ee 100644 --- a/code/addons/vitest/src/node/vitest.ts +++ b/code/addons/vitest/src/node/vitest.ts @@ -11,6 +11,7 @@ import { ADDON_ID, STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST, + STATUS_TYPE_ID_SCREENSHOT, storeOptions, } from '../constants'; import type { ErrorLike, FatalErrorEvent, StoreEvent, StoreState } from '../types'; @@ -41,6 +42,7 @@ new TestManager({ store, componentTestStatusStore: getStatusStore(STATUS_TYPE_ID_COMPONENT_TEST), a11yStatusStore: getStatusStore(STATUS_TYPE_ID_A11Y), + screenshotStatusStore: getStatusStore(STATUS_TYPE_ID_SCREENSHOT), testProviderStore: getTestProviderStore(ADDON_ID), onReady: () => { process.send?.({ type: 'ready' }); diff --git a/code/addons/vitest/src/preview.ts b/code/addons/vitest/src/preview.ts new file mode 100644 index 000000000000..69cba22a0dc9 --- /dev/null +++ b/code/addons/vitest/src/preview.ts @@ -0,0 +1,41 @@ +import type { StoryAnnotations } from 'storybook/internal/types'; + +console.log('addon vitest preview!'); + +// TODO: maybe this doesn't have to be an explicit preview annotation, but can be automatically added into vitest annotations somehow + +const preview: StoryAnnotations = { + afterEach: async ({ title, name, canvasElement, reporting }) => { + if (!(globalThis as any).__vitest_browser__) { + return; + } + //TODO: toggle this on an off based on something, probably like a11y + try { + console.log(`Taking screenshot for "${name}"`); + const { page } = await import('@vitest/browser/context'); + + const base64 = await page.screenshot({ + path: `screenshots/${title}/${name}.png`, + base64: true, + element: canvasElement.firstChild, + }); + + reporting.addReport({ + type: 'screenshot', + version: 1, + result: base64, + status: 'passed', + }); + } catch (error) { + console.error('Error taking screenshot', error); + reporting.addReport({ + type: 'screenshot', + version: 1, + result: error, + status: 'failed', + }); + } + }, +}; + +export default preview; From 2fd88b7646b128defd52b68b1ad3051f4107bf0e Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 27 Nov 2025 11:12:21 +0100 Subject: [PATCH 03/81] fix type --- code/addons/vitest/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/addons/vitest/src/index.ts b/code/addons/vitest/src/index.ts index 5706500adc08..b4ee2f7ddc59 100644 --- a/code/addons/vitest/src/index.ts +++ b/code/addons/vitest/src/index.ts @@ -9,7 +9,7 @@ export default () => definePreviewAddon({}); export async function triggerTestRun(actor: string, storyIds?: StoryId[]) { // TODO: there must be a smarter way to share the store here // while still lazy initializing it in the experimental_serverChannel preset - const store: Store | undefined = globalThis.__STORYBOOK_ADDON_VITEST_STORE__; + const store: Store | undefined = (globalThis as any).__STORYBOOK_ADDON_VITEST_STORE__; if (!store) { throw new Error('store not ready yet'); } From 8575ab45214bb69c4d870a11f4e5ea5d8438917e Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 28 Nov 2025 10:05:12 +0100 Subject: [PATCH 04/81] try to add statuses to currentRun data structure --- code/addons/vitest/src/constants.ts | 2 + code/addons/vitest/src/index.ts | 9 +-- code/addons/vitest/src/node/test-manager.ts | 81 ++++++++------------- code/addons/vitest/src/types.ts | 4 +- 4 files changed, 38 insertions(+), 58 deletions(-) diff --git a/code/addons/vitest/src/constants.ts b/code/addons/vitest/src/constants.ts index 6d36d0dc36c7..ab6631b17294 100644 --- a/code/addons/vitest/src/constants.ts +++ b/code/addons/vitest/src/constants.ts @@ -36,6 +36,8 @@ export const storeOptions = { coverage: false, a11y: false, }, + componentTestStatuses: [], + a11yStatuses: [], componentTestCount: { success: 0, error: 0, diff --git a/code/addons/vitest/src/index.ts b/code/addons/vitest/src/index.ts index b4ee2f7ddc59..65113dde3b30 100644 --- a/code/addons/vitest/src/index.ts +++ b/code/addons/vitest/src/index.ts @@ -38,14 +38,7 @@ export async function triggerTestRun(actor: string, storyIds?: StoryId[]) { console.log('Completed!'); console.dir(event.payload, { depth: 5 }); unsubscribe(); - - const storyStatuses = Object.values( - experimental_getStatusStore(STATUS_TYPE_ID_COMPONENT_TEST).getAll() - ).filter((statusByTypeId) => - event.payload.storyIds?.includes(statusByTypeId[STATUS_TYPE_ID_COMPONENT_TEST].storyId) - ); - - resolve({ ...event.payload, storyStatuses }); + resolve(event.payload); return; } case 'FATAL_ERROR': { diff --git a/code/addons/vitest/src/node/test-manager.ts b/code/addons/vitest/src/node/test-manager.ts index f8bbd605e314..f8ff0dbae403 100644 --- a/code/addons/vitest/src/node/test-manager.ts +++ b/code/addons/vitest/src/node/test-manager.ts @@ -196,6 +196,36 @@ 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 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); + } + this.store.setState((s) => { let { success: ctSuccess, error: ctError } = s.currentRun.componentTestCount; let { success: a11ySuccess, warning: a11yWarning, error: a11yError } = s.currentRun.a11yCount; @@ -225,6 +255,8 @@ 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), // 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 @@ -235,55 +267,6 @@ 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); - } - - const screenshotStatuses = testCaseResultsToFlush - .flatMap(({ storyId, reports }) => - reports - ?.filter((r) => r.type === 'screenshot') - .map((screenshotReport) => ({ - storyId, - typeId: STATUS_TYPE_ID_SCREENSHOT, - value: testStateToStatusValueMap[screenshotReport.status], - title: 'Screenshot', - description: screenshotReport.result, - sidebarContextMenu: false, - })) - ) - .filter((screenshotStatus) => screenshotStatus !== undefined); - - if (screenshotStatuses.length > 0) { - this.screenshotStatusStore.set(screenshotStatuses); - } }, 500); onTestRunEnd(endResult: { totalTestCount: number; unhandledErrors: VitestError[] }) { diff --git a/code/addons/vitest/src/types.ts b/code/addons/vitest/src/types.ts index 3392e6e74cf9..8eea529ca0b9 100644 --- a/code/addons/vitest/src/types.ts +++ b/code/addons/vitest/src/types.ts @@ -1,5 +1,5 @@ import type { experimental_UniversalStore } from 'storybook/internal/core-server'; -import type { PreviewAnnotation, StoryId } from 'storybook/internal/types'; +import type { PreviewAnnotation, Status, StoryId } from 'storybook/internal/types'; import type { API_HashEntry } from 'storybook/internal/types'; export interface VitestError extends Error { @@ -47,6 +47,8 @@ export type StoreState = { currentRun: { triggeredBy: RunTrigger | undefined; config: StoreState['config']; + componentTestStatuses: Status[]; + a11yStatuses: Status[]; componentTestCount: { success: number; error: number; From b1e48b8bbb8bdbcb31ce53d87dda5ed627467e49 Mon Sep 17 00:00:00 2001 From: Maelryn <134317754+Maelryn@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:53:58 +0100 Subject: [PATCH 05/81] UI: Fix Copy button overlapping code in portrait mode Fixes #23642 Increased paddingRight in syntaxhighlighter.tsx to 85px. This prevents the code text from running under the absolute positioned "Copy" button when viewing in mobile/portrait modes. --- .../components/syntaxhighlighter/syntaxhighlighter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx index 58286cec773e..d3127f921a64 100644 --- a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx +++ b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx @@ -120,7 +120,7 @@ See https://github.com/storybookjs/storybook/issues/18090 const Code = styled.div(({ theme }) => ({ flex: 1, paddingLeft: 2, // TODO: To match theming/global.ts for now - paddingRight: theme.layoutMargin, + paddingRight: 85, opacity: 1, fontFamily: theme.typography.fonts.mono, })); From c81c117d54ea2c48d7b1edde442c7ccd0b9b79a7 Mon Sep 17 00:00:00 2001 From: Maelryn <134317754+Maelryn@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:28:56 +0100 Subject: [PATCH 06/81] Replace hardcoded padding with ACTION_BAR_CLEARANCE --- .../components/syntaxhighlighter/syntaxhighlighter.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx index d3127f921a64..25aa78ff38b2 100644 --- a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx +++ b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx @@ -113,6 +113,9 @@ const Pre = styled.pre(({ theme, padded }) => ({ padding: padded ? theme.layoutMargin : 0, })); +// Clearance for absolutely-positioned Copy button +const ACTION_BAR_CLEARANCE = 85; + /* We can't use `code` since PrismJS races for it. See https://github.com/storybookjs/storybook/issues/18090 @@ -120,7 +123,7 @@ See https://github.com/storybookjs/storybook/issues/18090 const Code = styled.div(({ theme }) => ({ flex: 1, paddingLeft: 2, // TODO: To match theming/global.ts for now - paddingRight: 85, + paddingRight: ACTION_BAR_CLEARANCE, opacity: 1, fontFamily: theme.typography.fonts.mono, })); From ef5a1d67dda0b73793d0de5decfde823aeb6ffba Mon Sep 17 00:00:00 2001 From: Maelryn <134317754+Maelryn@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:53:57 +0100 Subject: [PATCH 07/81] Update stories layout --- .../components/components/ToggleButton/ToggleButton.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/code/core/src/components/components/ToggleButton/ToggleButton.stories.tsx b/code/core/src/components/components/ToggleButton/ToggleButton.stories.tsx index c1245b232745..38d821df485d 100644 --- a/code/core/src/components/components/ToggleButton/ToggleButton.stories.tsx +++ b/code/core/src/components/components/ToggleButton/ToggleButton.stories.tsx @@ -10,6 +10,7 @@ const meta = { title: 'ToggleButton', component: ToggleButton, tags: ['autodocs'], + parameters: { layout: 'fullscreen' }, args: { ariaLabel: false, children: 'Click me' }, } satisfies Meta; From be7e87259194005923a64e3864b5ef088f0d6475 Mon Sep 17 00:00:00 2001 From: Maelryn <134317754+Maelryn@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:55:25 +0100 Subject: [PATCH 08/81] Refactor: use flexbox layout per maintainer feedback --- .../syntaxhighlighter/syntaxhighlighter.tsx | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx index 25aa78ff38b2..d04c93e7676d 100644 --- a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx +++ b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx @@ -69,6 +69,9 @@ const Wrapper = styled.div( ({ theme }) => ({ position: 'relative', overflow: 'hidden', + display: 'flex', + flexWrap: 'wrap', + gap: theme.layoutMargin, color: theme.color.defaultText, }), ({ theme, bordered }) => @@ -82,7 +85,6 @@ const Wrapper = styled.div( ({ showLineNumbers }) => showLineNumbers ? { - // use the before pseudo element to display line numbers '.react-syntax-highlighter-line-number::before': { content: 'attr(data-line-number)', }, @@ -98,6 +100,13 @@ const UnstyledScroller = ({ children, className }: ScrollAreaProps) => ( const Scroller = styled(UnstyledScroller)( { position: 'relative', + width: 'fit-content', + '> div': { + width: 'fit-content', + '> div > pre': { + width: 'fit-content', + }, + }, }, ({ theme }) => themedSyntax(theme) ); @@ -113,34 +122,29 @@ const Pre = styled.pre(({ theme, padded }) => ({ padding: padded ? theme.layoutMargin : 0, })); -// Clearance for absolutely-positioned Copy button -const ACTION_BAR_CLEARANCE = 85; - -/* -We can't use `code` since PrismJS races for it. -See https://github.com/storybookjs/storybook/issues/18090 - */ const Code = styled.div(({ theme }) => ({ flex: 1, - paddingLeft: 2, // TODO: To match theming/global.ts for now - paddingRight: ACTION_BAR_CLEARANCE, + paddingLeft: 2, opacity: 1, fontFamily: theme.typography.fonts.mono, })); +const RelativeActionBar = styled(ActionBar)({ + position: 'relative', + marginLeft: 'auto', + alignSelf: 'flex-end', +}); + const processLineNumber = (row: any) => { const children = [...row.children]; const lineNumberNode = children[0]; const lineNumber = lineNumberNode.children[0].value; const processedLineNumberNode = { ...lineNumberNode, - // empty the line-number element children: [], properties: { ...lineNumberNode.properties, - // add a data-line-number attribute to line-number element, so we can access the line number with `content: attr(data-line-number)` 'data-line-number': lineNumber, - // remove the 'userSelect: none' style, which will produce extra empty lines when copy-pasting in firefox style: { ...lineNumberNode.properties.style, userSelect: 'auto' }, }, }; @@ -148,10 +152,6 @@ const processLineNumber = (row: any) => { return { ...row, children }; }; -/** - * A custom renderer for handling `span.linenumber` element in each line of code, which is enabled - * by default if no renderer is passed in from the parent component - */ const defaultRenderer: SyntaxHighlighterRenderer = ({ rows, stylesheet, useInlineStyles }) => { return rows.map((node: any, i: number) => { return createElement({ @@ -181,8 +181,6 @@ export interface SyntaxHighlighterState { copied: boolean; } -// copied from @types/react-syntax-highlighter/index.d.ts - export const SyntaxHighlighter = ({ children, language = 'jsx', @@ -250,7 +248,7 @@ export const SyntaxHighlighter = ({ {copyable ? ( - + ) : null} ); From 5ee9e53f2d28384e685fc0d3c075998e91486aeb Mon Sep 17 00:00:00 2001 From: Maelryn <134317754+Maelryn@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:10:47 +0100 Subject: [PATCH 09/81] Restore line number logic accidentally removed From fd27cf23296500c61f792b98c7b1aa99f8ab9966 Mon Sep 17 00:00:00 2001 From: Maelryn <134317754+Maelryn@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:22:09 +0100 Subject: [PATCH 10/81] Fix: Apply flex layout to Preview component --- .../docs/src/blocks/components/Preview.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index b43dd30519a3..1140fc821f3a 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -38,6 +38,7 @@ const ChildrenContainer = styled.div( flexWrap: 'wrap', overflow: 'auto', flexDirection: isColumn ? 'column' : 'row', + width: 'fit-content', '& .innerZoomElementWrapper > *': isColumn ? { @@ -172,15 +173,20 @@ const PositionedToolbar = styled(Toolbar)({ height: 40, }); -const Relative = styled.div({ +const Relative = styled.div(({ theme }) => ({ overflow: 'hidden', position: 'relative', + display: 'flex', + flexWrap: 'wrap', + gap: theme.layoutMargin, +})); + +const RelativeActionBar = styled(ActionBar)({ + position: 'relative', + marginLeft: 'auto', + alignSelf: 'flex-end', }); -/** - * A preview component for showing one or more component `Story` items. The preview also shows the - * source for the component as a drop-down. - */ export const Preview: FC = ({ isLoading, isColumn, @@ -279,7 +285,7 @@ export const Preview: FC = ({ )} - + {withSource && expanded && source} From d5ab7e7eed1289b35467c62d9e023b285f00ed52 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 15 Jan 2026 22:02:54 +0100 Subject: [PATCH 11/81] migrate to channel based triggering --- code/addons/vitest/build-config.ts | 4 ++ code/addons/vitest/package.json | 4 ++ .../src/components/SidebarContextMenu.tsx | 50 ++++++++++++---- code/addons/vitest/src/constants.ts | 24 +++++++- code/addons/vitest/src/index.ts | 60 +------------------ code/addons/vitest/src/preset.ts | 55 ++++++++++++++++- code/addons/vitest/src/types.ts | 54 +++++++++-------- code/core/src/core-server/build-dev.ts | 8 ++- code/core/src/core-server/dev-server.ts | 22 ++++--- code/core/src/core-server/utils/index-json.ts | 6 +- code/core/src/types/modules/core-common.ts | 8 +-- 11 files changed, 176 insertions(+), 119 deletions(-) diff --git a/code/addons/vitest/build-config.ts b/code/addons/vitest/build-config.ts index 989ec342a74d..276d1c8d01a6 100644 --- a/code/addons/vitest/build-config.ts +++ b/code/addons/vitest/build-config.ts @@ -28,6 +28,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 db6a22e97516..c2b33615370a 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -42,6 +42,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./constants": { + "types": "./dist/constants.d.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", diff --git a/code/addons/vitest/src/components/SidebarContextMenu.tsx b/code/addons/vitest/src/components/SidebarContextMenu.tsx index c8fe04772012..927e9b7f976f 100644 --- a/code/addons/vitest/src/components/SidebarContextMenu.tsx +++ b/code/addons/vitest/src/components/SidebarContextMenu.tsx @@ -3,8 +3,11 @@ import React from 'react'; import type { API_HashEntry } from 'storybook/internal/types'; +import { addons } from 'storybook/manager-api'; import { type API } from 'storybook/manager-api'; +import { TRIGGER_TEST_RUN_REQUEST, TRIGGER_TEST_RUN_RESPONSE } from '../constants'; +import type { TriggerTestRunResponsePayload } from '../constants'; import { useTestProvider } from '../use-test-provider-state'; import { TestProviderRender } from './TestProviderRender'; @@ -22,17 +25,42 @@ export const SidebarContextMenu: FC = ({ context, api } setStoreState, } = useTestProvider(api, context.id); + const handleTestTrigger = () => { + const channel = addons.getChannel(); + const requestId = `test-${Date.now()}`; + + const handleResponse = (payload: TriggerTestRunResponsePayload) => { + if (payload.requestId === requestId) { + channel.off(TRIGGER_TEST_RUN_RESPONSE, handleResponse); + console.log('Test run response:', payload); + alert(`Test run ${payload.status}!`); + } + }; + + channel.on(TRIGGER_TEST_RUN_RESPONSE, handleResponse); + channel.emit(TRIGGER_TEST_RUN_REQUEST, { + requestId, + actor: 'sidebar-test-button', + storyIds: context.type === 'story' ? [context.id] : undefined, + }); + }; + return ( - + <> + + + ); }; diff --git a/code/addons/vitest/src/constants.ts b/code/addons/vitest/src/constants.ts index ab6631b17294..3a5afaa135b6 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 { @@ -66,3 +66,25 @@ 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'; export const STATUS_TYPE_ID_SCREENSHOT = 'storybook/screenshot'; + +// 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[]; +}; + +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/index.ts b/code/addons/vitest/src/index.ts index 65113dde3b30..ed9c6eedc4b4 100644 --- a/code/addons/vitest/src/index.ts +++ b/code/addons/vitest/src/index.ts @@ -1,61 +1,3 @@ -import { experimental_getStatusStore } from 'storybook/internal/core-server'; -import { type StoryId, definePreviewAddon } from 'storybook/internal/csf'; - -import { STATUS_TYPE_ID_COMPONENT_TEST } from './constants'; -import type { Store } from './types'; +import { definePreviewAddon } from 'storybook/internal/csf'; export default () => definePreviewAddon({}); - -export async function triggerTestRun(actor: string, storyIds?: StoryId[]) { - // TODO: there must be a smarter way to share the store here - // while still lazy initializing it in the experimental_serverChannel preset - const store: Store | undefined = (globalThis as any).__STORYBOOK_ADDON_VITEST_STORE__; - if (!store) { - throw new Error('store not ready yet'); - } - - await store.untilReady(); - - const { - currentRun: { startedAt, finishedAt }, - } = store.getState(); - if (startedAt && !finishedAt) { - throw new Error('tests are already running'); - } - - store.send({ - type: 'TRIGGER_RUN', - payload: { - storyIds, - triggeredBy: `external:${actor}`, - }, - }); - - return new Promise((resolve, reject) => { - const unsubscribe = store.subscribe((event) => { - switch (event.type) { - case 'TEST_RUN_COMPLETED': { - console.log('Completed!'); - console.dir(event.payload, { depth: 5 }); - unsubscribe(); - resolve(event.payload); - return; - } - case 'FATAL_ERROR': { - console.log('ERROR!'); - console.dir(event.payload, { depth: 5 }); - unsubscribe(); - reject(event.payload); - return; - } - case 'CANCEL_RUN': { - console.log('CANCEL!'); - console.dir(event, { depth: 5 }); - unsubscribe(); - reject('cancelled'); - return; - } - } - }); - }); -} diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index 6e8825fc85a6..121c77301e80 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -29,6 +29,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'; @@ -103,7 +107,6 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti fsCache.set('state', selectCachedState(state)); } }); - globalThis.__STORYBOOK_ADDON_VITEST_STORE__ = store; const testProviderStore = experimental_getTestProviderStore(ADDON_ID); store.subscribe('TRIGGER_RUN', (event, eventInfo) => { @@ -185,6 +188,56 @@ 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 } = payload; + + const sendResponse = (response: Omit) => { + channel.emit(TRIGGER_TEST_RUN_RESPONSE, { requestId, ...response }); + }; + + await store.untilReady(); + + const { + currentRun: { startedAt, finishedAt }, + } = 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}`, + }, + }); + + 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 8eea529ca0b9..a2b587c28005 100644 --- a/code/addons/vitest/src/types.ts +++ b/code/addons/vitest/src/types.ts @@ -27,6 +27,33 @@ export type RunTrigger = | 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; + }; + 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: { coverage: boolean; @@ -44,32 +71,7 @@ export type StoreState = { error: ErrorLike; } | undefined; - currentRun: { - triggeredBy: RunTrigger | undefined; - config: StoreState['config']; - componentTestStatuses: Status[]; - a11yStatuses: Status[]; - 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; diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 1d605a5dc1e0..ef0e0129273e 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -28,9 +28,11 @@ import { resolvePackageDir } from '../shared/utils/module'; import { storybookDevServer } from './dev-server'; 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 { updateCheck } from './utils/update-check'; import { warnOnIncompatibleAddons } from './utils/warnOnIncompatibleAddons'; import { warnWhenUsingArgTypesRegex } from './utils/warnWhenUsingArgTypesRegex'; @@ -211,15 +213,19 @@ export async function buildDevStandalone( const features = await presets.apply('features'); global.FEATURES = features; + const server = await getServer(options); + const channel = getServerChannel(server); + 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/dev-server.ts b/code/core/src/core-server/dev-server.ts index da9cf0cb6042..d0d0b0236d31 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -12,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'; @@ -20,18 +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 }); +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) - ); + const app = polka({ server }); const workingDir = process.cwd(); const configDir = options.configDir; @@ -50,7 +48,7 @@ export async function storybookDevServer(options: Options) { app, storyIndexGeneratorPromise, normalizedStories, - serverChannel, + channel: options.channel, workingDir, configDir, }); @@ -107,7 +105,7 @@ export async function storybookDevServer(options: Options) { options, router: app, server, - channel: serverChannel, + channel: options.channel, }); let previewResult: Awaited> = @@ -121,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/utils/index-json.ts b/code/core/src/core-server/utils/index-json.ts index 08e1b5047957..60e7b9fdb2a3 100644 --- a/code/core/src/core-server/utils/index-json.ts +++ b/code/core/src/core-server/utils/index-json.ts @@ -28,17 +28,17 @@ export function registerIndexJsonRoute({ storyIndexGeneratorPromise, workingDir = process.cwd(), configDir, - serverChannel, + channel, normalizedStories, }: { app: Polka; storyIndexGeneratorPromise: Promise; - serverChannel: ServerChannel; + channel: ServerChannel; 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/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index d7422c13469f..2347389e93e5 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 { Channel } 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: Channel; } export type Options = LoadOptions & @@ -259,7 +257,7 @@ export interface Builder { startTime: ReturnType; router: ServerApp; server: HttpServer; - channel: ServerChannel; + channel: Channel; }) => Promise; From 6acb68dad935204d9dccb03ecda7640227b44816 Mon Sep 17 00:00:00 2001 From: Maelryn <134317754+Maelryn@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:37:24 +0100 Subject: [PATCH 12/81] Fix: Add maxWidth to fix horizontal scrolling and restore comments --- .../components/syntaxhighlighter/syntaxhighlighter.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx index d04c93e7676d..54b709d48c20 100644 --- a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx +++ b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx @@ -101,10 +101,13 @@ const Scroller = styled(UnstyledScroller)( { position: 'relative', width: 'fit-content', + maxWidth: '100%', '> div': { width: 'fit-content', + maxWidth: '100%', '> div > pre': { width: 'fit-content', + maxWidth: '100%', }, }, }, @@ -142,9 +145,11 @@ const processLineNumber = (row: any) => { const processedLineNumberNode = { ...lineNumberNode, children: [], + // empty the line-number element properties: { ...lineNumberNode.properties, 'data-line-number': lineNumber, + // remove the userSelect: none style, which will produce extra empty lines when copy-pasting in firefox style: { ...lineNumberNode.properties.style, userSelect: 'auto' }, }, }; From c754caa6d5124caf2a11ffe249df7cb3538383e3 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 19 Jan 2026 14:09:49 +0100 Subject: [PATCH 13/81] Restore removed JS comments --- .../addons/docs/src/blocks/components/Preview.tsx | 8 ++++++++ .../syntaxhighlighter/syntaxhighlighter.tsx | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index 9783613b27b3..24b1c5293e63 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -187,6 +187,14 @@ const RelativeActionBar = styled(ActionBar)({ alignSelf: 'flex-end', }); +/** + * A preview component for showing one or more component `Story` items. The preview also shows the + * source for the component as a drop-down. + */ +/** + * A preview component for showing one or more component `Story` items. The preview also shows the + * source for the component as a drop-down. + */ export const Preview: FC = ({ isLoading, isColumn, diff --git a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx index 54b709d48c20..1422db9743d2 100644 --- a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx +++ b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx @@ -85,6 +85,7 @@ const Wrapper = styled.div( ({ showLineNumbers }) => showLineNumbers ? { + // use the before pseudo element to display line numbers '.react-syntax-highlighter-line-number::before': { content: 'attr(data-line-number)', }, @@ -125,9 +126,13 @@ const Pre = styled.pre(({ theme, padded }) => ({ padding: padded ? theme.layoutMargin : 0, })); +/* +We can't use `code` since PrismJS races for it. +See https://github.com/storybookjs/storybook/issues/18090 + */ const Code = styled.div(({ theme }) => ({ flex: 1, - paddingLeft: 2, + paddingLeft: 2, // TODO: To match theming/global.ts for now opacity: 1, fontFamily: theme.typography.fonts.mono, })); @@ -144,10 +149,12 @@ const processLineNumber = (row: any) => { const lineNumber = lineNumberNode.children[0].value; const processedLineNumberNode = { ...lineNumberNode, + // empty the line-number element children: [], // empty the line-number element properties: { ...lineNumberNode.properties, + // add a data-line-number attribute to line-number element, so we can access the line number with `content: attr(data-line-number)` 'data-line-number': lineNumber, // remove the userSelect: none style, which will produce extra empty lines when copy-pasting in firefox style: { ...lineNumberNode.properties.style, userSelect: 'auto' }, @@ -157,6 +164,10 @@ const processLineNumber = (row: any) => { return { ...row, children }; }; +/** + * A custom renderer for handling `span.linenumber` element in each line of code, which is enabled + * by default if no renderer is passed in from the parent component + */ const defaultRenderer: SyntaxHighlighterRenderer = ({ rows, stylesheet, useInlineStyles }) => { return rows.map((node: any, i: number) => { return createElement({ @@ -186,6 +197,8 @@ export interface SyntaxHighlighterState { copied: boolean; } +// copied from @types/react-syntax-highlighter/index.d.ts + export const SyntaxHighlighter = ({ children, language = 'jsx', From 70f50d64216eb4590d7c5345c211e2df685e19c9 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 19 Jan 2026 14:11:23 +0100 Subject: [PATCH 14/81] Restore more JS comments --- .../components/syntaxhighlighter/syntaxhighlighter.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx index 1422db9743d2..531e78a63e09 100644 --- a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx +++ b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx @@ -155,6 +155,7 @@ const processLineNumber = (row: any) => { properties: { ...lineNumberNode.properties, // add a data-line-number attribute to line-number element, so we can access the line number with `content: attr(data-line-number)` + // add a data-line-number attribute to line-number element, so we can access the line number with `content: attr(data-line-number)` 'data-line-number': lineNumber, // remove the userSelect: none style, which will produce extra empty lines when copy-pasting in firefox style: { ...lineNumberNode.properties.style, userSelect: 'auto' }, From 8d3c5f98cf5a9e467a22a2e2cbee50c64e1d890d Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 19 Jan 2026 14:13:13 +0100 Subject: [PATCH 15/81] Restore existing stories --- .../components/components/ToggleButton/ToggleButton.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/code/core/src/components/components/ToggleButton/ToggleButton.stories.tsx b/code/core/src/components/components/ToggleButton/ToggleButton.stories.tsx index 38d821df485d..c1245b232745 100644 --- a/code/core/src/components/components/ToggleButton/ToggleButton.stories.tsx +++ b/code/core/src/components/components/ToggleButton/ToggleButton.stories.tsx @@ -10,7 +10,6 @@ const meta = { title: 'ToggleButton', component: ToggleButton, tags: ['autodocs'], - parameters: { layout: 'fullscreen' }, args: { ariaLabel: false, children: 'Click me' }, } satisfies Meta; From ac20f33b33e772d4d6311492d7fd41cf8737f389 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 19 Jan 2026 14:15:23 +0100 Subject: [PATCH 16/81] More JSDoc fixes --- .../components/syntaxhighlighter/syntaxhighlighter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx index 531e78a63e09..2c61927c6469 100644 --- a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx +++ b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx @@ -157,7 +157,7 @@ const processLineNumber = (row: any) => { // add a data-line-number attribute to line-number element, so we can access the line number with `content: attr(data-line-number)` // add a data-line-number attribute to line-number element, so we can access the line number with `content: attr(data-line-number)` 'data-line-number': lineNumber, - // remove the userSelect: none style, which will produce extra empty lines when copy-pasting in firefox + // remove the 'userSelect: none' style, which will produce extra empty lines when copy-pasting in firefox style: { ...lineNumberNode.properties.style, userSelect: 'auto' }, }, }; From 9b524332117d8f1e379c73a5483af28acbb4ad5f Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 19 Jan 2026 14:17:23 +0100 Subject: [PATCH 17/81] Apply suggestion from @Sidnioulz --- code/addons/docs/src/blocks/components/Preview.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index 24b1c5293e63..12246c013207 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -187,10 +187,6 @@ const RelativeActionBar = styled(ActionBar)({ alignSelf: 'flex-end', }); -/** - * A preview component for showing one or more component `Story` items. The preview also shows the - * source for the component as a drop-down. - */ /** * A preview component for showing one or more component `Story` items. The preview also shows the * source for the component as a drop-down. From 5a933816454fa3427ebb4f5fb347d400da9eae4c Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 19 Jan 2026 14:17:40 +0100 Subject: [PATCH 18/81] Apply suggestion from @Sidnioulz --- .../components/syntaxhighlighter/syntaxhighlighter.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx index 2c61927c6469..9a421228d85e 100644 --- a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx +++ b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx @@ -151,7 +151,6 @@ const processLineNumber = (row: any) => { ...lineNumberNode, // empty the line-number element children: [], - // empty the line-number element properties: { ...lineNumberNode.properties, // add a data-line-number attribute to line-number element, so we can access the line number with `content: attr(data-line-number)` From 20134bf23d36105a318e81b78d34299cece4572d Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 19 Jan 2026 14:17:49 +0100 Subject: [PATCH 19/81] Apply suggestion from @Sidnioulz --- .../components/syntaxhighlighter/syntaxhighlighter.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx index 9a421228d85e..209fb0e75b59 100644 --- a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx +++ b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx @@ -154,7 +154,6 @@ const processLineNumber = (row: any) => { properties: { ...lineNumberNode.properties, // add a data-line-number attribute to line-number element, so we can access the line number with `content: attr(data-line-number)` - // add a data-line-number attribute to line-number element, so we can access the line number with `content: attr(data-line-number)` 'data-line-number': lineNumber, // remove the 'userSelect: none' style, which will produce extra empty lines when copy-pasting in firefox style: { ...lineNumberNode.properties.style, userSelect: 'auto' }, From 23da885d6afd13b1efa4e6b076e2d65af788d52a Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Mon, 19 Jan 2026 21:19:36 +0100 Subject: [PATCH 20/81] expose a11yReports on CurrentRun --- .../a11y/src/components/A11yContext.tsx | 4 +- code/addons/a11y/src/index.ts | 2 +- code/addons/a11y/src/types.ts | 2 +- code/addons/vitest/package.json | 1 + code/addons/vitest/src/constants.ts | 1 + code/addons/vitest/src/node/test-manager.ts | 49 +++++++++++++------ code/addons/vitest/src/types.ts | 5 ++ 7 files changed, 45 insertions(+), 19 deletions(-) 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/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/vitest/package.json b/code/addons/vitest/package.json index c2b33615370a..1d88bc2fa3f8 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -80,6 +80,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 3a5afaa135b6..410a4f569ed2 100644 --- a/code/addons/vitest/src/constants.ts +++ b/code/addons/vitest/src/constants.ts @@ -38,6 +38,7 @@ export const storeOptions = { }, componentTestStatuses: [], a11yStatuses: [], + a11yReports: {}, componentTestCount: { success: 0, error: 0, diff --git a/code/addons/vitest/src/node/test-manager.ts b/code/addons/vitest/src/node/test-manager.ts index 1090e2d853fe..bb25b46f0b68 100644 --- a/code/addons/vitest/src/node/test-manager.ts +++ b/code/addons/vitest/src/node/test-manager.ts @@ -8,6 +8,8 @@ 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'; @@ -17,7 +19,14 @@ import { STATUS_TYPE_ID_SCREENSHOT, 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'; @@ -207,20 +216,26 @@ export class TestManager { 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); + 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); @@ -257,6 +272,10 @@ export class TestManager { 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 diff --git a/code/addons/vitest/src/types.ts b/code/addons/vitest/src/types.ts index a2b587c28005..2b86d0a2a09d 100644 --- a/code/addons/vitest/src/types.ts +++ b/code/addons/vitest/src/types.ts @@ -2,6 +2,10 @@ import type { experimental_UniversalStore } from 'storybook/internal/core-server import type { PreviewAnnotation, Status, StoryId } 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; @@ -41,6 +45,7 @@ export type CurrentRun = { warning: number; error: number; }; + a11yReports: Record; totalTestCount: number | undefined; storyIds: StoryId[] | undefined; startedAt: number | undefined; From 9d204e3e093b3905ecb41149d9b99b68803cd52f Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Mon, 19 Jan 2026 21:19:45 +0100 Subject: [PATCH 21/81] add Channel to all preset options --- code/core/src/common/presets.ts | 2 ++ code/core/src/core-server/build-dev.ts | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/code/core/src/common/presets.ts b/code/core/src/common/presets.ts index cb340f7cb6b2..9303c67da3ef 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 { Channel } 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: Channel; } ) { const { corePresets = [], overridePresets = [], ...restOptions } = options; diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index ef0e0129273e..631bf626378b 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -143,6 +143,9 @@ export async function buildDevStandalone( await warnWhenUsingArgTypesRegex(previewConfigPath, config); } catch (e) {} + const server = await getServer(options); + const channel = getServerChannel(server); + // 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 @@ -153,6 +156,7 @@ export async function buildDevStandalone( ], ...options, isCritical: true, + channel, }); const { renderer, builder, disableTelemetry } = await presets.apply('core', {}); @@ -209,12 +213,11 @@ 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; - const server = await getServer(options); - const channel = getServerChannel(server); await presets.apply('experimental_serverChannel', channel); const fullOptions: Options = { From cb395c7df72742c7da2012bbfd08888b9a66cee9 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Mon, 19 Jan 2026 21:44:25 +0100 Subject: [PATCH 22/81] update lock file --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 9b83c0ecf2e2..870030be6310 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" From facb26aab0891f786c973e8170dd56efd893d40e Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Mon, 19 Jan 2026 22:03:17 +0100 Subject: [PATCH 23/81] fix no channel in build --- code/core/src/core-server/build-static.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 3375f84edcd8..a1dcdcb88e87 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -17,6 +17,7 @@ import { global } from '@storybook/global'; import { join, relative, resolve } from 'pathe'; import picocolors from 'picocolors'; +import type Channel from '../channels'; import { resolvePackageDir } from '../shared/utils/module'; import type { StoryIndexGenerator } from './utils/StoryIndexGenerator'; import { buildOrThrow } from './utils/build-or-throw'; @@ -68,16 +69,24 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption .resolve('storybook/internal/core-server/presets/common-override-preset'); logger.step('Loading presets'); + // TODO: what to actually do with the channel here? + const channel = null as unknown as 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, From ea030b0534487bcb22ef9f7162afad4c471ddd99 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 20 Jan 2026 11:16:15 +0100 Subject: [PATCH 24/81] fix missing channel on options everywhere --- code/addons/vitest/src/constants.ts | 1 - code/addons/vitest/src/node/test-manager.ts | 11 +---------- code/addons/vitest/src/node/vitest.ts | 2 -- code/builders/builder-vite/src/vite-config.test.ts | 2 ++ code/core/src/cli/buildIndex.ts | 2 ++ code/core/src/core-server/build-static.ts | 7 ++++--- code/core/src/core-server/load.ts | 6 ++++++ code/core/src/core-server/utils/index-json.test.ts | 10 +++++----- .../angular/src/builders/build-storybook/index.ts | 8 +++++++- .../angular/src/builders/start-storybook/index.ts | 8 +++++++- .../src/server/framework-preset-angular-cli.test.ts | 11 +++++++++++ 11 files changed, 45 insertions(+), 23 deletions(-) diff --git a/code/addons/vitest/src/constants.ts b/code/addons/vitest/src/constants.ts index 410a4f569ed2..2bdab8f912f8 100644 --- a/code/addons/vitest/src/constants.ts +++ b/code/addons/vitest/src/constants.ts @@ -66,7 +66,6 @@ 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'; -export const STATUS_TYPE_ID_SCREENSHOT = 'storybook/screenshot'; // Channel event names for programmatic test triggering export const TRIGGER_TEST_RUN_REQUEST = `${ADDON_ID}/trigger-test-run-request`; diff --git a/code/addons/vitest/src/node/test-manager.ts b/code/addons/vitest/src/node/test-manager.ts index bb25b46f0b68..d1f58dbf6e1a 100644 --- a/code/addons/vitest/src/node/test-manager.ts +++ b/code/addons/vitest/src/node/test-manager.ts @@ -13,12 +13,7 @@ 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, - STATUS_TYPE_ID_SCREENSHOT, - storeOptions, -} from '../constants'; +import { STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST, storeOptions } from '../constants'; import type { CurrentRun, RunTrigger, @@ -35,7 +30,6 @@ export type TestManagerOptions = { store: experimental_UniversalStore; componentTestStatusStore: StatusStoreByTypeId; a11yStatusStore: StatusStoreByTypeId; - screenshotStatusStore: StatusStoreByTypeId; testProviderStore: TestProviderStoreById; onError?: (message: string, error: Error) => void; onReady?: () => void; @@ -58,8 +52,6 @@ export class TestManager { private a11yStatusStore: TestManagerOptions['a11yStatusStore']; - private screenshotStatusStore: TestManagerOptions['screenshotStatusStore']; - private testProviderStore: TestManagerOptions['testProviderStore']; private onReady?: TestManagerOptions['onReady']; @@ -76,7 +68,6 @@ export class TestManager { this.store = options.store; this.componentTestStatusStore = options.componentTestStatusStore; this.a11yStatusStore = options.a11yStatusStore; - this.screenshotStatusStore = options.screenshotStatusStore; this.testProviderStore = options.testProviderStore; this.onReady = options.onReady; this.storybookOptions = options.storybookOptions; diff --git a/code/addons/vitest/src/node/vitest.ts b/code/addons/vitest/src/node/vitest.ts index cf8470cde4ee..92d5871ff485 100644 --- a/code/addons/vitest/src/node/vitest.ts +++ b/code/addons/vitest/src/node/vitest.ts @@ -11,7 +11,6 @@ import { ADDON_ID, STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST, - STATUS_TYPE_ID_SCREENSHOT, storeOptions, } from '../constants'; import type { ErrorLike, FatalErrorEvent, StoreEvent, StoreState } from '../types'; @@ -42,7 +41,6 @@ new TestManager({ store, componentTestStatusStore: getStatusStore(STATUS_TYPE_ID_COMPONENT_TEST), a11yStatusStore: getStatusStore(STATUS_TYPE_ID_A11Y), - screenshotStatusStore: getStatusStore(STATUS_TYPE_ID_SCREENSHOT), testProviderStore: getTestProviderStore(ADDON_ID), onReady: () => { process.send?.({ type: 'ready' }); diff --git a/code/builders/builder-vite/src/vite-config.test.ts b/code/builders/builder-vite/src/vite-config.test.ts index 3097c069431a..e1e3fd57825d 100644 --- a/code/builders/builder-vite/src/vite-config.test.ts +++ b/code/builders/builder-vite/src/vite-config.test.ts @@ -1,5 +1,6 @@ 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'; @@ -17,6 +18,7 @@ const dummyOptions: Options = { configType: 'DEVELOPMENT', configDir: '', packageJson: {}, + channel: new Channel({}), presets: { apply: async (key: string) => ({ diff --git a/code/core/src/cli/buildIndex.ts b/code/core/src/cli/buildIndex.ts index 890cf3b56203..bc7dbf7722fe 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({}), }; await withTelemetry('index', { cliOptions, presetOptions }, () => buildIndexStandalone(options)); }; diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index a1dcdcb88e87..951423cfca37 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -17,7 +17,7 @@ import { global } from '@storybook/global'; import { join, relative, resolve } from 'pathe'; import picocolors from 'picocolors'; -import type Channel from '../channels'; +import Channel from '../channels'; import { resolvePackageDir } from '../shared/utils/module'; import type { StoryIndexGenerator } from './utils/StoryIndexGenerator'; import { buildOrThrow } from './utils/build-or-throw'; @@ -69,8 +69,9 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption .resolve('storybook/internal/core-server/presets/common-override-preset'); logger.step('Loading presets'); - // TODO: what to actually do with the channel here? - const channel = null as unknown as Channel; + + // no-op channel, as it's only relevant in dev mode + const channel = new Channel({}); let presets = await loadAllPresets({ corePresets: [commonPreset, ...corePresets], overridePresets: [commonOverridePreset], diff --git a/code/core/src/core-server/load.ts b/code/core/src/core-server/load.ts index 8ac8ca7d19f3..b5a4c2361e1c 100644 --- a/code/core/src/core-server/load.ts +++ b/code/core/src/core-server/load.ts @@ -12,6 +12,7 @@ import { global } from '@storybook/global'; import { dirname, join, relative, resolve } from 'pathe'; +import { Channel } from '../channels'; import { resolvePackageDir } from '../shared/utils/module'; export async function loadStorybook( @@ -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/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/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); From 8488a5972baa76f73c4aa320c96a3b1f2c00494e Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 20 Jan 2026 13:52:57 +0100 Subject: [PATCH 25/81] don't fetch index in vitest, just get it server-side --- code/addons/vitest/src/constants.ts | 2 +- code/addons/vitest/src/manager.tsx | 10 ++----- code/addons/vitest/src/node/vitest-manager.ts | 28 ++++++------------- code/addons/vitest/src/preset.ts | 12 +++++++- code/addons/vitest/src/types.ts | 6 ++-- 5 files changed, 24 insertions(+), 34 deletions(-) diff --git a/code/addons/vitest/src/constants.ts b/code/addons/vitest/src/constants.ts index 2bdab8f912f8..5e6f3b124f62 100644 --- a/code/addons/vitest/src/constants.ts +++ b/code/addons/vitest/src/constants.ts @@ -28,7 +28,7 @@ export const storeOptions = { watching: false, cancelling: false, fatalError: undefined, - indexUrl: undefined, + index: { entries: {}, v: 5 }, previewAnnotations: [], currentRun: { triggeredBy: undefined, 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/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts index 8cf3e0f15e7e..5b0fc3009ba0 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -10,7 +10,7 @@ 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 path, { dirname, join, normalize } from 'pathe'; @@ -183,24 +183,12 @@ 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.' - ); - } - 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 []; + private getStories(requestStoryIds?: string[]): StoryIndexEntry[] { + const index = this.testManager.store.getState().index; + if (requestStoryIds) { + return requestStoryIds.map((id) => index.entries[id]) as StoryIndexEntry[]; } + return Object.values(index.entries).filter((entry) => entry.type === 'story'); } private filterTestSpecifications( @@ -279,7 +267,7 @@ 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)) @@ -394,7 +382,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 121c77301e80..6fa5dd8e9a16 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'; @@ -8,7 +7,9 @@ import { loadPreviewOrConfigFile, resolvePathInStorybookCache, } from 'storybook/internal/common'; +import { STORY_INDEX_INVALIDATED } from 'storybook/internal/core-events'; import { + type StoryIndexGenerator, experimental_UniversalStore, experimental_getTestProviderStore, } from 'storybook/internal/core-server'; @@ -81,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', @@ -98,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, @@ -109,6 +114,11 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti }); const testProviderStore = experimental_getTestProviderStore(ADDON_ID); + channel.on(STORY_INDEX_INVALIDATED, async () => { + const index = await storyIndexGenerator.getIndex(); + store.setState((s) => ({ ...s, index })); + }); + store.subscribe('TRIGGER_RUN', (event, eventInfo) => { testProviderStore.setState('test-provider-state:running'); store.setState((s) => ({ diff --git a/code/addons/vitest/src/types.ts b/code/addons/vitest/src/types.ts index 2b86d0a2a09d..15a5d0281a5c 100644 --- a/code/addons/vitest/src/types.ts +++ b/code/addons/vitest/src/types.ts @@ -1,5 +1,5 @@ import type { experimental_UniversalStore } from 'storybook/internal/core-server'; -import type { PreviewAnnotation, Status, 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'; @@ -66,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: | { From 4f9641788784275ae2186868a2e5bb96e213ec1b Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 5 Feb 2026 20:35:32 +0100 Subject: [PATCH 26/81] cleanup, don't crash on getIndex failing --- code/addons/vitest/package.json | 2 + .../src/components/SidebarContextMenu.tsx | 50 ++++--------------- code/addons/vitest/src/preset.ts | 16 ++++-- 3 files changed, 25 insertions(+), 43 deletions(-) diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json index ad96ae1530a5..c8be26ac530d 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -45,6 +45,7 @@ }, "./constants": { "types": "./dist/constants.d.ts", + "code": "./src/constants.ts", "default": "./dist/constants.js" }, "./internal/coverage-reporter": "./dist/node/coverage-reporter.js", @@ -57,6 +58,7 @@ "./preset": "./dist/preset.js", "./preview": { "types": "./dist/preview.d.ts", + "code": "./src/preview.ts", "default": "./dist/preview.js" }, "./vitest": "./dist/node/vitest.js", diff --git a/code/addons/vitest/src/components/SidebarContextMenu.tsx b/code/addons/vitest/src/components/SidebarContextMenu.tsx index 927e9b7f976f..c8fe04772012 100644 --- a/code/addons/vitest/src/components/SidebarContextMenu.tsx +++ b/code/addons/vitest/src/components/SidebarContextMenu.tsx @@ -3,11 +3,8 @@ import React from 'react'; import type { API_HashEntry } from 'storybook/internal/types'; -import { addons } from 'storybook/manager-api'; import { type API } from 'storybook/manager-api'; -import { TRIGGER_TEST_RUN_REQUEST, TRIGGER_TEST_RUN_RESPONSE } from '../constants'; -import type { TriggerTestRunResponsePayload } from '../constants'; import { useTestProvider } from '../use-test-provider-state'; import { TestProviderRender } from './TestProviderRender'; @@ -25,42 +22,17 @@ export const SidebarContextMenu: FC = ({ context, api } setStoreState, } = useTestProvider(api, context.id); - const handleTestTrigger = () => { - const channel = addons.getChannel(); - const requestId = `test-${Date.now()}`; - - const handleResponse = (payload: TriggerTestRunResponsePayload) => { - if (payload.requestId === requestId) { - channel.off(TRIGGER_TEST_RUN_RESPONSE, handleResponse); - console.log('Test run response:', payload); - alert(`Test run ${payload.status}!`); - } - }; - - channel.on(TRIGGER_TEST_RUN_RESPONSE, handleResponse); - channel.emit(TRIGGER_TEST_RUN_REQUEST, { - requestId, - actor: 'sidebar-test-button', - storyIds: context.type === 'story' ? [context.id] : undefined, - }); - }; - return ( - <> - - - + ); }; diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index 6fa5dd8e9a16..b0f23643fbf4 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -7,12 +7,12 @@ import { loadPreviewOrConfigFile, resolvePathInStorybookCache, } from 'storybook/internal/common'; -import { STORY_INDEX_INVALIDATED } from 'storybook/internal/core-events'; 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, @@ -25,6 +25,7 @@ import { isEqual } from 'es-toolkit/predicate'; import picocolors from 'picocolors'; import { dedent } from 'ts-dedent'; +import { shouldLog } from '../../../core/src/node-logger/logger'; import { ADDON_ID, COVERAGE_DIRECTORY, @@ -114,9 +115,16 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti }); const testProviderStore = experimental_getTestProviderStore(ADDON_ID); - channel.on(STORY_INDEX_INVALIDATED, async () => { - const index = await storyIndexGenerator.getIndex(); - store.setState((s) => ({ ...s, index })); + 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'); + if (shouldLog('debug')) { + logger.error(error); + } + } }); store.subscribe('TRIGGER_RUN', (event, eventInfo) => { From c172f78b9c253e739d949ad9e1890a0bef22433a Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 6 Feb 2026 11:13:43 +0100 Subject: [PATCH 27/81] add isAddonA11yEnabled preset property to addon-a11y --- code/addons/a11y/build-config.ts | 5 +++++ code/addons/a11y/package.json | 1 + code/addons/a11y/preset.js | 1 + code/addons/a11y/src/preset.ts | 3 +++ 4 files changed, 10 insertions(+) create mode 100644 code/addons/a11y/preset.js create mode 100644 code/addons/a11y/src/preset.ts 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 903f836be99a..feb2a177526a 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/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; From 06900f2a11acb34abb46f9cd49642306045e4acd Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 6 Feb 2026 13:52:45 +0100 Subject: [PATCH 28/81] support optional config overrides in TRIGGER_TEST_RUN flow --- code/addons/vitest/src/constants.ts | 1 + code/addons/vitest/src/node/test-manager.ts | 9 +++++++-- code/addons/vitest/src/preset.ts | 6 +++++- code/addons/vitest/src/types.ts | 1 + 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/code/addons/vitest/src/constants.ts b/code/addons/vitest/src/constants.ts index 5e6f3b124f62..e49ef528030e 100644 --- a/code/addons/vitest/src/constants.ts +++ b/code/addons/vitest/src/constants.ts @@ -75,6 +75,7 @@ export type TriggerTestRunRequestPayload = { requestId: string; actor: string; storyIds?: string[]; + config?: Partial; }; export type TestRunResult = CurrentRun; diff --git a/code/addons/vitest/src/node/test-manager.ts b/code/addons/vitest/src/node/test-manager.ts index d1f58dbf6e1a..9e49216303a5 100644 --- a/code/addons/vitest/src/node/test-manager.ts +++ b/code/addons/vitest/src/node/test-manager.ts @@ -91,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; @@ -123,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: { @@ -139,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(); diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index b0f23643fbf4..6b79140ca246 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -208,7 +208,7 @@ 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 } = payload; + const { requestId, actor, storyIds, config: configOverride } = payload; const sendResponse = (response: Omit) => { channel.emit(TRIGGER_TEST_RUN_RESPONSE, { requestId, ...response }); @@ -218,6 +218,7 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti const { currentRun: { startedAt, finishedAt }, + config, } = store.getState(); if (startedAt && !finishedAt) { sendResponse({ @@ -232,6 +233,9 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti payload: { storyIds, triggeredBy: `external:${actor}`, + ...(configOverride && { + configOverride: { ...config, ...configOverride }, + }), }, }); diff --git a/code/addons/vitest/src/types.ts b/code/addons/vitest/src/types.ts index 15a5d0281a5c..e1f4d64ccdd7 100644 --- a/code/addons/vitest/src/types.ts +++ b/code/addons/vitest/src/types.ts @@ -84,6 +84,7 @@ export type TriggerRunEvent = { payload: { storyIds?: string[] | undefined; triggeredBy: RunTrigger; + configOverride?: StoreState['config']; }; }; From 9827e9686b4578ff921944548718274dfb74d1d6 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 10 Feb 2026 16:11:32 +0100 Subject: [PATCH 29/81] Centralize Vite plugins for builder-vite and addon-vitest --- code/addons/vitest/src/vitest-plugin/index.ts | 40 ++----- code/builders/builder-vite/src/build.ts | 3 +- .../src/codegen-modern-iframe-script.ts | 1 + .../src/codegen-project-annotations.ts | 97 +++++++++++++++++ code/builders/builder-vite/src/envs.ts | 19 ---- .../src/plugins/code-generator-plugin.ts | 9 +- .../builder-vite/src/plugins/index.ts | 18 ++-- .../src/plugins/inject-export-order-plugin.ts | 3 +- .../src/plugins/storybook-config-plugin.ts | 86 +++++++++++++++ .../src/plugins/storybook-docgen-plugin.ts | 32 ++++++ .../src/plugins/storybook-entry-plugin.ts | 36 +++++++ .../plugins/storybook-optimize-deps-plugin.ts | 52 +++++++++ .../storybook-project-annotations-plugin.ts | 49 +++++++++ .../src/plugins/storybook-runtime-plugin.ts | 74 +++++++++++++ .../src/plugins/strip-story-hmr-boundaries.ts | 4 +- code/builders/builder-vite/src/preset.ts | 71 +++++++++---- .../builder-vite/src/virtual-file-names.ts | 1 + .../builder-vite/src/vite-config.test.ts | 100 ++++++++++++++---- code/builders/builder-vite/src/vite-config.ts | 79 +++++--------- code/builders/builder-vite/src/vite-server.ts | 5 +- 20 files changed, 619 insertions(+), 160 deletions(-) create mode 100644 code/builders/builder-vite/src/codegen-project-annotations.ts create mode 100644 code/builders/builder-vite/src/plugins/storybook-config-plugin.ts create mode 100644 code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts create mode 100644 code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts create mode 100644 code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts create mode 100644 code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts create mode 100644 code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index b07edb2bfd00..96f2676645ad 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,27 @@ 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([ + // Core Storybook Vite plugins from builder-vite's preset + // (resolve conditions, envPrefix, fs.allow, project annotations, docgen, external globals) + 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 +231,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 +386,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 +405,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.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts index e063c3504c64..dd8643be1056 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -127,6 +127,7 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}'; ${options.isCsf4 ? previewFileImport : imports.join('\n')} + // Use import { getProjectAnnotations } from '${SB_VIRTUAL_FILES.VIRTUAL_PROJECT_ANNOTATIONS_FILE}'; instead ${getPreviewAnnotationsFunction} window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); 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..42c0b030fb8b --- /dev/null +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -0,0 +1,97 @@ +import { getFrameworkName, loadPreviewOrConfigFile } from 'storybook/internal/common'; +import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools'; +import type { Options, PreviewAnnotation } from 'storybook/internal/types'; + +import { 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. + * + * This virtual module encapsulates the `getProjectAnnotations` function which composes all preview + * annotations (from addons, frameworks, and the user's preview file) into a single configuration + * object used by Storybook's runtime. + * + * The generated module can be imported as: + * + * ```ts + * import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + * ``` + * + * This decouples the project annotations logic from the main iframe entry script, making it + * reusable by other consumers (e.g., addon-vitest). + */ +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 previewFileVariable = variables[variables.length - 1]; + const previewFileImport = imports[imports.length - 1]; + + if (options.isCsf4) { + return dedent` + ${previewFileImport} + + export function getProjectAnnotations() { + return ${previewFileVariable}.default.composed; + } + `.trim(); + } + + return dedent` + import { composeConfigs } from 'storybook/preview-api'; + + ${imports.join('\n')} + + export function getProjectAnnotations() { + return composeConfigs([${variables.join(', ')}]); + } + `.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..4946c341a981 100644 --- a/code/builders/builder-vite/src/envs.ts +++ b/code/builders/builder-vite/src/envs.ts @@ -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/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index 2af433d0c90b..5205a0806ca2 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -17,7 +17,7 @@ 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; @@ -57,7 +57,10 @@ export function codeGeneratorPlugin(options: Options): Plugin { iframeId = `${config.root}/iframe.html`; }, resolveId(source) { - if (SB_VIRTUAL_FILE_IDS.includes(source)) { + if ( + source !== SB_VIRTUAL_FILES.VIRTUAL_PROJECT_ANNOTATIONS_FILE && + SB_VIRTUAL_FILE_IDS.includes(source) + ) { return getResolvedVirtualModuleId(source); } if (source === iframePath) { @@ -94,5 +97,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..ef9336b8036c 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 { externalGlobalsPlugin, rewriteImport } from './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..4253ddadd423 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts @@ -0,0 +1,86 @@ +import { resolve } from 'node:path'; + +import { isPreservingSymlinks, resolvePathInStorybookCache } from 'storybook/internal/common'; + +import type { Plugin } from 'vite'; + +/** + * Options for the Storybook config plugin. + * + * This plugin provides the base Storybook-specific Vite configuration, including resolve + * conditions, environment variable prefixes, and filesystem access rules. It is designed to be + * shared between `@storybook/builder-vite` and `@storybook/addon-vitest`. + */ +export interface StorybookConfigPluginOptions { + /** The Storybook configuration directory (e.g., '.storybook') */ + configDir: string; + /** + * Cache key for the Vite cache directory. When set, cacheDir is resolved via Storybook's cache + * using the `sb-vite` prefix. Omit to let the caller handle cache directory configuration. + */ + cacheKey?: string; + /** + * Base public path for the Vite dev server. When set, overrides Vite's default base. For + * builder-vite, this is typically './'. Omit to keep the existing base from the user's config. + */ + base?: string; + /** + * Whether to set the Vite root to the parent of the config directory. Defaults to `true`. Set to + * `false` when the root is managed externally (e.g., by vitest). + */ + setRoot?: boolean; +} + +/** + * A Vite plugin that provides the base Storybook configuration. + * + * This handles: + * + * - Optionally setting the project root to the parent of the Storybook config directory + * - Optionally configuring the Vite cache directory + * - 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[] { + const projectRoot = resolve(options.configDir, '..'); + + return [ + { + name: 'storybook:config-plugin', + enforce: 'pre', + async config(config) { + const { defaultClientConditions = [] } = await import('vite'); + + return { + ...(options.cacheKey + ? { cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey) } + : {}), + ...(options.setRoot !== false ? { root: projectRoot } : {}), + ...(options.base !== undefined ? { base: options.base } : {}), + resolve: { + conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], + preserveSymlinks: isPreservingSymlinks(), + }, + // 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. + envPrefix: config.envPrefix ? ['STORYBOOK_'] : ['VITE_', 'STORYBOOK_'], + }; + }, + }, + { + 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-docgen-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts new file mode 100644 index 000000000000..8844dea41646 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts @@ -0,0 +1,32 @@ +import type { Options } from 'storybook/internal/types'; + +import { vite } from '@storybook/csf-plugin'; + +import type { Plugin } from 'vite'; + +/** + * A Vite plugin that handles the extraction of component metadata (argTypes, descriptions) for + * Storybook's documentation features. + * + * This wraps `@storybook/csf-plugin` and configures it based on Storybook's addon-docs options and + * CSF enrichment settings. The plugin processes CSF (Component Story Format) files to extract + * component metadata that powers Storybook's docs pages and controls. + * + * This plugin is designed to be shared between `@storybook/builder-vite` and + * `@storybook/addon-vitest`. + */ +export async function storybookDocgenPlugin(options: Options): Promise { + const { presets } = options; + + const addons = await presets.apply('addons', []); + const docsOptions = + // @ts-expect-error - not sure what type to use here + addons.find((a) => [a, a.name].includes('@storybook/addon-docs'))?.options ?? {}; + + const enrichCsf = await presets.apply('experimental_enrichCsf'); + + return vite({ + ...docsOptions?.csfPluginOptions, + enrichCsf, + }) as Plugin; +} 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..45d419d07199 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts @@ -0,0 +1,36 @@ +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. + * + * This handles: + * + * - Virtual module resolution for story imports, addon setup, and the main app entry + * - Story import function generation (dynamic imports for code splitting) + * - Iframe HTML transformation and build entry configuration + * - Story index watching for HMR invalidation + * - Export order injection (`__namedExportsOrder`) for consistent story discovery + * - HMR boundary stripping to prevent stories from being treated as HMR boundaries + * + * Note: The project annotations virtual module is provided separately by the `viteCorePlugins` + * preset so that it can be shared with addon-vitest. + * + * @returns An array of Vite plugins with appropriate enforcement ordering + */ +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/storybook-optimize-deps-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts new file mode 100644 index 000000000000..083c5bfc6fe8 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -0,0 +1,52 @@ +import type { StoryIndexGenerator } from 'storybook/internal/core-server'; +import type { Options, StoryIndex } from 'storybook/internal/types'; + +import type { Plugin } from 'vite'; + +import { INCLUDE_CANDIDATES } from '../constants'; +import { getUniqueImportPaths } from '../utils/unique-import-paths'; + +/** + * A Vite plugin that configures dependency optimization for Storybook's dev server. + * + * This handles: + * + * - Setting optimizeDeps entries from the story index (so Vite knows which stories to pre-bundle) + * - Including known CJS dependencies that need to be pre-compiled to ESM + * - Merging extra optimization dependencies from Storybook presets + * + * This plugin only applies in development mode (`command === 'serve'`). In production builds, + * Rollup handles dependency bundling differently. + */ +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: getUniqueImportPaths(index), + // Known CJS dependencies that need to be pre-compiled to ESM, + // plus any extra deps from Storybook presets. + include: [ + ...INCLUDE_CANDIDATES, + ...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..8714d2b28c04 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts @@ -0,0 +1,49 @@ +import type { Options } from 'storybook/internal/types'; + +import type { Plugin } from 'vite'; + +import { generateProjectAnnotationsCode } from '../codegen-project-annotations'; +import { SB_VIRTUAL_FILES, getResolvedVirtualModuleId } from '../virtual-file-names'; + +const VIRTUAL_ID = SB_VIRTUAL_FILES.VIRTUAL_PROJECT_ANNOTATIONS_FILE; +const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); + +/** + * A Vite plugin that serves the project annotations virtual module. + * + * This plugin handles the `virtual:/@storybook/builder-vite/project-annotations.js` virtual module, + * which exports a `getProjectAnnotations` function that composes all preview annotations (from + * addons, frameworks, and the user's preview file) into a single configuration object. + * + * The virtual module can be imported as: + * + * ```ts + * import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + * ``` + * + * This plugin is extracted from the builder-specific code-generator-plugin so that it can be shared + * with `@storybook/addon-vitest` and other consumers that need access to the composed project + * annotations without the full builder entry-point machinery (iframe handling, story index + * watching, etc.). + */ +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..be4701826b74 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts @@ -0,0 +1,74 @@ +import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; +import type { Builder_EnvsRaw } from 'storybook/internal/types'; +import type { Options } from 'storybook/internal/types'; + +import type { Plugin } from 'vite'; + +import { stringifyProcessEnvs } from '../envs'; +import { externalGlobalsPlugin } from './external-globals-plugin'; + +/** + * Options for the Storybook runtime plugin. + * + * This plugin injects necessary globals and environment variables for Storybook's runtime. It is + * designed to be shared between `@storybook/builder-vite` and `@storybook/addon-vitest`. + */ +export interface StorybookRuntimePluginOptions { + /** + * Map of external module names to their global variable reference names. + * + * Storybook preview modules are pre-bundled and exposed as globals at runtime. This map tells the + * plugin how to transform imports of those modules into destructured global variable references. + * + * @example + * + * ``` + * { "storybook/preview-api": "__STORYBOOK_MODULE_PREVIEW_API__" } + * ``` + */ + externals: Record; + + /** + * Pre-resolved environment variables to inject as `import.meta.env.*` defines. + * + * When provided, these are filtered by the resolved `envPrefix` from the Vite config and injected + * into Vite's `define` option. + * + * This allows callers to resolve env vars from their own source (e.g., Storybook presets) and + * pass them in without the plugin needing access to the presets system. + */ + envs?: Builder_EnvsRaw; +} + +/** + * A composite Vite plugin that injects necessary globals and environment variables for Storybook's + * runtime. + * + * This handles: + * + * - Transforming imports of pre-bundled Storybook preview modules to global variable references + * (e.g., `import { useMemo } from 'storybook/preview-api'` becomes `const { useMemo } = + * __STORYBOOK_MODULE_PREVIEW_API__`) + * - Setting up dev-mode aliases for external modules + * - Injecting environment variables as `import.meta.env.*` defines + * + * @returns An array of Vite plugins + */ +export async function storybookRuntimePlugin(options: Options): Promise { + const plugins: Plugin[] = [await externalGlobalsPlugin(globalsNameReferenceMap)]; + 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..71cd7964f376 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -1,34 +1,61 @@ import { findConfigFile } from 'storybook/internal/common'; +import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; 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 { storybookRuntimePlugin } 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`. + * + * Includes: + * + * - **Config plugin**: Resolve conditions (`storybook`, `stories`, `test`), environment variable + * prefixes (`VITE_`, `STORYBOOK_`), symlink preservation, and `fs.allow` for the config + * directory + * - **Project annotations plugin**: Virtual module serving `getProjectAnnotations` + * - **Docgen plugin**: CSF processing and component metadata extraction + * - **Runtime plugin**: External globals transformation for pre-bundled Storybook modules + * - **Mocking plugins**: Injects the mocker runtime script into the HTML and sets up rules to swap + * modules based on sb.mock() calls. + * + * Consumers can override builder-specific settings (root, base, cacheDir) by adding their own Vite + * plugins on top. + */ +export async function viteCorePlugins( + existing: 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 build = await options.presets.apply('build'); + const externals: Record = { ...globalsNameReferenceMap }; - const coreOptions = await options.presets.apply('core'); + if (build?.test?.disableBlocks) { + externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; + } - return { - ...existing, - plugins: [ - ...(existing.plugins ?? []), - ...(previewConfigPath - ? [ - viteInjectMockerRuntime({ previewConfigPath }), - viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }), - ] - : []), - ], - }; + return [ + ...(await storybookRuntimePlugin(options)), + ...storybookConfigPlugin({ configDir: options.configDir, setRoot: false }), + storybookOptimizeDepsPlugin(options), + storybookProjectAnnotationsPlugin(options), + ...(previewConfigPath + ? [ + viteInjectMockerRuntime({ previewConfigPath }), + viteMockPlugin({ + previewConfigPath, + coreOptions: await options.presets.apply('core'), + configDir: options.configDir, + }), + ] + : []), + ]; } diff --git a/code/builders/builder-vite/src/virtual-file-names.ts b/code/builders/builder-vite/src/virtual-file-names.ts index cf8f319480be..8c3d99e8738c 100644 --- a/code/builders/builder-vite/src/virtual-file-names.ts +++ b/code/builders/builder-vite/src/virtual-file-names.ts @@ -2,6 +2,7 @@ export const SB_VIRTUAL_FILES = { VIRTUAL_APP_FILE: 'virtual:/@storybook/builder-vite/vite-app.js', VIRTUAL_STORIES_FILE: 'virtual:/@storybook/builder-vite/storybook-stories.js', VIRTUAL_ADDON_SETUP_FILE: 'virtual:/@storybook/builder-vite/setup-addons.js', + VIRTUAL_PROJECT_ANNOTATIONS_FILE: 'virtual:/@storybook/builder-vite/project-annotations.js', }; export const SB_VIRTUAL_FILE_IDS = Object.values(SB_VIRTUAL_FILES); diff --git a/code/builders/builder-vite/src/vite-config.test.ts b/code/builders/builder-vite/src/vite-config.test.ts index 3097c069431a..a9a1d3572b28 100644 --- a/code/builders/builder-vite/src/vite-config.test.ts +++ b/code/builders/builder-vite/src/vite-config.test.ts @@ -4,6 +4,7 @@ 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) => ({ @@ -34,7 +35,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 +44,85 @@ 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 add STORYBOOK_ when user has custom envPrefix', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({ envPrefix: 'SECRET_' }, {}); + expect(result.envPrefix).toStrictEqual(['STORYBOOK_']); + }); + + it('should add STORYBOOK_ when user has custom envPrefix array', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({ envPrefix: ['SECRET_', 'VUE_'] }, {}); + expect(result.envPrefix).toStrictEqual(['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 set root when setRoot is not false', 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.root).toBe('/test'); + }); + + it('should not set root when setRoot is false', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook', setRoot: false }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({}, {}); + expect(result.root).toBeUndefined(); + }); + + it('should set base when provided', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook', base: './' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({}, {}); + expect(result.base).toBe('./'); + }); + + 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..02c43095eafd 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 { @@ -16,14 +11,9 @@ import type { InlineConfig as ViteInlineConfig, } from 'vite'; -import { - codeGeneratorPlugin, - csfPlugin, - externalGlobalsPlugin, - injectExportOrderPlugin, - pluginWebpackStats, - stripStoryHMRBoundary, -} from './plugins'; +import { pluginWebpackStats, storybookEntryPlugin } from './plugins'; +import { storybookDocgenPlugin } from './plugins/storybook-docgen-plugin'; +import { viteCorePlugins as corePlugins } from './preset'; import type { BuilderOptions } from './types'; export type PluginConfigType = 'build' | 'development'; @@ -46,7 +36,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 +48,14 @@ 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), - root: projectRoot, - // 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_'], // Pass build.target option from user's vite config build: { target: buildProperty?.target, @@ -86,33 +68,30 @@ 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 projectRoot = resolve(options.configDir, '..'); const plugins = [ - codeGeneratorPlugin(options), - await csfPlugin(options), - await injectExportOrderPlugin(), - await stripStoryHMRBoundary(), + // Shared core plugins (resolve conditions, envPrefix, fs.allow, docgen, externals, etc.) + ...(await corePlugins([], options)), + await storybookDocgenPlugin(options), + // Builder-specific: root, base, and cacheDir { - 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); - } + name: 'storybook:builder-vite-config', + enforce: 'pre' as const, + config() { + return { + root: projectRoot, + // Allow storybook deployed as subfolder. See https://github.com/storybookjs/builder-vite/issues/238 + base: './', + ...(options.cacheKey + ? { cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey) } + : {}), + }; }, }, - 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..ed2349db1e7d 100644 --- a/code/builders/builder-vite/src/vite-server.ts +++ b/code/builders/builder-vite/src/vite-server.ts @@ -5,9 +5,7 @@ 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'; export async function createViteServer(options: Options, devServer: Server) { @@ -29,7 +27,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 +48,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); } From d647cb862fc66480d2a30d2e611f490c49bf5d0a Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 11 Feb 2026 10:36:10 +0100 Subject: [PATCH 30/81] Refactor builder-vite: streamline plugin imports and remove unused code - Removed commented-out import for `getProjectAnnotations` in `codegen-modern-iframe-script.ts`. - Eliminated the direct call to `storybookRuntimePlugin` in `preset.ts`, now included in `vite-config.ts` for better organization. - Cleaned up `sandbox-parts.ts` by removing unnecessary `beforeAll` import while maintaining functionality for CSF4 support. --- .../builder-vite/src/codegen-modern-iframe-script.ts | 1 - code/builders/builder-vite/src/preset.ts | 2 -- code/builders/builder-vite/src/vite-config.ts | 2 ++ scripts/tasks/sandbox-parts.ts | 5 ++--- 4 files changed, 4 insertions(+), 6 deletions(-) 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 dd8643be1056..e063c3504c64 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -127,7 +127,6 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}'; ${options.isCsf4 ? previewFileImport : imports.join('\n')} - // Use import { getProjectAnnotations } from '${SB_VIRTUAL_FILES.VIRTUAL_PROJECT_ANNOTATIONS_FILE}'; instead ${getPreviewAnnotationsFunction} window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index 71cd7964f376..9a036b5d68bf 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -7,7 +7,6 @@ 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 { storybookRuntimePlugin } from './plugins/storybook-runtime-plugin'; import { viteInjectMockerRuntime } from './plugins/vite-inject-mocker/plugin'; import { viteMockPlugin } from './plugins/vite-mock/plugin'; @@ -43,7 +42,6 @@ export async function viteCorePlugins( } return [ - ...(await storybookRuntimePlugin(options)), ...storybookConfigPlugin({ configDir: options.configDir, setRoot: false }), storybookOptimizeDepsPlugin(options), storybookProjectAnnotationsPlugin(options), diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 02c43095eafd..58b00a8cc697 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -13,6 +13,7 @@ import type { import { pluginWebpackStats, storybookEntryPlugin } from './plugins'; import { storybookDocgenPlugin } from './plugins/storybook-docgen-plugin'; +import { storybookRuntimePlugin } from './plugins/storybook-runtime-plugin'; import { viteCorePlugins as corePlugins } from './preset'; import type { BuilderOptions } from './types'; @@ -73,6 +74,7 @@ export async function pluginConfig(options: Options) { const plugins = [ // Shared core plugins (resolve conditions, envPrefix, fs.allow, docgen, externals, etc.) ...(await corePlugins([], options)), + ...(await storybookRuntimePlugin(options)), await storybookDocgenPlugin(options), // Builder-specific: root, base, and cacheDir { 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' From 73e88867e67d8e41aaa1d64ad1b4e39a4df1a969 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 11 Feb 2026 11:54:56 +0100 Subject: [PATCH 31/81] Fix optimize deps --- .../builders/builder-vite/src/optimizeDeps.ts | 43 ------------------- .../plugins/storybook-optimize-deps-plugin.ts | 27 +++++++++--- 2 files changed, 21 insertions(+), 49 deletions(-) delete mode 100644 code/builders/builder-vite/src/optimizeDeps.ts diff --git a/code/builders/builder-vite/src/optimizeDeps.ts b/code/builders/builder-vite/src/optimizeDeps.ts deleted file mode 100644 index 61e5c1ed5b56..000000000000 --- a/code/builders/builder-vite/src/optimizeDeps.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 - * performance. - */ -const asyncFilter = async (arr: string[], predicate: (val: string) => Promise) => - Promise.all(arr.map(predicate)).then((results) => arr.filter((_v, index) => results[index])); - -// TODO: This function should be reworked. The code it uses is outdated and we need to investigate -// More info: https://github.com/storybookjs/storybook/issues/32462#issuecomment-3421326557 -export async function getOptimizeDeps(config: ViteInlineConfig, options: Options) { - const [extraOptimizeDeps, storyIndexGenerator] = await Promise.all([ - options.presets.apply('optimizeViteDeps', []), - options.presets.apply('storyIndexGenerator'), - ]); - - const index: StoryIndex = await storyIndexGenerator.getIndex(); - - // TODO: check if resolveConfig takes a lot of time, possible optimizations here - const resolvedConfig = await resolveConfig(config, 'serve', 'development'); - - // This function converts ids which might include ` > ` to a real path, if it exists on disk. - // See https://github.com/vitejs/vite/blob/67d164392e8e9081dc3f0338c4b4b8eea6c5f7da/packages/vite/src/node/optimizer/index.ts#L182-L199 - 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), - // 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 || [])], - }; - - return optimizeDeps; -} 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 index 083c5bfc6fe8..36695e28dc03 100644 --- a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -1,7 +1,7 @@ import type { StoryIndexGenerator } from 'storybook/internal/core-server'; import type { Options, StoryIndex } from 'storybook/internal/types'; -import type { Plugin } from 'vite'; +import { type Plugin, resolveConfig } from 'vite'; import { INCLUDE_CANDIDATES } from '../constants'; import { getUniqueImportPaths } from '../utils/unique-import-paths'; @@ -34,19 +34,34 @@ export function storybookOptimizeDepsPlugin(options: Options): Plugin { const index: StoryIndex = await storyIndexGenerator.getIndex(); + const { plugins, ...configToResolve } = config; + + const resolvedConfig = await resolveConfig(configToResolve, 'serve', 'development'); + + const resolve = resolvedConfig.createResolver({ asSrc: false }); + const include = await asyncFilter([...extraOptimizeDeps, ...INCLUDE_CANDIDATES], async (id) => + Boolean(await resolve(id)) + ); + return { optimizeDeps: { // Story file paths as entry points for the optimizer entries: getUniqueImportPaths(index), // Known CJS dependencies that need to be pre-compiled to ESM, // plus any extra deps from Storybook presets. - include: [ - ...INCLUDE_CANDIDATES, - ...extraOptimizeDeps, - ...(config.optimizeDeps?.include || []), - ], + include: [...include, ...(config.optimizeDeps?.include || [])], }, }; }, }; } + +/** + * Helper function which allows us to `filter` with an async predicate. Uses Promise.all for + * performance. + */ +async function asyncFilter(arr: string[], predicate: (val: string) => Promise) { + return Promise.all(arr.map(predicate)).then((results) => + arr.filter((_v, index) => results[index]) + ); +} From 4e54860c5d8fc97a84ed1ec3eadb8da78a5e5747 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 11 Feb 2026 14:02:29 +0100 Subject: [PATCH 32/81] Refactor storybookOptimizeDepsPlugin: simplify config resolution - Updated the `storybookOptimizeDepsPlugin` to directly resolve the configuration with an empty object instead of destructuring from the provided config. This change streamlines the plugin's logic for better clarity and maintainability. --- .../src/plugins/storybook-optimize-deps-plugin.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index 36695e28dc03..efa526a4f2d4 100644 --- a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -34,9 +34,7 @@ export function storybookOptimizeDepsPlugin(options: Options): Plugin { const index: StoryIndex = await storyIndexGenerator.getIndex(); - const { plugins, ...configToResolve } = config; - - const resolvedConfig = await resolveConfig(configToResolve, 'serve', 'development'); + const resolvedConfig = await resolveConfig({}, 'serve', 'development'); const resolve = resolvedConfig.createResolver({ asSrc: false }); const include = await asyncFilter([...extraOptimizeDeps, ...INCLUDE_CANDIDATES], async (id) => From ad0c63bb142e0cb2d9c07e5f75d0e4c89250297f Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 11 Feb 2026 14:42:32 +0100 Subject: [PATCH 33/81] Improve config resolution --- .../plugins/storybook-optimize-deps-plugin.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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 index efa526a4f2d4..e42e303a1432 100644 --- a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:url'; + import type { StoryIndexGenerator } from 'storybook/internal/core-server'; import type { Options, StoryIndex } from 'storybook/internal/types'; @@ -34,11 +36,19 @@ export function storybookOptimizeDepsPlugin(options: Options): Plugin { const index: StoryIndex = await storyIndexGenerator.getIndex(); - const resolvedConfig = await resolveConfig({}, 'serve', 'development'); - - const resolve = resolvedConfig.createResolver({ asSrc: false }); + const resolvedConfig = await resolveConfig( + { + root: resolve(options.configDir, '..'), + }, + 'serve', + 'development', + undefined, + undefined, + undefined + ); + const resolveId = await (await resolvedConfig).createResolver({ asSrc: false }); const include = await asyncFilter([...extraOptimizeDeps, ...INCLUDE_CANDIDATES], async (id) => - Boolean(await resolve(id)) + Boolean(await resolveId(id)) ); return { From 4f0fbef1d9626a6bd6184b2c76871954d445e90a Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 11 Feb 2026 21:03:36 +0100 Subject: [PATCH 34/81] Add optimizeDeps functionality to builder-vite - Introduced a new `optimizeDeps.ts` file containing the `getOptimizeDeps` function to handle dependency optimization for Vite. - Updated `vite-server.ts` to utilize `getOptimizeDeps` for improved dependency inclusion in the Vite server configuration. - Refactored `storybookOptimizeDepsPlugin` to streamline the inclusion of extra dependencies without redundant resolution logic. - Removed unused asyncFilter function from `storybookOptimizeDepsPlugin` for cleaner codebase. --- .../builders/builder-vite/src/optimizeDeps.ts | 30 +++++++++++++++++ .../plugins/storybook-optimize-deps-plugin.ts | 32 ++----------------- code/builders/builder-vite/src/vite-server.ts | 7 ++++ 3 files changed, 39 insertions(+), 30 deletions(-) create mode 100644 code/builders/builder-vite/src/optimizeDeps.ts diff --git a/code/builders/builder-vite/src/optimizeDeps.ts b/code/builders/builder-vite/src/optimizeDeps.ts new file mode 100644 index 000000000000..09c8b51b581e --- /dev/null +++ b/code/builders/builder-vite/src/optimizeDeps.ts @@ -0,0 +1,30 @@ +import { type UserConfig, type InlineConfig as ViteInlineConfig, resolveConfig } from 'vite'; + +import { INCLUDE_CANDIDATES } from './constants'; + +/** + * Helper function which allows us to `filter` with an async predicate. Uses Promise.all for + * performance. + */ +const asyncFilter = async (arr: string[], predicate: (val: string) => Promise) => + Promise.all(arr.map(predicate)).then((results) => arr.filter((_v, index) => results[index])); + +// TODO: This function should be reworked. The code it uses is outdated and we need to investigate +// More info: https://github.com/storybookjs/storybook/issues/32462#issuecomment-3421326557 +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'); + + // This function converts ids which might include ` > ` to a real path, if it exists on disk. + // See https://github.com/vitejs/vite/blob/67d164392e8e9081dc3f0338c4b4b8eea6c5f7da/packages/vite/src/node/optimizer/index.ts#L182-L199 + const resolve = resolvedConfig.createResolver({ asSrc: false }); + const include = await asyncFilter(INCLUDE_CANDIDATES, async (id) => Boolean(await resolve(id))); + + 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, ...(config.optimizeDeps?.include || [])], + } satisfies UserConfig['optimizeDeps']; + + return optimizeDeps; +} 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 index e42e303a1432..e7fc99c56fba 100644 --- a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -1,11 +1,8 @@ -import { resolve } from 'node:url'; - import type { StoryIndexGenerator } from 'storybook/internal/core-server'; import type { Options, StoryIndex } from 'storybook/internal/types'; -import { type Plugin, resolveConfig } from 'vite'; +import { type Plugin } from 'vite'; -import { INCLUDE_CANDIDATES } from '../constants'; import { getUniqueImportPaths } from '../utils/unique-import-paths'; /** @@ -36,40 +33,15 @@ export function storybookOptimizeDepsPlugin(options: Options): Plugin { const index: StoryIndex = await storyIndexGenerator.getIndex(); - const resolvedConfig = await resolveConfig( - { - root: resolve(options.configDir, '..'), - }, - 'serve', - 'development', - undefined, - undefined, - undefined - ); - const resolveId = await (await resolvedConfig).createResolver({ asSrc: false }); - const include = await asyncFilter([...extraOptimizeDeps, ...INCLUDE_CANDIDATES], async (id) => - Boolean(await resolveId(id)) - ); - return { optimizeDeps: { // Story file paths as entry points for the optimizer entries: getUniqueImportPaths(index), // Known CJS dependencies that need to be pre-compiled to ESM, // plus any extra deps from Storybook presets. - include: [...include, ...(config.optimizeDeps?.include || [])], + include: [...extraOptimizeDeps, ...(config.optimizeDeps?.include || [])], }, }; }, }; } - -/** - * Helper function which allows us to `filter` with an async predicate. Uses Promise.all for - * performance. - */ -async function asyncFilter(arr: string[], predicate: (val: string) => Promise) { - return Promise.all(arr.map(predicate)).then((results) => - arr.filter((_v, index) => results[index]) - ); -} diff --git a/code/builders/builder-vite/src/vite-server.ts b/code/builders/builder-vite/src/vite-server.ts index ed2349db1e7d..9c694e94e410 100644 --- a/code/builders/builder-vite/src/vite-server.ts +++ b/code/builders/builder-vite/src/vite-server.ts @@ -6,6 +6,7 @@ import { dedent } from 'ts-dedent'; import type { InlineConfig, ServerOptions } from 'vite'; import { createViteLogger } from './logger'; +import { getOptimizeDeps } from './optimizeDeps'; import { commonConfig } from './vite-config'; export async function createViteServer(options: Options, devServer: Server) { @@ -13,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: { From 0e3667f1bd46323a024d64a754dcc854c79ba26f Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 08:46:42 +0100 Subject: [PATCH 35/81] Cleanup --- .../src/plugins/storybook-config-plugin.ts | 37 ++++++------------- .../src/plugins/storybook-runtime-plugin.ts | 2 +- code/builders/builder-vite/src/preset.ts | 2 +- 3 files changed, 14 insertions(+), 27 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts index 4253ddadd423..1ba7d8880ac0 100644 --- a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts @@ -1,5 +1,3 @@ -import { resolve } from 'node:path'; - import { isPreservingSymlinks, resolvePathInStorybookCache } from 'storybook/internal/common'; import type { Plugin } from 'vite'; @@ -14,21 +12,6 @@ import type { Plugin } from 'vite'; export interface StorybookConfigPluginOptions { /** The Storybook configuration directory (e.g., '.storybook') */ configDir: string; - /** - * Cache key for the Vite cache directory. When set, cacheDir is resolved via Storybook's cache - * using the `sb-vite` prefix. Omit to let the caller handle cache directory configuration. - */ - cacheKey?: string; - /** - * Base public path for the Vite dev server. When set, overrides Vite's default base. For - * builder-vite, this is typically './'. Omit to keep the existing base from the user's config. - */ - base?: string; - /** - * Whether to set the Vite root to the parent of the config directory. Defaults to `true`. Set to - * `false` when the root is managed externally (e.g., by vitest). - */ - setRoot?: boolean; } /** @@ -44,8 +27,6 @@ export interface StorybookConfigPluginOptions { * - Preserving symlinks when applicable */ export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Plugin[] { - const projectRoot = resolve(options.configDir, '..'); - return [ { name: 'storybook:config-plugin', @@ -53,19 +34,25 @@ export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Pl async config(config) { const { defaultClientConditions = [] } = await import('vite'); + const existingEnvPrefix = config.envPrefix; + const mergedEnvPrefix = existingEnvPrefix + ? Array.from( + new Set([ + ...(Array.isArray(existingEnvPrefix) ? existingEnvPrefix : [existingEnvPrefix]), + 'STORYBOOK_', + ]) + ) + : ['VITE_', 'STORYBOOK_']; + return { - ...(options.cacheKey - ? { cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey) } - : {}), - ...(options.setRoot !== false ? { root: projectRoot } : {}), - ...(options.base !== undefined ? { base: options.base } : {}), + cacheDir: resolvePathInStorybookCache('sb-vite'), resolve: { conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], preserveSymlinks: isPreservingSymlinks(), }, // 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. - envPrefix: config.envPrefix ? ['STORYBOOK_'] : ['VITE_', 'STORYBOOK_'], + envPrefix: mergedEnvPrefix, }; }, }, diff --git a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts index be4701826b74..e930d9cfb513 100644 --- a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts @@ -56,7 +56,7 @@ export interface StorybookRuntimePluginOptions { */ export async function storybookRuntimePlugin(options: Options): Promise { const plugins: Plugin[] = [await externalGlobalsPlugin(globalsNameReferenceMap)]; - const envs = await options.presets.apply>('env'); + const envs = await options.presets.apply('env'); if (envs && Object.keys(envs).length > 0) { plugins.push({ diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index 9a036b5d68bf..3eb6b248b10f 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -42,7 +42,7 @@ export async function viteCorePlugins( } return [ - ...storybookConfigPlugin({ configDir: options.configDir, setRoot: false }), + ...storybookConfigPlugin({ configDir: options.configDir }), storybookOptimizeDepsPlugin(options), storybookProjectAnnotationsPlugin(options), ...(previewConfigPath From 59342f0c34c319847851b0661b35db544cca805f Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 09:38:06 +0100 Subject: [PATCH 36/81] Refactor static directory handling in Vitest plugin and core server --- code/addons/vitest/src/vitest-plugin/index.ts | 27 ++++--------- code/core/src/builder-manager/index.ts | 20 ++-------- code/core/src/core-server/index.ts | 2 +- .../src/core-server/utils/server-statics.ts | 38 ++++++++++++++----- 4 files changed, 40 insertions(+), 47 deletions(-) diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 96f2676645ad..bd61bec98cf4 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -16,7 +16,7 @@ import { StoryIndexGenerator, Tag, experimental_loadStorybook, - mapStaticDir, + useStaticDirs, } from 'storybook/internal/core-server'; import { componentTransform, readConfig, vitestTransform } from 'storybook/internal/csf-tools'; import { MainFileMissingError } from 'storybook/internal/server-errors'; @@ -28,11 +28,10 @@ import { match } from 'micromatch'; import { join, normalize, relative, resolve, sep } from 'pathe'; import path from 'pathe'; import picocolors from 'picocolors'; -import sirv from 'sirv'; import { dedent } from 'ts-dedent'; import type { PluginOption } from 'vite'; -// Shared plugins from builder-vite (relative import to prebundle without adding a package dependency) +// ! Relative import to prebundle it without needing to depend on the Vite builder import { withoutVitePlugins } from '../../../../builders/builder-vite/src/utils/without-vite-plugins'; import type { InternalOptions, UserOptions } from './types'; @@ -405,7 +404,9 @@ export const storybookTest = async (options?: UserOptions): Promise => }, }; - const config = mergeConfig(baseConfig, viteConfigFromStorybook); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { plugins: _, ...viteConfig } = viteConfigFromStorybook; + const config = mergeConfig(baseConfig, viteConfig); // alert the user of problems if ((nonMutableInputConfig.test?.include?.length ?? 0) > 0) { @@ -448,21 +449,9 @@ export const storybookTest = async (options?: UserOptions): Promise => }, async configureServer(server) { if (staticDirs) { - for (const staticDir of staticDirs) { - try { - const { staticPath, targetEndpoint } = mapStaticDir(staticDir, directories.configDir); - server.middlewares.use( - targetEndpoint, - sirv(staticPath, { - dev: true, - etag: true, - extensions: [], - }) - ); - } catch (e) { - console.warn(e); - } - } + useStaticDirs(staticDirs, directories.configDir, (endpoint, handler) => + server.middlewares.use(endpoint, handler) + ); } }, async transform(code, id) { diff --git a/code/core/src/builder-manager/index.ts b/code/core/src/builder-manager/index.ts index 265b37e102d8..54241166fd38 100644 --- a/code/core/src/builder-manager/index.ts +++ b/code/core/src/builder-manager/index.ts @@ -1,12 +1,12 @@ import { cp, rm, writeFile } from 'node:fs/promises'; import { stringifyProcessEnvs } from 'storybook/internal/common'; +import { sirvMiddleware } from 'storybook/internal/core-server'; import { logger } from 'storybook/internal/node-logger'; import { globalExternals } from '@fal-works/esbuild-plugin-global-externals'; import { resolveModulePath } from 'exsolve'; import { join, parse } from 'pathe'; -import sirv from 'sirv'; import { globalsModuleInfoMap } from '../manager/globals/globals-module-info'; import { BROWSER_TARGETS, SUPPORTED_FEATURES } from '../shared/constants/environments-support'; @@ -171,22 +171,8 @@ const starter: StarterFunction = async function* starterGeneratorFn({ yield; - router.use( - '/sb-addons', - sirv(addonsDir, { - maxAge: 300000, - dev: true, - immutable: true, - }) - ); - router.use( - '/sb-manager', - sirv(CORE_DIR_ORIGIN, { - maxAge: 300000, - dev: true, - immutable: true, - }) - ); + router.use('/sb-addons', sirvMiddleware(addonsDir, { maxAge: 300000, immutable: true })); + router.use('/sb-manager', sirvMiddleware(CORE_DIR_ORIGIN, { maxAge: 300000, immutable: true })); const { cssFiles, jsFiles } = await readOrderedFiles(addonsDir, compilation?.outputFiles); diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts index f475fa6166ca..c7bcd6fc731e 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -7,7 +7,7 @@ export * from './build-dev'; export * from './build-index'; export * from './withTelemetry'; export { default as build } from './standalone'; -export { mapStaticDir } from './utils/server-statics'; +export { mapStaticDir, useStaticDirs, sirvMiddleware } from './utils/server-statics'; export { StoryIndexGenerator } from './utils/StoryIndexGenerator'; export { generateStoryFile } from './utils/generate-story'; export type { GenerateStoryResult, GenerateStoryOptions } from './utils/generate-story'; diff --git a/code/core/src/core-server/utils/server-statics.ts b/code/core/src/core-server/utils/server-statics.ts index 966fdd2f5789..7233fd7c8b74 100644 --- a/code/core/src/core-server/utils/server-statics.ts +++ b/code/core/src/core-server/utils/server-statics.ts @@ -1,6 +1,6 @@ import { existsSync, statSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; -import { basename, dirname, isAbsolute, join, posix, resolve, sep, win32 } from 'node:path'; +import { basename, isAbsolute, join, posix, resolve, sep, win32 } from 'node:path'; import { getDirectoryFromWorkingDir, @@ -14,6 +14,7 @@ import { relative } from 'pathe'; import picocolors from 'picocolors'; import type { Polka } from 'polka'; import sirv from 'sirv'; +import type { RequestHandler } from 'sirv'; import { dedent } from 'ts-dedent'; import { resolvePackageDir } from '../../shared/utils/module'; @@ -109,12 +110,12 @@ export async function useStatics(app: Polka, options: Options): Promise { } req.url = `/${faviconFile}`; - return sirvWorkaround(faviconDir)(req, res, next); + return sirvMiddleware(faviconDir)(req, res, next); }); - staticDirs.map((dir) => { + for (const dir of staticDirs) { try { - const { staticDir, staticPath, targetEndpoint } = mapStaticDir(dir, options.configDir); + const { staticDir, targetEndpoint } = mapStaticDir(dir, options.configDir); // Don't log for internal static dirs if (!targetEndpoint.startsWith('/sb-') && !staticDir.startsWith(cacheDir)) { @@ -123,34 +124,51 @@ export async function useStatics(app: Polka, options: Options): Promise { `Serving static files from ${CLI_COLORS.info(relativeStaticDir)} at ${CLI_COLORS.info(targetEndpoint)}` ); } + } catch { + // already handled in useStaticDirs + } + } + + useStaticDirs(staticDirs, options.configDir, (endpoint, handler) => app.use(endpoint, handler)); +} + +export function useStaticDirs( + staticDirs: NonNullable, + configDir: string, + use: (endpoint: string, handler: RequestHandler) => void +): void { + for (const dir of staticDirs) { + try { + const { staticPath, targetEndpoint } = mapStaticDir(dir, configDir); if (existsSync(staticPath) && statSync(staticPath).isFile()) { // sirv doesn't support serving single files, so we need to pass the file's directory to sirv instead const staticPathDir = resolve(staticPath, '..'); const staticPathFile = basename(staticPath); - app.use(targetEndpoint, (req, res, next) => { + use(targetEndpoint, (req, res, next) => { // Rewrite the URL to match the file's name, ensuring that we only ever serve the file // even when sirv is passed the full directory req.url = `/${staticPathFile}`; - sirvWorkaround(staticPathDir)(req, res, next); + sirvMiddleware(staticPathDir)(req, res, next); }); } else { - app.use(targetEndpoint, sirvWorkaround(staticPath)); + use(targetEndpoint, sirvMiddleware(staticPath)); } } catch (e) { if (e instanceof Error) { logger.warn(e.message); } } - }); + } } /** - * This is a workaround for sirv breaking when serving multiple directories on the same endpoint. + * Wrapper around sirv that works around sirv breaking when serving multiple directories on the same + * endpoint. * * @see https://github.com/lukeed/polka/issues/218 */ -const sirvWorkaround: typeof sirv = +export const sirvMiddleware: typeof sirv = (dir, opts = {}) => (req, res, next) => { // polka+sirv will modify the request URL, so we need to restore it after sirv is done From 9915f3d14e63f62963a50ce8034f5b6881f3f134 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 10:26:38 +0100 Subject: [PATCH 37/81] Fix types --- .../src/core-server/utils/server-statics.ts | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/code/core/src/core-server/utils/server-statics.ts b/code/core/src/core-server/utils/server-statics.ts index 7233fd7c8b74..e3020b43890d 100644 --- a/code/core/src/core-server/utils/server-statics.ts +++ b/code/core/src/core-server/utils/server-statics.ts @@ -10,15 +10,40 @@ import { import { CLI_COLORS, logger, once } from 'storybook/internal/node-logger'; import type { Options, StorybookConfigRaw } from 'storybook/internal/types'; +import type { Stats } from 'fs'; +import type { IncomingMessage, ServerResponse } from 'http'; import { relative } from 'pathe'; import picocolors from 'picocolors'; import type { Polka } from 'polka'; import sirv from 'sirv'; -import type { RequestHandler } from 'sirv'; import { dedent } from 'ts-dedent'; import { resolvePackageDir } from '../../shared/utils/module'; +type Arrayable = T | T[]; + +interface SirvOptions { + dev?: boolean; + etag?: boolean; + maxAge?: number; + immutable?: boolean; + single?: string | boolean; + ignores?: false | Arrayable; + extensions?: string[]; + dotfiles?: boolean; + brotli?: boolean; + gzip?: boolean; + onNoMatch?: (req: IncomingMessage, res: ServerResponse) => void; + setHeaders?: (res: ServerResponse, pathname: string, stats: Stats) => void; +} + +export type NextHandler = () => void | Promise; +export type RequestHandler = ( + req: IncomingMessage, + res: ServerResponse, + next?: NextHandler +) => void; + const cacheDir = resolvePathInStorybookCache('', 'ignored-sub').split('ignored-sub')[0]; const files = new Map(); @@ -168,7 +193,10 @@ export function useStaticDirs( * * @see https://github.com/lukeed/polka/issues/218 */ -export const sirvMiddleware: typeof sirv = +export const sirvMiddleware: ( + dir?: string | undefined, + opts?: SirvOptions | undefined +) => RequestHandler = (dir, opts = {}) => (req, res, next) => { // polka+sirv will modify the request URL, so we need to restore it after sirv is done From 5439fe11ee2fcef68477cb53ba259ae189917cdf Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 11:21:21 +0100 Subject: [PATCH 38/81] Remove obsolete test cases --- .../builder-vite/src/vite-config.test.ts | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/code/builders/builder-vite/src/vite-config.test.ts b/code/builders/builder-vite/src/vite-config.test.ts index a9a1d3572b28..0dc8c5b04708 100644 --- a/code/builders/builder-vite/src/vite-config.test.ts +++ b/code/builders/builder-vite/src/vite-config.test.ts @@ -59,22 +59,6 @@ describe('storybookConfigPlugin', () => { expect(result.envPrefix).toStrictEqual(['VITE_', 'STORYBOOK_']); }); - it('should add STORYBOOK_ when user has custom envPrefix', async () => { - const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); - const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; - - const result = await (configPlugin.config as Function)({ envPrefix: 'SECRET_' }, {}); - expect(result.envPrefix).toStrictEqual(['STORYBOOK_']); - }); - - it('should add STORYBOOK_ when user has custom envPrefix array', async () => { - const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); - const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; - - const result = await (configPlugin.config as Function)({ envPrefix: ['SECRET_', 'VUE_'] }, {}); - expect(result.envPrefix).toStrictEqual(['STORYBOOK_']); - }); - it('should include storybook resolve conditions', async () => { const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; @@ -85,30 +69,6 @@ describe('storybookConfigPlugin', () => { expect(result.resolve.conditions).toContain('test'); }); - it('should set root when setRoot is not false', 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.root).toBe('/test'); - }); - - it('should not set root when setRoot is false', async () => { - const plugins = storybookConfigPlugin({ configDir: '/test/.storybook', setRoot: false }); - const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; - - const result = await (configPlugin.config as Function)({}, {}); - expect(result.root).toBeUndefined(); - }); - - it('should set base when provided', async () => { - const plugins = storybookConfigPlugin({ configDir: '/test/.storybook', base: './' }); - const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; - - const result = await (configPlugin.config as Function)({}, {}); - expect(result.base).toBe('./'); - }); - 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')!; From 36df811d6d3951515e70932077d09c4ad07bb394 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:00:07 +0100 Subject: [PATCH 39/81] Revert "Fix types" This reverts commit 9915f3d14e63f62963a50ce8034f5b6881f3f134. --- .../src/core-server/utils/server-statics.ts | 32 ++----------------- 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/code/core/src/core-server/utils/server-statics.ts b/code/core/src/core-server/utils/server-statics.ts index e3020b43890d..7233fd7c8b74 100644 --- a/code/core/src/core-server/utils/server-statics.ts +++ b/code/core/src/core-server/utils/server-statics.ts @@ -10,40 +10,15 @@ import { import { CLI_COLORS, logger, once } from 'storybook/internal/node-logger'; import type { Options, StorybookConfigRaw } from 'storybook/internal/types'; -import type { Stats } from 'fs'; -import type { IncomingMessage, ServerResponse } from 'http'; import { relative } from 'pathe'; import picocolors from 'picocolors'; import type { Polka } from 'polka'; import sirv from 'sirv'; +import type { RequestHandler } from 'sirv'; import { dedent } from 'ts-dedent'; import { resolvePackageDir } from '../../shared/utils/module'; -type Arrayable = T | T[]; - -interface SirvOptions { - dev?: boolean; - etag?: boolean; - maxAge?: number; - immutable?: boolean; - single?: string | boolean; - ignores?: false | Arrayable; - extensions?: string[]; - dotfiles?: boolean; - brotli?: boolean; - gzip?: boolean; - onNoMatch?: (req: IncomingMessage, res: ServerResponse) => void; - setHeaders?: (res: ServerResponse, pathname: string, stats: Stats) => void; -} - -export type NextHandler = () => void | Promise; -export type RequestHandler = ( - req: IncomingMessage, - res: ServerResponse, - next?: NextHandler -) => void; - const cacheDir = resolvePathInStorybookCache('', 'ignored-sub').split('ignored-sub')[0]; const files = new Map(); @@ -193,10 +168,7 @@ export function useStaticDirs( * * @see https://github.com/lukeed/polka/issues/218 */ -export const sirvMiddleware: ( - dir?: string | undefined, - opts?: SirvOptions | undefined -) => RequestHandler = +export const sirvMiddleware: typeof sirv = (dir, opts = {}) => (req, res, next) => { // polka+sirv will modify the request URL, so we need to restore it after sirv is done From 5298a639ff7281d4f2519566d2e8d31f39376472 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:00:24 +0100 Subject: [PATCH 40/81] Revert "Refactor static directory handling in Vitest plugin and core server" This reverts commit 59342f0c34c319847851b0661b35db544cca805f. --- code/addons/vitest/src/vitest-plugin/index.ts | 27 +++++++++---- code/core/src/builder-manager/index.ts | 20 ++++++++-- code/core/src/core-server/index.ts | 2 +- .../src/core-server/utils/server-statics.ts | 38 +++++-------------- 4 files changed, 47 insertions(+), 40 deletions(-) diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index bd61bec98cf4..96f2676645ad 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -16,7 +16,7 @@ import { StoryIndexGenerator, Tag, experimental_loadStorybook, - useStaticDirs, + mapStaticDir, } from 'storybook/internal/core-server'; import { componentTransform, readConfig, vitestTransform } from 'storybook/internal/csf-tools'; import { MainFileMissingError } from 'storybook/internal/server-errors'; @@ -28,10 +28,11 @@ import { match } from 'micromatch'; import { join, normalize, relative, resolve, sep } from 'pathe'; 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'; @@ -404,9 +405,7 @@ export const storybookTest = async (options?: UserOptions): Promise => }, }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { plugins: _, ...viteConfig } = viteConfigFromStorybook; - const config = mergeConfig(baseConfig, viteConfig); + const config = mergeConfig(baseConfig, viteConfigFromStorybook); // alert the user of problems if ((nonMutableInputConfig.test?.include?.length ?? 0) > 0) { @@ -449,9 +448,21 @@ export const storybookTest = async (options?: UserOptions): Promise => }, async configureServer(server) { if (staticDirs) { - useStaticDirs(staticDirs, directories.configDir, (endpoint, handler) => - server.middlewares.use(endpoint, handler) - ); + for (const staticDir of staticDirs) { + try { + const { staticPath, targetEndpoint } = mapStaticDir(staticDir, directories.configDir); + server.middlewares.use( + targetEndpoint, + sirv(staticPath, { + dev: true, + etag: true, + extensions: [], + }) + ); + } catch (e) { + console.warn(e); + } + } } }, async transform(code, id) { diff --git a/code/core/src/builder-manager/index.ts b/code/core/src/builder-manager/index.ts index 54241166fd38..265b37e102d8 100644 --- a/code/core/src/builder-manager/index.ts +++ b/code/core/src/builder-manager/index.ts @@ -1,12 +1,12 @@ import { cp, rm, writeFile } from 'node:fs/promises'; import { stringifyProcessEnvs } from 'storybook/internal/common'; -import { sirvMiddleware } from 'storybook/internal/core-server'; import { logger } from 'storybook/internal/node-logger'; import { globalExternals } from '@fal-works/esbuild-plugin-global-externals'; import { resolveModulePath } from 'exsolve'; import { join, parse } from 'pathe'; +import sirv from 'sirv'; import { globalsModuleInfoMap } from '../manager/globals/globals-module-info'; import { BROWSER_TARGETS, SUPPORTED_FEATURES } from '../shared/constants/environments-support'; @@ -171,8 +171,22 @@ const starter: StarterFunction = async function* starterGeneratorFn({ yield; - router.use('/sb-addons', sirvMiddleware(addonsDir, { maxAge: 300000, immutable: true })); - router.use('/sb-manager', sirvMiddleware(CORE_DIR_ORIGIN, { maxAge: 300000, immutable: true })); + router.use( + '/sb-addons', + sirv(addonsDir, { + maxAge: 300000, + dev: true, + immutable: true, + }) + ); + router.use( + '/sb-manager', + sirv(CORE_DIR_ORIGIN, { + maxAge: 300000, + dev: true, + immutable: true, + }) + ); const { cssFiles, jsFiles } = await readOrderedFiles(addonsDir, compilation?.outputFiles); diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts index c7bcd6fc731e..f475fa6166ca 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -7,7 +7,7 @@ export * from './build-dev'; export * from './build-index'; export * from './withTelemetry'; export { default as build } from './standalone'; -export { mapStaticDir, useStaticDirs, sirvMiddleware } from './utils/server-statics'; +export { mapStaticDir } from './utils/server-statics'; export { StoryIndexGenerator } from './utils/StoryIndexGenerator'; export { generateStoryFile } from './utils/generate-story'; export type { GenerateStoryResult, GenerateStoryOptions } from './utils/generate-story'; diff --git a/code/core/src/core-server/utils/server-statics.ts b/code/core/src/core-server/utils/server-statics.ts index 7233fd7c8b74..966fdd2f5789 100644 --- a/code/core/src/core-server/utils/server-statics.ts +++ b/code/core/src/core-server/utils/server-statics.ts @@ -1,6 +1,6 @@ import { existsSync, statSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; -import { basename, isAbsolute, join, posix, resolve, sep, win32 } from 'node:path'; +import { basename, dirname, isAbsolute, join, posix, resolve, sep, win32 } from 'node:path'; import { getDirectoryFromWorkingDir, @@ -14,7 +14,6 @@ import { relative } from 'pathe'; import picocolors from 'picocolors'; import type { Polka } from 'polka'; import sirv from 'sirv'; -import type { RequestHandler } from 'sirv'; import { dedent } from 'ts-dedent'; import { resolvePackageDir } from '../../shared/utils/module'; @@ -110,12 +109,12 @@ export async function useStatics(app: Polka, options: Options): Promise { } req.url = `/${faviconFile}`; - return sirvMiddleware(faviconDir)(req, res, next); + return sirvWorkaround(faviconDir)(req, res, next); }); - for (const dir of staticDirs) { + staticDirs.map((dir) => { try { - const { staticDir, targetEndpoint } = mapStaticDir(dir, options.configDir); + const { staticDir, staticPath, targetEndpoint } = mapStaticDir(dir, options.configDir); // Don't log for internal static dirs if (!targetEndpoint.startsWith('/sb-') && !staticDir.startsWith(cacheDir)) { @@ -124,51 +123,34 @@ export async function useStatics(app: Polka, options: Options): Promise { `Serving static files from ${CLI_COLORS.info(relativeStaticDir)} at ${CLI_COLORS.info(targetEndpoint)}` ); } - } catch { - // already handled in useStaticDirs - } - } - - useStaticDirs(staticDirs, options.configDir, (endpoint, handler) => app.use(endpoint, handler)); -} - -export function useStaticDirs( - staticDirs: NonNullable, - configDir: string, - use: (endpoint: string, handler: RequestHandler) => void -): void { - for (const dir of staticDirs) { - try { - const { staticPath, targetEndpoint } = mapStaticDir(dir, configDir); if (existsSync(staticPath) && statSync(staticPath).isFile()) { // sirv doesn't support serving single files, so we need to pass the file's directory to sirv instead const staticPathDir = resolve(staticPath, '..'); const staticPathFile = basename(staticPath); - use(targetEndpoint, (req, res, next) => { + app.use(targetEndpoint, (req, res, next) => { // Rewrite the URL to match the file's name, ensuring that we only ever serve the file // even when sirv is passed the full directory req.url = `/${staticPathFile}`; - sirvMiddleware(staticPathDir)(req, res, next); + sirvWorkaround(staticPathDir)(req, res, next); }); } else { - use(targetEndpoint, sirvMiddleware(staticPath)); + app.use(targetEndpoint, sirvWorkaround(staticPath)); } } catch (e) { if (e instanceof Error) { logger.warn(e.message); } } - } + }); } /** - * Wrapper around sirv that works around sirv breaking when serving multiple directories on the same - * endpoint. + * This is a workaround for sirv breaking when serving multiple directories on the same endpoint. * * @see https://github.com/lukeed/polka/issues/218 */ -export const sirvMiddleware: typeof sirv = +const sirvWorkaround: typeof sirv = (dir, opts = {}) => (req, res, next) => { // polka+sirv will modify the request URL, so we need to restore it after sirv is done From d4f2b6c15a0d96f75465b6742fd1432416c0a7fb Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:04:08 +0100 Subject: [PATCH 41/81] Refactor Vite plugin to streamline external globals handling - Moved the external globals logic into the `storybookRuntimePlugin` function. - Removed redundant external globals mapping from `viteCorePlugins`. - Added conditional handling for `@storybook/addon-docs/blocks` based on build configuration. --- .../src/plugins/storybook-runtime-plugin.ts | 11 ++++++++++- code/builders/builder-vite/src/preset.ts | 8 -------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts index e930d9cfb513..c2f559b892d1 100644 --- a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts @@ -55,7 +55,16 @@ export interface StorybookRuntimePluginOptions { * @returns An array of Vite plugins */ export async function storybookRuntimePlugin(options: Options): Promise { - const plugins: Plugin[] = [await externalGlobalsPlugin(globalsNameReferenceMap)]; + 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__'; + } + + const plugins: Plugin[] = [await externalGlobalsPlugin(externals)]; const envs = await options.presets.apply('env'); if (envs && Object.keys(envs).length > 0) { diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index 3eb6b248b10f..03b192edbb1e 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -1,5 +1,4 @@ import { findConfigFile } from 'storybook/internal/common'; -import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; import type { Options } from 'storybook/internal/types'; import type { PluginOption } from 'vite'; @@ -34,13 +33,6 @@ export async function viteCorePlugins( ): Promise { const previewConfigPath = findConfigFile('preview', options.configDir); - const build = await options.presets.apply('build'); - const externals: Record = { ...globalsNameReferenceMap }; - - if (build?.test?.disableBlocks) { - externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; - } - return [ ...storybookConfigPlugin({ configDir: options.configDir }), storybookOptimizeDepsPlugin(options), From afe0ea3a5650a916142b600bb3d945ce7930c66b Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:08:34 +0100 Subject: [PATCH 42/81] Update virtual file names and remove unused constant - Removed the `VIRTUAL_PROJECT_ANNOTATIONS_FILE` from `SB_VIRTUAL_FILES`. - Updated the `VIRTUAL_ID` in the `storybook-project-annotations-plugin` to directly use the virtual file path string instead of the constant. --- .../builder-vite/src/plugins/code-generator-plugin.ts | 5 +---- .../src/plugins/storybook-project-annotations-plugin.ts | 2 +- code/builders/builder-vite/src/virtual-file-names.ts | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) 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 5205a0806ca2..a2ddef103eab 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -57,10 +57,7 @@ export function codeGeneratorPlugin(options: Options) { iframeId = `${config.root}/iframe.html`; }, resolveId(source) { - if ( - source !== SB_VIRTUAL_FILES.VIRTUAL_PROJECT_ANNOTATIONS_FILE && - SB_VIRTUAL_FILE_IDS.includes(source) - ) { + if (SB_VIRTUAL_FILE_IDS.includes(source)) { return getResolvedVirtualModuleId(source); } if (source === iframePath) { 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 index 8714d2b28c04..4acf44898243 100644 --- a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts @@ -5,7 +5,7 @@ import type { Plugin } from 'vite'; import { generateProjectAnnotationsCode } from '../codegen-project-annotations'; import { SB_VIRTUAL_FILES, getResolvedVirtualModuleId } from '../virtual-file-names'; -const VIRTUAL_ID = SB_VIRTUAL_FILES.VIRTUAL_PROJECT_ANNOTATIONS_FILE; +const VIRTUAL_ID = 'virtual:/@storybook/builder-vite/project-annotations.js'; const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); /** diff --git a/code/builders/builder-vite/src/virtual-file-names.ts b/code/builders/builder-vite/src/virtual-file-names.ts index 8c3d99e8738c..cf8f319480be 100644 --- a/code/builders/builder-vite/src/virtual-file-names.ts +++ b/code/builders/builder-vite/src/virtual-file-names.ts @@ -2,7 +2,6 @@ export const SB_VIRTUAL_FILES = { VIRTUAL_APP_FILE: 'virtual:/@storybook/builder-vite/vite-app.js', VIRTUAL_STORIES_FILE: 'virtual:/@storybook/builder-vite/storybook-stories.js', VIRTUAL_ADDON_SETUP_FILE: 'virtual:/@storybook/builder-vite/setup-addons.js', - VIRTUAL_PROJECT_ANNOTATIONS_FILE: 'virtual:/@storybook/builder-vite/project-annotations.js', }; export const SB_VIRTUAL_FILE_IDS = Object.values(SB_VIRTUAL_FILES); From e3546afac8226e0bb5a0e8cd014bb6aa7a94b888 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:09:56 +0100 Subject: [PATCH 43/81] Update jsdocs --- .../src/plugins/storybook-config-plugin.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts index 1ba7d8880ac0..37f5b3da20cc 100644 --- a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts @@ -2,15 +2,7 @@ import { isPreservingSymlinks, resolvePathInStorybookCache } from 'storybook/int import type { Plugin } from 'vite'; -/** - * Options for the Storybook config plugin. - * - * This plugin provides the base Storybook-specific Vite configuration, including resolve - * conditions, environment variable prefixes, and filesystem access rules. It is designed to be - * shared between `@storybook/builder-vite` and `@storybook/addon-vitest`. - */ export interface StorybookConfigPluginOptions { - /** The Storybook configuration directory (e.g., '.storybook') */ configDir: string; } @@ -19,8 +11,6 @@ export interface StorybookConfigPluginOptions { * * This handles: * - * - Optionally setting the project root to the parent of the Storybook config directory - * - Optionally configuring the Vite cache directory * - Adding Storybook resolve conditions (`storybook`, `stories`, `test`) * - Setting up environment variable prefixes (`VITE_`, `STORYBOOK_`) * - Allowing the Storybook config directory in Vite's filesystem restrictions From a81220a9ea32195b45be0cdb6989c66646098074 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:15:13 +0100 Subject: [PATCH 44/81] Cleanup docs --- .../src/codegen-project-annotations.ts | 17 +-------- .../src/plugins/storybook-config-plugin.ts | 4 +- .../src/plugins/storybook-docgen-plugin.ts | 7 ---- .../src/plugins/storybook-entry-plugin.ts | 14 ------- .../plugins/storybook-optimize-deps-plugin.ts | 13 +------ .../storybook-project-annotations-plugin.ts | 11 +----- .../src/plugins/storybook-runtime-plugin.ts | 38 ------------------- code/builders/builder-vite/src/preset.ts | 14 ------- 8 files changed, 5 insertions(+), 113 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts index 42c0b030fb8b..05037358c3d0 100644 --- a/code/builders/builder-vite/src/codegen-project-annotations.ts +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -8,22 +8,7 @@ import { dedent } from 'ts-dedent'; import { processPreviewAnnotation } from './utils/process-preview-annotation'; -/** - * Generates the code for the `PROJECT_ANNOTATIONS_FILE` virtual module. - * - * This virtual module encapsulates the `getProjectAnnotations` function which composes all preview - * annotations (from addons, frameworks, and the user's preview file) into a single configuration - * object used by Storybook's runtime. - * - * The generated module can be imported as: - * - * ```ts - * import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; - * ``` - * - * This decouples the project annotations logic from the main iframe entry script, making it - * reusable by other consumers (e.g., addon-vitest). - */ +/** 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); diff --git a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts index 37f5b3da20cc..f5c9a230e068 100644 --- a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts @@ -25,6 +25,8 @@ export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Pl 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([ @@ -40,8 +42,6 @@ export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Pl conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], preserveSymlinks: isPreservingSymlinks(), }, - // 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. envPrefix: mergedEnvPrefix, }; }, diff --git a/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts index 8844dea41646..cc9ae94be512 100644 --- a/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts @@ -7,13 +7,6 @@ import type { Plugin } from 'vite'; /** * A Vite plugin that handles the extraction of component metadata (argTypes, descriptions) for * Storybook's documentation features. - * - * This wraps `@storybook/csf-plugin` and configures it based on Storybook's addon-docs options and - * CSF enrichment settings. The plugin processes CSF (Component Story Format) files to extract - * component metadata that powers Storybook's docs pages and controls. - * - * This plugin is designed to be shared between `@storybook/builder-vite` and - * `@storybook/addon-vitest`. */ export async function storybookDocgenPlugin(options: Options): Promise { const { presets } = options; diff --git a/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts index 45d419d07199..f4c0ba3d38d3 100644 --- a/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts @@ -9,20 +9,6 @@ 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. - * - * This handles: - * - * - Virtual module resolution for story imports, addon setup, and the main app entry - * - Story import function generation (dynamic imports for code splitting) - * - Iframe HTML transformation and build entry configuration - * - Story index watching for HMR invalidation - * - Export order injection (`__namedExportsOrder`) for consistent story discovery - * - HMR boundary stripping to prevent stories from being treated as HMR boundaries - * - * Note: The project annotations virtual module is provided separately by the `viteCorePlugins` - * preset so that it can be shared with addon-vitest. - * - * @returns An array of Vite plugins with appropriate enforcement ordering */ export async function storybookEntryPlugin(options: Options): Promise { return [ 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 index e7fc99c56fba..7702f8e2aa2b 100644 --- a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -5,18 +5,7 @@ import { type Plugin } from 'vite'; import { getUniqueImportPaths } from '../utils/unique-import-paths'; -/** - * A Vite plugin that configures dependency optimization for Storybook's dev server. - * - * This handles: - * - * - Setting optimizeDeps entries from the story index (so Vite knows which stories to pre-bundle) - * - Including known CJS dependencies that need to be pre-compiled to ESM - * - Merging extra optimization dependencies from Storybook presets - * - * This plugin only applies in development mode (`command === 'serve'`). In production builds, - * Rollup handles dependency bundling differently. - */ +/** A Vite plugin that configures dependency optimization for Storybook's dev server. */ export function storybookOptimizeDepsPlugin(options: Options): Plugin { return { name: 'storybook:optimize-deps-plugin', 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 index 4acf44898243..9b6986090f44 100644 --- a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts @@ -3,7 +3,7 @@ import type { Options } from 'storybook/internal/types'; import type { Plugin } from 'vite'; import { generateProjectAnnotationsCode } from '../codegen-project-annotations'; -import { SB_VIRTUAL_FILES, getResolvedVirtualModuleId } from '../virtual-file-names'; +import { getResolvedVirtualModuleId } from '../virtual-file-names'; const VIRTUAL_ID = 'virtual:/@storybook/builder-vite/project-annotations.js'; const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); @@ -11,20 +11,11 @@ const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); /** * A Vite plugin that serves the project annotations virtual module. * - * This plugin handles the `virtual:/@storybook/builder-vite/project-annotations.js` virtual module, - * which exports a `getProjectAnnotations` function that composes all preview annotations (from - * addons, frameworks, and the user's preview file) into a single configuration object. - * * The virtual module can be imported as: * * ```ts * import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; * ``` - * - * This plugin is extracted from the builder-specific code-generator-plugin so that it can be shared - * with `@storybook/addon-vitest` and other consumers that need access to the composed project - * annotations without the full builder entry-point machinery (iframe handling, story index - * watching, etc.). */ export function storybookProjectAnnotationsPlugin(options: Options): Plugin { let projectRoot: string; diff --git a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts index c2f559b892d1..a9ecbe501e0a 100644 --- a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts @@ -7,52 +7,14 @@ import type { Plugin } from 'vite'; import { stringifyProcessEnvs } from '../envs'; import { externalGlobalsPlugin } from './external-globals-plugin'; -/** - * Options for the Storybook runtime plugin. - * - * This plugin injects necessary globals and environment variables for Storybook's runtime. It is - * designed to be shared between `@storybook/builder-vite` and `@storybook/addon-vitest`. - */ export interface StorybookRuntimePluginOptions { - /** - * Map of external module names to their global variable reference names. - * - * Storybook preview modules are pre-bundled and exposed as globals at runtime. This map tells the - * plugin how to transform imports of those modules into destructured global variable references. - * - * @example - * - * ``` - * { "storybook/preview-api": "__STORYBOOK_MODULE_PREVIEW_API__" } - * ``` - */ externals: Record; - - /** - * Pre-resolved environment variables to inject as `import.meta.env.*` defines. - * - * When provided, these are filtered by the resolved `envPrefix` from the Vite config and injected - * into Vite's `define` option. - * - * This allows callers to resolve env vars from their own source (e.g., Storybook presets) and - * pass them in without the plugin needing access to the presets system. - */ envs?: Builder_EnvsRaw; } /** * A composite Vite plugin that injects necessary globals and environment variables for Storybook's * runtime. - * - * This handles: - * - * - Transforming imports of pre-bundled Storybook preview modules to global variable references - * (e.g., `import { useMemo } from 'storybook/preview-api'` becomes `const { useMemo } = - * __STORYBOOK_MODULE_PREVIEW_API__`) - * - Setting up dev-mode aliases for external modules - * - Injecting environment variables as `import.meta.env.*` defines - * - * @returns An array of Vite plugins */ export async function storybookRuntimePlugin(options: Options): Promise { const build = await options.presets.apply('build'); diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index 03b192edbb1e..c30f02695f3c 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -12,20 +12,6 @@ import { viteMockPlugin } from './plugins/vite-mock/plugin'; /** * Preset that provides the core Storybook Vite plugins shared between `@storybook/builder-vite` and * `@storybook/addon-vitest`. - * - * Includes: - * - * - **Config plugin**: Resolve conditions (`storybook`, `stories`, `test`), environment variable - * prefixes (`VITE_`, `STORYBOOK_`), symlink preservation, and `fs.allow` for the config - * directory - * - **Project annotations plugin**: Virtual module serving `getProjectAnnotations` - * - **Docgen plugin**: CSF processing and component metadata extraction - * - **Runtime plugin**: External globals transformation for pre-bundled Storybook modules - * - **Mocking plugins**: Injects the mocker runtime script into the HTML and sets up rules to swap - * modules based on sb.mock() calls. - * - * Consumers can override builder-specific settings (root, base, cacheDir) by adding their own Vite - * plugins on top. */ export async function viteCorePlugins( existing: PluginOption[], From 895cb72e89f838f4450c7fa5a0e2328021cb819d Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:15:48 +0100 Subject: [PATCH 45/81] Cleanup comments --- code/addons/vitest/src/vitest-plugin/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 96f2676645ad..f472b46cc97d 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -207,8 +207,6 @@ export const storybookTest = async (options?: UserOptions): Promise => core, features, ] = await Promise.all([ - // Core Storybook Vite plugins from builder-vite's preset - // (resolve conditions, envPrefix, fs.allow, project annotations, docgen, external globals) presets.apply('viteCorePlugins', []), getStoryGlobsAndFiles(presets, directories), presets.apply('framework', undefined), From 47420f1796db7ecb6878ec4338f9ea4a40ba0df4 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:26:39 +0100 Subject: [PATCH 46/81] Refactor imports and remove unused code in Vitest and Vite builders - Removed unused `readFileSync` import in `preset.ts`. - Simplified imports in `envs.ts` by removing the `Options` type. - Cleaned up imports in `storybook-config-plugin.ts` by removing the unused `resolvePathInStorybookCache` and adjusting the `Plugin` import. --- code/addons/vitest/src/preset.ts | 1 - code/builders/builder-vite/src/envs.ts | 2 +- .../builder-vite/src/plugins/storybook-config-plugin.ts | 5 ++--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index c95d534cd96b..fa08fb128d19 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'; diff --git a/code/builders/builder-vite/src/envs.ts b/code/builders/builder-vite/src/envs.ts index 4946c341a981..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'; diff --git a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts index f5c9a230e068..7152f47f4cf4 100644 --- a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts @@ -1,6 +1,6 @@ -import { isPreservingSymlinks, resolvePathInStorybookCache } from 'storybook/internal/common'; +import { isPreservingSymlinks } from 'storybook/internal/common'; -import type { Plugin } from 'vite'; +import { type Plugin } from 'vite'; export interface StorybookConfigPluginOptions { configDir: string; @@ -37,7 +37,6 @@ export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Pl : ['VITE_', 'STORYBOOK_']; return { - cacheDir: resolvePathInStorybookCache('sb-vite'), resolve: { conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], preserveSymlinks: isPreservingSymlinks(), From 91ab0953ec727746c1877376b4fcec2bd6feadb7 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:30:44 +0100 Subject: [PATCH 47/81] Remove obsole docgen plugin --- .../src/plugins/storybook-docgen-plugin.ts | 25 ------------------- code/builders/builder-vite/src/vite-config.ts | 5 ++-- 2 files changed, 2 insertions(+), 28 deletions(-) delete mode 100644 code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts diff --git a/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts deleted file mode 100644 index cc9ae94be512..000000000000 --- a/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Options } from 'storybook/internal/types'; - -import { vite } from '@storybook/csf-plugin'; - -import type { Plugin } from 'vite'; - -/** - * A Vite plugin that handles the extraction of component metadata (argTypes, descriptions) for - * Storybook's documentation features. - */ -export async function storybookDocgenPlugin(options: Options): Promise { - const { presets } = options; - - const addons = await presets.apply('addons', []); - const docsOptions = - // @ts-expect-error - not sure what type to use here - addons.find((a) => [a, a.name].includes('@storybook/addon-docs'))?.options ?? {}; - - const enrichCsf = await presets.apply('experimental_enrichCsf'); - - return vite({ - ...docsOptions?.csfPluginOptions, - enrichCsf, - }) as Plugin; -} diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 58b00a8cc697..72bcb95f0232 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -11,8 +11,7 @@ import type { InlineConfig as ViteInlineConfig, } from 'vite'; -import { pluginWebpackStats, storybookEntryPlugin } from './plugins'; -import { storybookDocgenPlugin } from './plugins/storybook-docgen-plugin'; +import { csfPlugin, pluginWebpackStats, storybookEntryPlugin } from './plugins'; import { storybookRuntimePlugin } from './plugins/storybook-runtime-plugin'; import { viteCorePlugins as corePlugins } from './preset'; import type { BuilderOptions } from './types'; @@ -75,7 +74,7 @@ export async function pluginConfig(options: Options) { // Shared core plugins (resolve conditions, envPrefix, fs.allow, docgen, externals, etc.) ...(await corePlugins([], options)), ...(await storybookRuntimePlugin(options)), - await storybookDocgenPlugin(options), + await csfPlugin(options), // Builder-specific: root, base, and cacheDir { name: 'storybook:builder-vite-config', From 606bdac2b526cb3009d92c40a3ad3357fa20b050 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 14:33:02 +0100 Subject: [PATCH 48/81] Refactor modern iframe script generation and enhance project annotations plugin - Simplified the `generateModernIframeScriptCode` function by removing unused parameters and code related to preview annotations. - Updated the `generateModernIframeScriptCodeFromPreviews` function to utilize the new `getProjectAnnotations` import. - Enhanced the `storybookProjectAnnotationsPlugin` to include HMR support for project annotations, allowing for dynamic updates during development. --- .../src/codegen-modern-iframe-script.ts | 84 ++----------------- .../storybook-project-annotations-plugin.ts | 16 +++- 2 files changed, 21 insertions(+), 79 deletions(-) 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..c163830b4fe9 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 { RESOLVED_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(); }; @@ -125,10 +60,8 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; import { isPreview } from 'storybook/internal/csf'; import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}'; - - ${options.isCsf4 ? previewFileImport : imports.join('\n')} - ${getPreviewAnnotationsFunction} - + import { getProjectAnnotations } from '${RESOLVED_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 +71,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/plugins/storybook-project-annotations-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts index 9b6986090f44..3824e2c683b3 100644 --- a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts @@ -6,7 +6,7 @@ import { generateProjectAnnotationsCode } from '../codegen-project-annotations'; import { getResolvedVirtualModuleId } from '../virtual-file-names'; const VIRTUAL_ID = 'virtual:/@storybook/builder-vite/project-annotations.js'; -const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); +export const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); /** * A Vite plugin that serves the project annotations virtual module. @@ -33,7 +33,19 @@ export function storybookProjectAnnotationsPlugin(options: Options): Plugin { }, async load(id) { if (id === RESOLVED_VIRTUAL_ID) { - return generateProjectAnnotationsCode(options, projectRoot); + const code = await generateProjectAnnotationsCode(options, projectRoot); + + const hmrCode = [ + 'if (import.meta.hot) {', + ' import.meta.hot.accept((newModule) => {', + ' window.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({', + ' getProjectAnnotations: newModule.getProjectAnnotations,', + ' });', + ' });', + '}', + ].join('\n'); + + return `${code}\n\n${hmrCode}`; } }, }; From 2d0adaaffbc7f4f085c1b232db6f48292c09c9bc Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 14:46:13 +0100 Subject: [PATCH 49/81] Refactor project annotations handling in Vite builder - Renamed the `RESOLVED_VIRTUAL_ID` constant to `PROJECT_ANNOTATIONS_VIRTUAL_ID` for clarity. - Updated import statements in `codegen-modern-iframe-script.ts` to reflect the new naming. - Adjusted the order of plugin inclusion in `preset.ts` to ensure `storybookProjectAnnotationsPlugin` is added correctly. - Cleaned up the `storybook-project-annotations-plugin.ts` by reordering the declaration of `VIRTUAL_ID` and `RESOLVED_VIRTUAL_ID` for better readability. --- .../src/codegen-modern-iframe-script.ts | 4 ++-- .../src/codegen-project-annotations.ts | 12 ++++++++++++ .../storybook-project-annotations-plugin.ts | 18 +++--------------- code/builders/builder-vite/src/preset.ts | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) 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 c163830b4fe9..50f1295bbd7d 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -4,7 +4,7 @@ import type { Options } from 'storybook/internal/types'; import { dedent } from 'ts-dedent'; -import { RESOLVED_VIRTUAL_ID } from './plugins/storybook-project-annotations-plugin'; +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) { @@ -60,7 +60,7 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; import { isPreview } from 'storybook/internal/csf'; import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}'; - import { getProjectAnnotations } from '${RESOLVED_VIRTUAL_ID}'; + import { getProjectAnnotations } from '${PROJECT_ANNOTATIONS_VIRTUAL_ID}'; window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts index 05037358c3d0..7d5e12706fd1 100644 --- a/code/builders/builder-vite/src/codegen-project-annotations.ts +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -66,6 +66,16 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { `.trim(); } + const hmrCode = [ + 'if (import.meta.hot) {', + ' import.meta.hot.accept((newModule) => {', + ' window.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({', + ' getProjectAnnotations: newModule.getProjectAnnotations,', + ' });', + ' });', + '}', + ].join('\n'); + return dedent` import { composeConfigs } from 'storybook/preview-api'; @@ -74,6 +84,8 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { export function getProjectAnnotations() { return composeConfigs([${variables.join(', ')}]); } + + ${hmrCode} `.trim(); } 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 index 3824e2c683b3..beb879493f64 100644 --- a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts @@ -5,8 +5,8 @@ import type { Plugin } from 'vite'; import { generateProjectAnnotationsCode } from '../codegen-project-annotations'; import { getResolvedVirtualModuleId } from '../virtual-file-names'; -const VIRTUAL_ID = 'virtual:/@storybook/builder-vite/project-annotations.js'; -export const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); +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. @@ -33,19 +33,7 @@ export function storybookProjectAnnotationsPlugin(options: Options): Plugin { }, async load(id) { if (id === RESOLVED_VIRTUAL_ID) { - const code = await generateProjectAnnotationsCode(options, projectRoot); - - const hmrCode = [ - 'if (import.meta.hot) {', - ' import.meta.hot.accept((newModule) => {', - ' window.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({', - ' getProjectAnnotations: newModule.getProjectAnnotations,', - ' });', - ' });', - '}', - ].join('\n'); - - return `${code}\n\n${hmrCode}`; + return generateProjectAnnotationsCode(options, projectRoot); } }, }; diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index c30f02695f3c..0df1412bcae5 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -20,9 +20,9 @@ export async function viteCorePlugins( const previewConfigPath = findConfigFile('preview', options.configDir); return [ + storybookProjectAnnotationsPlugin(options), ...storybookConfigPlugin({ configDir: options.configDir }), storybookOptimizeDepsPlugin(options), - storybookProjectAnnotationsPlugin(options), ...(previewConfigPath ? [ viteInjectMockerRuntime({ previewConfigPath }), From b500e3a0ef65e213dcf5865fef294f93fe3a9c80 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 14:55:13 +0100 Subject: [PATCH 50/81] Cleanup --- code/builders/builder-vite/src/preset.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index 0df1412bcae5..9d913eb6134a 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -14,7 +14,7 @@ import { viteMockPlugin } from './plugins/vite-mock/plugin'; * `@storybook/addon-vitest`. */ export async function viteCorePlugins( - existing: PluginOption[], + _: PluginOption[], options: Options ): Promise { const previewConfigPath = findConfigFile('preview', options.configDir); From aa9a68823db3976fee37520ff7185aa2674c479b Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 13 Feb 2026 09:35:51 +0100 Subject: [PATCH 51/81] Enhance storybookOptimizeDepsPlugin to support additional entry points - Updated the `entries` property in the `storybookOptimizeDepsPlugin` to allow for both string and array formats, improving flexibility in dependency optimization. --- .../src/plugins/storybook-optimize-deps-plugin.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index 7702f8e2aa2b..ccbd5e4a9a4e 100644 --- a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -25,7 +25,12 @@ export function storybookOptimizeDepsPlugin(options: Options): Plugin { return { optimizeDeps: { // Story file paths as entry points for the optimizer - entries: getUniqueImportPaths(index), + 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 || [])], From 8331c0cf34abce18695f16365a19d5a79ba0035e Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 17 Feb 2026 10:30:08 +0100 Subject: [PATCH 52/81] remove unused preview entry from addon-vitest --- code/addons/vitest/build-config.ts | 4 --- code/addons/vitest/package.json | 5 ---- code/addons/vitest/preview.js | 1 - code/addons/vitest/src/preview.ts | 41 ------------------------------ 4 files changed, 51 deletions(-) delete mode 100644 code/addons/vitest/preview.js delete mode 100644 code/addons/vitest/src/preview.ts diff --git a/code/addons/vitest/build-config.ts b/code/addons/vitest/build-config.ts index 276d1c8d01a6..9e5f242e6329 100644 --- a/code/addons/vitest/build-config.ts +++ b/code/addons/vitest/build-config.ts @@ -12,10 +12,6 @@ const config: BuildEntries = { entryPoint: './src/manager.tsx', dts: false, }, - { - exportEntries: ['./preview'], - entryPoint: './src/preview.ts', - }, { exportEntries: ['./internal/setup-file'], entryPoint: './src/vitest-plugin/setup-file.ts', diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json index 528a2763b77c..0825c5472085 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -56,11 +56,6 @@ "./package.json": "./package.json", "./postinstall": "./dist/postinstall.js", "./preset": "./dist/preset.js", - "./preview": { - "types": "./dist/preview.d.ts", - "code": "./src/preview.ts", - "default": "./dist/preview.js" - }, "./vitest": "./dist/node/vitest.js", "./vitest-plugin": { "types": "./dist/vitest-plugin/index.d.ts", diff --git a/code/addons/vitest/preview.js b/code/addons/vitest/preview.js deleted file mode 100644 index 542d45f8cf26..000000000000 --- a/code/addons/vitest/preview.js +++ /dev/null @@ -1 +0,0 @@ -export * from './dist/preview.js'; diff --git a/code/addons/vitest/src/preview.ts b/code/addons/vitest/src/preview.ts deleted file mode 100644 index 69cba22a0dc9..000000000000 --- a/code/addons/vitest/src/preview.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { StoryAnnotations } from 'storybook/internal/types'; - -console.log('addon vitest preview!'); - -// TODO: maybe this doesn't have to be an explicit preview annotation, but can be automatically added into vitest annotations somehow - -const preview: StoryAnnotations = { - afterEach: async ({ title, name, canvasElement, reporting }) => { - if (!(globalThis as any).__vitest_browser__) { - return; - } - //TODO: toggle this on an off based on something, probably like a11y - try { - console.log(`Taking screenshot for "${name}"`); - const { page } = await import('@vitest/browser/context'); - - const base64 = await page.screenshot({ - path: `screenshots/${title}/${name}.png`, - base64: true, - element: canvasElement.firstChild, - }); - - reporting.addReport({ - type: 'screenshot', - version: 1, - result: base64, - status: 'passed', - }); - } catch (error) { - console.error('Error taking screenshot', error); - reporting.addReport({ - type: 'screenshot', - version: 1, - result: error, - status: 'failed', - }); - } - }, -}; - -export default preview; From 1947630e90151c24ad56a59b8acb5c5aa38263b3 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 17 Feb 2026 13:22:56 +0100 Subject: [PATCH 53/81] fix tests and type checks --- .../vitest/src/node/test-manager.test.ts | 101 +++++++++--------- code/addons/vitest/src/node/vitest-manager.ts | 9 +- 2 files changed, 56 insertions(+), 54 deletions(-) diff --git a/code/addons/vitest/src/node/test-manager.test.ts b/code/addons/vitest/src/node/test-manager.test.ts index 3a5668e645f3..c0634a94a30f 100644 --- a/code/addons/vitest/src/node/test-manager.test.ts +++ b/code/addons/vitest/src/node/test-manager.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { Channel, type ChannelTransport } from 'storybook/internal/channels'; import { Tag, experimental_MockUniversalStore } from 'storybook/internal/core-server'; import type { Options, @@ -52,16 +51,60 @@ 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], + }, + '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 +145,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, diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts index 2406feb19a46..aaac0307454a 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -221,7 +221,14 @@ export class VitestManager { private getStories(requestStoryIds?: string[]): StoryIndexEntry[] { const index = this.testManager.store.getState().index; if (requestStoryIds) { - return requestStoryIds.map((id) => index.entries[id]) as StoryIndexEntry[]; + const stories: StoryIndexEntry[] = []; + for (const id of requestStoryIds) { + const entry = index.entries[id]; + if (entry?.type === 'story') { + stories.push(entry); + } + } + return stories; } return Object.values(index.entries).filter((entry) => entry.type === 'story'); } From 82421a98e737f321ca19bc1d15d4cf783f20a27f Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 17 Feb 2026 14:26:28 +0100 Subject: [PATCH 54/81] fix channel type issues --- code/core/src/cli/buildIndex.ts | 2 +- code/core/src/core-server/build-dev.ts | 6 +++--- code/core/src/core-server/build-static.ts | 11 +++++------ code/core/src/core-server/load.ts | 6 +++--- code/core/src/core-server/utils/doTelemetry.ts | 6 +++++- code/core/src/core-server/utils/whats-new.ts | 12 ++++++++++-- 6 files changed, 27 insertions(+), 16 deletions(-) diff --git a/code/core/src/cli/buildIndex.ts b/code/core/src/cli/buildIndex.ts index bc7dbf7722fe..7cb6cd5a7ce3 100644 --- a/code/core/src/cli/buildIndex.ts +++ b/code/core/src/cli/buildIndex.ts @@ -25,6 +25,6 @@ export const buildIndex = async ( corePresets: [], overridePresets: [], channel: new Channel({}), - }; + } as unknown as Parameters[1]['presetOptions']; await withTelemetry('index', { cliOptions, presetOptions }, () => buildIndexStandalone(options)); }; diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 22c32cae18f2..96612364ddcb 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -157,7 +157,7 @@ export async function buildDevStandalone( ], ...options, isCritical: true, - channel, + channel: channel as unknown as Parameters[0]['channel'], }); const { renderer, builder, disableTelemetry } = await presets.apply('core', {}); @@ -215,7 +215,7 @@ export async function buildDevStandalone( import.meta.resolve('storybook/internal/core-server/presets/common-override-preset'), ], ...options, - channel, + channel: channel as unknown as Parameters[0]['channel'], }); const features = await presets.apply('features'); @@ -226,7 +226,7 @@ export async function buildDevStandalone( ...options, presets, features, - channel, + channel: channel as unknown as Options['channel'], }; const { address, networkAddress, managerResult, previewResult } = await buildOrThrow(async () => diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 951423cfca37..7dd0eca34b25 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'; @@ -17,7 +17,6 @@ import { global } from '@storybook/global'; import { join, relative, resolve } from 'pathe'; import picocolors from 'picocolors'; -import Channel from '../channels'; import { resolvePackageDir } from '../shared/utils/module'; import type { StoryIndexGenerator } from './utils/StoryIndexGenerator'; import { buildOrThrow } from './utils/build-or-throw'; @@ -76,7 +75,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption corePresets: [commonPreset, ...corePresets], overridePresets: [commonOverridePreset], isCritical: true, - channel, + channel: channel as unknown as Parameters[0]['channel'], ...options, }); @@ -86,7 +85,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption ...options, presets, build, - channel, + channel: channel as unknown as Options['channel'], }); const resolvedRenderer = renderer @@ -102,7 +101,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption ], overridePresets: [...(previewBuilder.overridePresets || []), commonOverridePreset], build, - channel, + channel: channel as unknown as Parameters[0]['channel'], ...options, }); @@ -121,7 +120,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const fullOptions: Options = { ...options, - channel, + channel: channel as unknown as Options['channel'], presets, features, build, diff --git a/code/core/src/core-server/load.ts b/code/core/src/core-server/load.ts index b5a4c2361e1c..975775f21a0a 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, @@ -12,7 +13,6 @@ import { global } from '@storybook/global'; import { dirname, join, relative, resolve } from 'pathe'; -import { Channel } from '../channels'; import { resolvePackageDir } from '../shared/utils/module'; export async function loadStorybook( @@ -59,7 +59,7 @@ export async function loadStorybook( ], ...options, isCritical: true, - channel, + channel: channel as unknown as Parameters[0]['channel'], }); const { renderer, builder } = await presets.apply('core', {}); @@ -82,7 +82,7 @@ export async function loadStorybook( overridePresets: [ import.meta.resolve('storybook/internal/core-server/presets/common-override-preset'), ], - channel, + channel: channel as unknown as Parameters[0]['channel'], ...options, }); diff --git a/code/core/src/core-server/utils/doTelemetry.ts b/code/core/src/core-server/utils/doTelemetry.ts index 2e6464dcfb96..7e75e383f702 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: [], + } as unknown as Parameters[2]['presetOptions'], }); return; } diff --git a/code/core/src/core-server/utils/whats-new.ts b/code/core/src/core-server/utils/whats-new.ts index c63bf60086cc..fc1ec9f40b45 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: [], + } as unknown as Parameters[2]['presetOptions'], 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: [], + } as unknown as Parameters[2]['presetOptions'], skipPrompt: true, }); } From aaa74100052f891ab44596e0a90cbced698d8a12 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 18 Feb 2026 12:49:45 +0100 Subject: [PATCH 55/81] improve test filtering logic to support running focused tests accross multiple story files --- .../vitest/src/node/test-manager.test.ts | 124 ++++++++++++++++++ code/addons/vitest/src/node/test-manager.ts | 44 +++++-- code/addons/vitest/src/node/vitest-manager.ts | 111 +++++++++++----- 3 files changed, 234 insertions(+), 45 deletions(-) diff --git a/code/addons/vitest/src/node/test-manager.test.ts b/code/addons/vitest/src/node/test-manager.test.ts index c0634a94a30f..736e688ab6fc 100644 --- a/code/addons/vitest/src/node/test-manager.test.ts +++ b/code/addons/vitest/src/node/test-manager.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TestResult } from 'vitest/node'; import { Tag, experimental_MockUniversalStore } from 'storybook/internal/core-server'; import type { @@ -76,6 +77,24 @@ const mockIndex = { 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', @@ -257,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 9e49216303a5..7f11a90f2599 100644 --- a/code/addons/vitest/src/node/test-manager.ts +++ b/code/addons/vitest/src/node/test-manager.ts @@ -179,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. * @@ -286,18 +302,22 @@ export class TestManager { 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 aaac0307454a..018165da983c 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -14,6 +14,7 @@ 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); @@ -233,6 +234,78 @@ export class VitestManager { 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( testSpecifications: TestSpecification[], stories: StoryIndexEntry[] @@ -315,39 +388,11 @@ export class VitestManager { ? 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( From 8a97a8ef9b5f4ecbb229011e9a5308dba61df883 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 19 Feb 2026 16:36:36 +0700 Subject: [PATCH 56/81] Gate nx workflow to storybookjs/storybook to prevent runs on forks Schedule and push events in the nx workflow were not gated to the main repository, causing unnecessary CI runs on forks. --- .github/workflows/nx.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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: From 8af759954a87c6b95a63100c8e550fd3dfda60fc Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 19 Feb 2026 12:05:27 +0100 Subject: [PATCH 57/81] Add storybookExternalGlobalsPlugin and related tests; refactor runtime plugin integration --- .../builder-vite/src/plugins/index.ts | 2 +- ...storybook-external-globals-plugin.test.ts} | 2 +- ...s => storybook-external-globals-plugin.ts} | 16 ++++++++++++++- .../src/plugins/storybook-runtime-plugin.ts | 20 +++---------------- code/builders/builder-vite/src/preset.ts | 2 ++ code/builders/builder-vite/src/vite-config.ts | 12 +++++++---- 6 files changed, 30 insertions(+), 24 deletions(-) rename code/builders/builder-vite/src/plugins/{external-globals-plugin.test.ts => storybook-external-globals-plugin.test.ts} (95%) rename code/builders/builder-vite/src/plugins/{external-globals-plugin.ts => storybook-external-globals-plugin.ts} (88%) diff --git a/code/builders/builder-vite/src/plugins/index.ts b/code/builders/builder-vite/src/plugins/index.ts index ef9336b8036c..078886a86b4a 100644 --- a/code/builders/builder-vite/src/plugins/index.ts +++ b/code/builders/builder-vite/src/plugins/index.ts @@ -9,4 +9,4 @@ 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 { externalGlobalsPlugin, rewriteImport } from './external-globals-plugin'; +export { storybookExternalGlobalsPlugin } from './storybook-external-globals-plugin'; 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 88% 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..bcc4459dfccc 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,11 +2,15 @@ import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; +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'; import type { Alias, Plugin } from 'vite'; +import { globalsNameReferenceMap } from '../../../../core/src/manager/globals/globals'; + const escapeKeys = (key: string) => key.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); const defaultImportRegExp = 'import ([^*{}]+) from'; const replacementMap = new Map([ @@ -38,7 +42,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-runtime-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts index a9ecbe501e0a..a60d66eace7e 100644 --- a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts @@ -1,32 +1,18 @@ -import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; import type { Builder_EnvsRaw } from 'storybook/internal/types'; import type { Options } from 'storybook/internal/types'; import type { Plugin } from 'vite'; import { stringifyProcessEnvs } from '../envs'; -import { externalGlobalsPlugin } from './external-globals-plugin'; export interface StorybookRuntimePluginOptions { externals: Record; envs?: Builder_EnvsRaw; } -/** - * A composite Vite plugin that injects necessary globals and environment variables for Storybook's - * runtime. - */ -export async function storybookRuntimePlugin(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__'; - } - - const plugins: Plugin[] = [await externalGlobalsPlugin(externals)]; +/** 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) { diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index 9d913eb6134a..0f33305a8c20 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -6,6 +6,7 @@ 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'; @@ -23,6 +24,7 @@ export async function viteCorePlugins( storybookProjectAnnotationsPlugin(options), ...storybookConfigPlugin({ configDir: options.configDir }), storybookOptimizeDepsPlugin(options), + ...(await storybookSanitizeEnvs(options)), ...(previewConfigPath ? [ viteInjectMockerRuntime({ previewConfigPath }), diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 72bcb95f0232..8a8b833962c0 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -11,8 +11,12 @@ import type { InlineConfig as ViteInlineConfig, } from 'vite'; -import { csfPlugin, pluginWebpackStats, storybookEntryPlugin } from './plugins'; -import { storybookRuntimePlugin } from './plugins/storybook-runtime-plugin'; +import { + csfPlugin, + pluginWebpackStats, + storybookEntryPlugin, + storybookExternalGlobalsPlugin, +} from './plugins'; import { viteCorePlugins as corePlugins } from './preset'; import type { BuilderOptions } from './types'; @@ -71,9 +75,9 @@ export async function pluginConfig(options: Options) { const projectRoot = resolve(options.configDir, '..'); const plugins = [ - // Shared core plugins (resolve conditions, envPrefix, fs.allow, docgen, externals, etc.) + // Shared core plugins (resolve conditions, envPrefix, fs.allow, externals, env vars, etc.) ...(await corePlugins([], options)), - ...(await storybookRuntimePlugin(options)), + await storybookExternalGlobalsPlugin(options), await csfPlugin(options), // Builder-specific: root, base, and cacheDir { From fa82d19ae452480b337359ff46fe8116057560d9 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 19 Feb 2026 12:12:53 +0100 Subject: [PATCH 58/81] Enhance project annotations handling with HMR support and refactor getProjectAnnotations function --- .../src/codegen-project-annotations.ts | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts index 7d5e12706fd1..9d13c9390ec9 100644 --- a/code/builders/builder-vite/src/codegen-project-annotations.ts +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -2,7 +2,7 @@ import { getFrameworkName, loadPreviewOrConfigFile } from 'storybook/internal/co import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools'; import type { Options, PreviewAnnotation } from 'storybook/internal/types'; -import { genImport, genSafeVariableName } from 'knitwork'; +import { genArrayFromRaw, genImport, genSafeVariableName } from 'knitwork'; import { filename } from 'pathe/utils'; import { dedent } from 'ts-dedent'; @@ -53,6 +53,7 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { 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]; @@ -60,32 +61,47 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { return dedent` ${previewFileImport} - export function getProjectAnnotations() { - return ${previewFileVariable}.default.composed; + 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(); } - const hmrCode = [ - 'if (import.meta.hot) {', - ' import.meta.hot.accept((newModule) => {', - ' window.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({', - ' getProjectAnnotations: newModule.getProjectAnnotations,', - ' });', - ' });', - '}', - ].join('\n'); - return dedent` import { composeConfigs } from 'storybook/preview-api'; ${imports.join('\n')} - export function getProjectAnnotations() { - return composeConfigs([${variables.join(', ')}]); + 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); } - ${hmrCode} + 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(); } From 8117c38aed9273ad5de15e08bc124dfcbc1b3f03 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 19 Feb 2026 12:13:41 +0100 Subject: [PATCH 59/81] Cleanup --- code/builders/builder-vite/src/codegen-modern-iframe-script.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 50f1295bbd7d..e8a615db5b59 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -57,8 +57,7 @@ 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}'; import { getProjectAnnotations } from '${PROJECT_ANNOTATIONS_VIRTUAL_ID}'; From d955fd45d4bff666d108ceca0b1ec2e35acb6cda Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 19 Feb 2026 19:22:53 +0700 Subject: [PATCH 60/81] Replace Channel class with ChannelLike interface in type declarations The Channel class has a private `sender` field which causes TypeScript nominal incompatibility between source and dist declarations. This introduces a ChannelLike structural interface and uses it in Options, loadAllPresets, and Builder types, eliminating all `as unknown as` channel casts. --- code/core/src/channels/index.ts | 2 +- code/core/src/channels/main.ts | 3 ++- code/core/src/channels/types.ts | 19 +++++++++++++++++++ code/core/src/common/presets.ts | 4 ++-- code/core/src/core-server/build-dev.ts | 6 +++--- code/core/src/core-server/build-static.ts | 8 ++++---- code/core/src/core-server/load.ts | 4 ++-- .../core/src/core-server/utils/doTelemetry.ts | 2 +- code/core/src/core-server/utils/index-json.ts | 4 ++-- code/core/src/core-server/utils/whats-new.ts | 4 ++-- code/core/src/types/modules/core-common.ts | 6 +++--- 11 files changed, 41 insertions(+), 21 deletions(-) diff --git a/code/core/src/channels/index.ts b/code/core/src/channels/index.ts index 988b1bdb797c..55f19d42708a 100644 --- a/code/core/src/channels/index.ts +++ b/code/core/src/channels/index.ts @@ -49,4 +49,4 @@ 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..31deb459733c 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/common/presets.ts b/code/core/src/common/presets.ts index 9303c67da3ef..38298c4b22a3 100644 --- a/code/core/src/common/presets.ts +++ b/code/core/src/common/presets.ts @@ -15,7 +15,7 @@ import type { import { join, parse, resolve } from 'pathe'; import { dedent } from 'ts-dedent'; -import type { Channel } from '../channels'; +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'; @@ -336,7 +336,7 @@ export async function loadAllPresets( /** Whether preset failures should be critical or not */ isCritical?: boolean; build?: StorybookConfigRaw['build']; - channel: Channel; + channel: ChannelLike; } ) { const { corePresets = [], overridePresets = [], ...restOptions } = options; diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 96612364ddcb..22c32cae18f2 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -157,7 +157,7 @@ export async function buildDevStandalone( ], ...options, isCritical: true, - channel: channel as unknown as Parameters[0]['channel'], + channel, }); const { renderer, builder, disableTelemetry } = await presets.apply('core', {}); @@ -215,7 +215,7 @@ export async function buildDevStandalone( import.meta.resolve('storybook/internal/core-server/presets/common-override-preset'), ], ...options, - channel: channel as unknown as Parameters[0]['channel'], + channel, }); const features = await presets.apply('features'); @@ -226,7 +226,7 @@ export async function buildDevStandalone( ...options, presets, features, - channel: channel as unknown as Options['channel'], + channel, }; const { address, networkAddress, managerResult, previewResult } = await buildOrThrow(async () => diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 7dd0eca34b25..8feef56617e4 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -75,7 +75,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption corePresets: [commonPreset, ...corePresets], overridePresets: [commonOverridePreset], isCritical: true, - channel: channel as unknown as Parameters[0]['channel'], + channel, ...options, }); @@ -85,7 +85,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption ...options, presets, build, - channel: channel as unknown as Options['channel'], + channel, }); const resolvedRenderer = renderer @@ -101,7 +101,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption ], overridePresets: [...(previewBuilder.overridePresets || []), commonOverridePreset], build, - channel: channel as unknown as Parameters[0]['channel'], + channel, ...options, }); @@ -120,7 +120,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const fullOptions: Options = { ...options, - channel: channel as unknown as Options['channel'], + channel, presets, features, build, diff --git a/code/core/src/core-server/load.ts b/code/core/src/core-server/load.ts index 975775f21a0a..0c0d716dce37 100644 --- a/code/core/src/core-server/load.ts +++ b/code/core/src/core-server/load.ts @@ -59,7 +59,7 @@ export async function loadStorybook( ], ...options, isCritical: true, - channel: channel as unknown as Parameters[0]['channel'], + channel, }); const { renderer, builder } = await presets.apply('core', {}); @@ -82,7 +82,7 @@ export async function loadStorybook( overridePresets: [ import.meta.resolve('storybook/internal/core-server/presets/common-override-preset'), ], - channel: channel as unknown as Parameters[0]['channel'], + channel, ...options, }); diff --git a/code/core/src/core-server/utils/doTelemetry.ts b/code/core/src/core-server/utils/doTelemetry.ts index 7e75e383f702..447a96a6962c 100644 --- a/code/core/src/core-server/utils/doTelemetry.ts +++ b/code/core/src/core-server/utils/doTelemetry.ts @@ -32,7 +32,7 @@ export async function doTelemetry( ...options, corePresets: [], overridePresets: [], - } as unknown as Parameters[2]['presetOptions'], + }, }); return; } diff --git a/code/core/src/core-server/utils/index-json.ts b/code/core/src/core-server/utils/index-json.ts index 60e7b9fdb2a3..c5fd3dcf4a3d 100644 --- a/code/core/src/core-server/utils/index-json.ts +++ b/code/core/src/core-server/utils/index-json.ts @@ -8,7 +8,7 @@ import { debounce } from 'es-toolkit/function'; import type { Polka } from 'polka'; import type { StoryIndexGenerator } from './StoryIndexGenerator'; -import type { ServerChannel } from './get-server-channel'; +import type { ChannelLike } from 'storybook/internal/channels'; import { watchStorySpecifiers } from './watch-story-specifiers'; import { watchConfig } from './watchConfig'; @@ -33,7 +33,7 @@ export function registerIndexJsonRoute({ }: { app: Polka; storyIndexGeneratorPromise: Promise; - channel: ServerChannel; + channel: ChannelLike; workingDir?: string; configDir?: string; normalizedStories: NormalizedStoriesSpecifier[]; diff --git a/code/core/src/core-server/utils/whats-new.ts b/code/core/src/core-server/utils/whats-new.ts index fc1ec9f40b45..b9ab06edd29e 100644 --- a/code/core/src/core-server/utils/whats-new.ts +++ b/code/core/src/core-server/utils/whats-new.ts @@ -105,7 +105,7 @@ export function initializeWhatsNew( ...options, corePresets: [], overridePresets: [], - } as unknown as Parameters[2]['presetOptions'], + }, skipPrompt: true, }); } @@ -123,7 +123,7 @@ export function initializeWhatsNew( ...options, corePresets: [], overridePresets: [], - } as unknown as Parameters[2]['presetOptions'], + }, skipPrompt: true, }); } diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 2347389e93e5..25bbc42efc99 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -1,5 +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 { Channel } from 'storybook/internal/channels'; +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'; @@ -218,7 +218,7 @@ export interface BuilderOptions { export interface StorybookConfigOptions { presets: Presets; presetsList?: LoadedPreset[]; - channel: Channel; + channel: ChannelLike; } export type Options = LoadOptions & @@ -257,7 +257,7 @@ export interface Builder { startTime: ReturnType; router: ServerApp; server: HttpServer; - channel: Channel; + channel: ChannelLike; }) => Promise; From 0a258bfe2c59b204b849efb02b73f9e0ddc832e0 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 19 Feb 2026 19:43:54 +0700 Subject: [PATCH 61/81] Fix prettier formatting for ChannelLike changes --- code/core/src/channels/index.ts | 8 +++++++- code/core/src/channels/types.ts | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/code/core/src/channels/index.ts b/code/core/src/channels/index.ts index 55f19d42708a..879dbad40931 100644 --- a/code/core/src/channels/index.ts +++ b/code/core/src/channels/index.ts @@ -49,4 +49,10 @@ export function createBrowserChannel({ page, extraTransports = [] }: Options): C return channel; } -export type { Listener, ChannelEvent, ChannelTransport, ChannelHandler, ChannelLike } from './types'; +export type { + Listener, + ChannelEvent, + ChannelTransport, + ChannelHandler, + ChannelLike, +} from './types'; diff --git a/code/core/src/channels/types.ts b/code/core/src/channels/types.ts index 31deb459733c..49ba640a0665 100644 --- a/code/core/src/channels/types.ts +++ b/code/core/src/channels/types.ts @@ -30,8 +30,8 @@ export interface EventsKeyValue { } /** - * Structural interface for Channel, used in type declarations to avoid - * nominal incompatibility between source and dist Channel class declarations. + * 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; From f645c85b0330f2d4d9676cf3e6228309c762c8f2 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 19 Feb 2026 20:26:55 +0700 Subject: [PATCH 62/81] Fix import ordering in index-json.ts --- code/core/src/core-server/utils/index-json.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/core-server/utils/index-json.ts b/code/core/src/core-server/utils/index-json.ts index c5fd3dcf4a3d..98d69ba41366 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 { ChannelLike } from 'storybook/internal/channels'; import { watchStorySpecifiers } from './watch-story-specifiers'; import { watchConfig } from './watchConfig'; From 6918d49be3859ce7b22d2aba2f67c4b6b3711e44 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 19 Feb 2026 14:27:32 +0100 Subject: [PATCH 63/81] Fix linting --- .../src/codegen-modern-iframe-script.test.ts | 88 +++---------------- .../src/plugins/code-generator-plugin.ts | 4 +- 2 files changed, 13 insertions(+), 79 deletions(-) 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/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index a2ddef103eab..50a163e9ff9c 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -20,7 +20,6 @@ import { 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) { } }, configResolved(config) { - projectRoot = config.root; iframeId = `${config.root}/iframe.html`; }, resolveId(source) { @@ -78,7 +76,7 @@ export function codeGeneratorPlugin(options: Options) { return generateAddonSetupCode(); } case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE): { - return generateModernIframeScriptCode(options, projectRoot); + return generateModernIframeScriptCode(options); } case iframeId: { return readFileSync( From 554a95163915cceaebff1e119ba553011debf2b5 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 19 Feb 2026 14:32:28 +0100 Subject: [PATCH 64/81] Fix wrong path --- .../src/plugins/storybook-external-globals-plugin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts index bcc4459dfccc..54bb15a28c96 100644 --- a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts @@ -2,6 +2,7 @@ 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'; @@ -9,8 +10,6 @@ import { init, parse } from 'es-module-lexer'; import MagicString from 'magic-string'; import type { Alias, Plugin } from 'vite'; -import { globalsNameReferenceMap } from '../../../../core/src/manager/globals/globals'; - const escapeKeys = (key: string) => key.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); const defaultImportRegExp = 'import ([^*{}]+) from'; const replacementMap = new Map([ From 6481e73282e3520a2d298fcc9e0040031d5f79a6 Mon Sep 17 00:00:00 2001 From: gayanMatch Date: Fri, 9 Jan 2026 09:23:33 -0800 Subject: [PATCH 65/81] feat: added aria-label to popver and aria-haspop to dialog --- MIGRATION.md | 14 ++++++++ .../blocks/components/ArgsTable/ArgValue.tsx | 1 + .../addons/docs/src/blocks/controls/Color.tsx | 1 + .../Popover/PopoverProvider.stories.tsx | 1 + .../components/Popover/PopoverProvider.tsx | 33 +++++++++++++++++-- .../components/components/Tabs/Tabs.hooks.tsx | 1 + .../tooltip/TooltipMessage.stories.tsx | 2 +- .../components/preview/tools/share.tsx | 1 + .../components/sidebar/ChecklistWidget.tsx | 1 + .../components/sidebar/ContextMenu.tsx | 1 + .../src/manager/components/sidebar/Menu.tsx | 1 + .../manager/components/sidebar/RefBlocks.tsx | 1 + .../components/sidebar/RefIndicator.tsx | 1 + .../manager/components/sidebar/TagsFilter.tsx | 2 +- .../src/manager/container/Menu.stories.tsx | 2 +- 15 files changed, 57 insertions(+), 6 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 0805c3682b8b..7baf30894167 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -627,6 +627,20 @@ 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 to provide an accessible label for the popover dialog. This label is announced by screen readers when the popover opens. **This prop will become mandatory in Storybook 11.** Provide a concise description of the popover's purpose. + +```tsx +}> + + +``` + +##### Automatic aria-haspopup + +PopoverProvider now automatically sets `aria-haspopup="dialog"` on the trigger element. You no longer need to manually add this attribute to your trigger buttons. + #### 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/docs/src/blocks/components/ArgsTable/ArgValue.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx index 1be3d2075744..b8a313d3f9c1 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/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..946cf3cdcc1a 100644 --- a/code/core/src/components/components/Popover/PopoverProvider.tsx +++ b/code/core/src/components/components/Popover/PopoverProvider.tsx @@ -1,5 +1,8 @@ import type { DOMAttributes, ReactElement, ReactNode } from 'react'; -import React, { useCallback, useState } from 'react'; +import React, { cloneElement, useCallback, useState } from 'react'; +import type { FocusableElement } from '@react-types/shared'; + +import { deprecate } from 'storybook/internal/client-logger'; import { Pressable } from '@react-aria/interactions'; import { DialogTrigger } from 'react-aria-components/patched-dist/Dialog'; @@ -9,6 +12,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 +62,7 @@ export interface PopoverProviderProps { } export const PopoverProvider = ({ + ariaLabel, placement: placementProp = 'bottom-start', hasChrome = true, hasCloseButton = false, @@ -66,6 +76,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 +102,19 @@ export const PopoverProvider = ({ onOpenChange={onOpenChange} {...props} > - {children} - + + { + cloneElement(children, { + 'aria-haspopup': 'dialog', + } as DOMAttributes) as ReactElement, string> + } + + ( height: '300px', }} > - + 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..1177418e1a96 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..e9c6f0eedc33 100644 --- a/code/core/src/manager/container/Menu.stories.tsx +++ b/code/core/src/manager/container/Menu.stories.tsx @@ -21,7 +21,7 @@ export default { height: '300px', }} > - + From c460895f616f845564dbc9791d85beacbf5dfcec Mon Sep 17 00:00:00 2001 From: gayanMatch Date: Fri, 16 Jan 2026 08:07:36 -0500 Subject: [PATCH 66/81] renamed aria labels --- MIGRATION.md | 4 ---- code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx | 2 +- .../src/components/components/Popover/PopoverProvider.tsx | 4 +++- code/core/src/components/components/Tabs/Tabs.hooks.tsx | 2 +- code/core/src/manager/components/sidebar/ChecklistWidget.tsx | 2 +- code/core/src/manager/components/sidebar/ContextMenu.tsx | 2 +- code/core/src/manager/components/sidebar/RefIndicator.tsx | 2 +- 7 files changed, 8 insertions(+), 10 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 7baf30894167..805358b51d6d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -637,10 +637,6 @@ The `ariaLabel` prop was added to provide an accessible label for the popover di ``` -##### Automatic aria-haspopup - -PopoverProvider now automatically sets `aria-haspopup="dialog"` on the trigger element. You no longer need to manually add this attribute to your trigger buttons. - #### 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/docs/src/blocks/components/ArgsTable/ArgValue.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx index b8a313d3f9c1..66a723c592ad 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx @@ -203,7 +203,7 @@ const ArgSummary: FC = ({ value, initialExpandedArgs }) => { return ( { diff --git a/code/core/src/components/components/Popover/PopoverProvider.tsx b/code/core/src/components/components/Popover/PopoverProvider.tsx index 946cf3cdcc1a..12d65461760c 100644 --- a/code/core/src/components/components/Popover/PopoverProvider.tsx +++ b/code/core/src/components/components/Popover/PopoverProvider.tsx @@ -104,9 +104,11 @@ export const PopoverProvider = ({ > { + // React-aria does not inject aria-haspopup='dialog' to support legacy screen readers, so we do it ourselves. + // @ts-expect-error react-aria does not allow passing valid ARIA attributes to Pressable children cloneElement(children, { 'aria-haspopup': 'dialog', - } as DOMAttributes) as ReactElement, string> + }) } { {loaded && ( ( diff --git a/code/core/src/manager/components/sidebar/ContextMenu.tsx b/code/core/src/manager/components/sidebar/ContextMenu.tsx index 1177418e1a96..93894cec3dd9 100644 --- a/code/core/src/manager/components/sidebar/ContextMenu.tsx +++ b/code/core/src/manager/components/sidebar/ContextMenu.tsx @@ -196,7 +196,7 @@ export const useContextMenu = (context: API_HashEntry, links: Link[], api: API) onMouseEnter: handlers.onMouseEnter, node: shouldRender ? ( ( From 00043a58250c597a2f853100877c85c88a9bdeef Mon Sep 17 00:00:00 2001 From: gayanMatch Date: Fri, 16 Jan 2026 08:12:12 -0500 Subject: [PATCH 67/81] fix MIGRATION.md --- MIGRATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index 805358b51d6d..1357d6ddd4d0 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -629,7 +629,7 @@ PopoverProvider is based on react-aria. It must have a single child that acts as ##### Added: ariaLabel -The `ariaLabel` prop was added to provide an accessible label for the popover dialog. This label is announced by screen readers when the popover opens. **This prop will become mandatory in Storybook 11.** Provide a concise description of the popover's purpose. +The `ariaLabel` prop was added in Storybook 10.2 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 }> From 1aef5d8819623dc7ed480ec8724d4ef97520d7d9 Mon Sep 17 00:00:00 2001 From: gayanMatch Date: Fri, 16 Jan 2026 12:45:46 -0500 Subject: [PATCH 68/81] fix CI --- .../components/components/Popover/PopoverProvider.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/code/core/src/components/components/Popover/PopoverProvider.tsx b/code/core/src/components/components/Popover/PopoverProvider.tsx index 12d65461760c..3424c77959ab 100644 --- a/code/core/src/components/components/Popover/PopoverProvider.tsx +++ b/code/core/src/components/components/Popover/PopoverProvider.tsx @@ -104,11 +104,12 @@ export const PopoverProvider = ({ > { - // React-aria does not inject aria-haspopup='dialog' to support legacy screen readers, so we do it ourselves. - // @ts-expect-error react-aria does not allow passing valid ARIA attributes to Pressable children - cloneElement(children, { - 'aria-haspopup': 'dialog', - }) + /* 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' } + ) } Date: Fri, 16 Jan 2026 14:02:15 -0500 Subject: [PATCH 69/81] Remove unused FocusableElement import --- code/core/src/components/components/Popover/PopoverProvider.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/code/core/src/components/components/Popover/PopoverProvider.tsx b/code/core/src/components/components/Popover/PopoverProvider.tsx index 3424c77959ab..d597c8d0f494 100644 --- a/code/core/src/components/components/Popover/PopoverProvider.tsx +++ b/code/core/src/components/components/Popover/PopoverProvider.tsx @@ -1,6 +1,5 @@ import type { DOMAttributes, ReactElement, ReactNode } from 'react'; import React, { cloneElement, useCallback, useState } from 'react'; -import type { FocusableElement } from '@react-types/shared'; import { deprecate } from 'storybook/internal/client-logger'; From 5d51ca02cfc5ea2f8a04dea9d14f5eb83ea3bcf0 Mon Sep 17 00:00:00 2001 From: gayanMatch Date: Fri, 16 Jan 2026 19:04:41 -0500 Subject: [PATCH 70/81] fix style error --- .../components/tooltip/TooltipMessage.stories.tsx | 8 +++++++- code/core/src/manager/container/Menu.stories.tsx | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/code/core/src/components/components/tooltip/TooltipMessage.stories.tsx b/code/core/src/components/components/tooltip/TooltipMessage.stories.tsx index 917e8881c491..9de23369b9a9 100644 --- a/code/core/src/components/components/tooltip/TooltipMessage.stories.tsx +++ b/code/core/src/components/components/tooltip/TooltipMessage.stories.tsx @@ -27,7 +27,13 @@ const WithPopoverDecorator: DecoratorFunction = (storyFn) => ( height: '300px', }} > - + diff --git a/code/core/src/manager/container/Menu.stories.tsx b/code/core/src/manager/container/Menu.stories.tsx index e9c6f0eedc33..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', }} > - + From aeb63d36df34ffc839bf118e317f54b3bfdbf86f Mon Sep 17 00:00:00 2001 From: yannbf Date: Thu, 19 Feb 2026 15:03:29 +0100 Subject: [PATCH 71/81] Update Playwright trace configuration and persist Playwright results in CI scripts --- code/playwright.config.ts | 2 +- scripts/ci/sandboxes.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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/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')), ], }), From 333bdce65e3f95cf9b89d44419311601d3b6ea33 Mon Sep 17 00:00:00 2001 From: yannbf Date: Thu, 19 Feb 2026 15:04:17 +0100 Subject: [PATCH 72/81] REVERT THIS COMMIT - This is just to debug --- code/e2e-tests/preview-api.spec.ts | 2 +- .../cli-storybook/src/sandbox-templates.ts | 30 +++++++++---------- scripts/tasks/e2e-tests-build.ts | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/code/e2e-tests/preview-api.spec.ts b/code/e2e-tests/preview-api.spec.ts index befdd6bab7a8..7f5d66b68880 100644 --- a/code/e2e-tests/preview-api.spec.ts +++ b/code/e2e-tests/preview-api.spec.ts @@ -26,7 +26,7 @@ test.describe('preview-api', () => { await expect(sbPage.page.locator('.sidebar-container')).toBeVisible(); // wait for the play function to complete - await sbPage.viewAddonPanel('Interactions'); + await sbPage.viewAddonPanel('Interaactions'); const interactionsTab = page.getByRole('tab', { name: 'Interactions' }); await expect(interactionsTab).toBeVisible(); const panel = sbPage.panelContent(); diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index aedb83ee9316..7b26f52a1565 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -1006,21 +1006,21 @@ export const normal: TemplateKey[] = [ // TODO: Add this back once we resolve the React 19 issues // 'cra/default-ts', 'react-vite/default-ts', - 'angular-cli/default-ts', - 'vue3-vite/default-ts', - // 'nuxt-vite/default-ts', // temporarily disabled because it's broken - 'lit-vite/default-ts', - 'svelte-vite/default-ts', - 'svelte-kit/skeleton-ts', - 'nextjs/default-ts', - 'nextjs-vite/default-ts', - 'bench/react-vite-default-ts', - 'bench/react-webpack-18-ts', - 'bench/react-vite-default-ts-nodocs', - 'bench/react-vite-default-ts-test-build', - 'bench/react-webpack-18-ts-test-build', - // 'ember/default-js', - 'react-rsbuild/default-ts', + // 'angular-cli/default-ts', + // 'vue3-vite/default-ts', + // // 'nuxt-vite/default-ts', // temporarily disabled because it's broken + // 'lit-vite/default-ts', + // 'svelte-vite/default-ts', + // 'svelte-kit/skeleton-ts', + // 'nextjs/default-ts', + // 'nextjs-vite/default-ts', + // 'bench/react-vite-default-ts', + // 'bench/react-webpack-18-ts', + // 'bench/react-vite-default-ts-nodocs', + // 'bench/react-vite-default-ts-test-build', + // 'bench/react-webpack-18-ts-test-build', + // // 'ember/default-js', + // 'react-rsbuild/default-ts', ]; export const merged: TemplateKey[] = [ diff --git a/scripts/tasks/e2e-tests-build.ts b/scripts/tasks/e2e-tests-build.ts index 70e11f96b945..7303f79087d9 100644 --- a/scripts/tasks/e2e-tests-build.ts +++ b/scripts/tasks/e2e-tests-build.ts @@ -35,7 +35,7 @@ export const e2eTestsBuild: Task & { port: number; type: 'build' | 'dev' } = { const playwrightCommand = process.env.DEBUG ? `yarn playwright test --project=chromium --ui ${testFiles.join(' ')}` - : `yarn playwright test ${testFiles.join(' ')}`; + : `yarn playwright test preview-api`; await waitOn({ resources: [`http://localhost:${port}`], interval: 16, timeout: 200000 }); await exec( From c8e62b3d522ad69dc1bb2a8a1e49f72ba133947f Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 19 Feb 2026 15:05:29 +0100 Subject: [PATCH 73/81] fix: Harmonize UI copy for onboarding checklist --- code/core/src/manager/components/sidebar/ChecklistWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx index 82c631f4b500..b94251a92eac 100644 --- a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx +++ b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx @@ -247,7 +247,7 @@ export const ChecklistWidget = () => { Date: Thu, 19 Feb 2026 15:14:14 +0100 Subject: [PATCH 74/81] remove usage of non-exported shouldLog --- code/addons/vitest/src/preset.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index 6b79140ca246..bb0f035959c7 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -25,7 +25,6 @@ import { isEqual } from 'es-toolkit/predicate'; import picocolors from 'picocolors'; import { dedent } from 'ts-dedent'; -import { shouldLog } from '../../../core/src/node-logger/logger'; import { ADDON_ID, COVERAGE_DIRECTORY, @@ -120,10 +119,8 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti const index = await storyIndexGenerator.getIndex(); store.setState((s) => ({ ...s, index })); } catch (error) { - logger.debug('Failed to update story index after invalidation'); - if (shouldLog('debug')) { - logger.error(error); - } + logger.debug('Failed to update story index after invalidation, Error:'); + logger.debug(error); } }); From 0dd475634e324303cf495473d587f80605c3027e Mon Sep 17 00:00:00 2001 From: yannbf Date: Thu, 19 Feb 2026 15:22:24 +0100 Subject: [PATCH 75/81] Revert "REVERT THIS COMMIT - This is just to debug" This reverts commit 333bdce65e3f95cf9b89d44419311601d3b6ea33. --- code/e2e-tests/preview-api.spec.ts | 2 +- .../cli-storybook/src/sandbox-templates.ts | 30 +++++++++---------- scripts/tasks/e2e-tests-build.ts | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/code/e2e-tests/preview-api.spec.ts b/code/e2e-tests/preview-api.spec.ts index 7f5d66b68880..befdd6bab7a8 100644 --- a/code/e2e-tests/preview-api.spec.ts +++ b/code/e2e-tests/preview-api.spec.ts @@ -26,7 +26,7 @@ test.describe('preview-api', () => { await expect(sbPage.page.locator('.sidebar-container')).toBeVisible(); // wait for the play function to complete - await sbPage.viewAddonPanel('Interaactions'); + await sbPage.viewAddonPanel('Interactions'); const interactionsTab = page.getByRole('tab', { name: 'Interactions' }); await expect(interactionsTab).toBeVisible(); const panel = sbPage.panelContent(); diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index 7b26f52a1565..aedb83ee9316 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -1006,21 +1006,21 @@ export const normal: TemplateKey[] = [ // TODO: Add this back once we resolve the React 19 issues // 'cra/default-ts', 'react-vite/default-ts', - // 'angular-cli/default-ts', - // 'vue3-vite/default-ts', - // // 'nuxt-vite/default-ts', // temporarily disabled because it's broken - // 'lit-vite/default-ts', - // 'svelte-vite/default-ts', - // 'svelte-kit/skeleton-ts', - // 'nextjs/default-ts', - // 'nextjs-vite/default-ts', - // 'bench/react-vite-default-ts', - // 'bench/react-webpack-18-ts', - // 'bench/react-vite-default-ts-nodocs', - // 'bench/react-vite-default-ts-test-build', - // 'bench/react-webpack-18-ts-test-build', - // // 'ember/default-js', - // 'react-rsbuild/default-ts', + 'angular-cli/default-ts', + 'vue3-vite/default-ts', + // 'nuxt-vite/default-ts', // temporarily disabled because it's broken + 'lit-vite/default-ts', + 'svelte-vite/default-ts', + 'svelte-kit/skeleton-ts', + 'nextjs/default-ts', + 'nextjs-vite/default-ts', + 'bench/react-vite-default-ts', + 'bench/react-webpack-18-ts', + 'bench/react-vite-default-ts-nodocs', + 'bench/react-vite-default-ts-test-build', + 'bench/react-webpack-18-ts-test-build', + // 'ember/default-js', + 'react-rsbuild/default-ts', ]; export const merged: TemplateKey[] = [ diff --git a/scripts/tasks/e2e-tests-build.ts b/scripts/tasks/e2e-tests-build.ts index 7303f79087d9..70e11f96b945 100644 --- a/scripts/tasks/e2e-tests-build.ts +++ b/scripts/tasks/e2e-tests-build.ts @@ -35,7 +35,7 @@ export const e2eTestsBuild: Task & { port: number; type: 'build' | 'dev' } = { const playwrightCommand = process.env.DEBUG ? `yarn playwright test --project=chromium --ui ${testFiles.join(' ')}` - : `yarn playwright test preview-api`; + : `yarn playwright test ${testFiles.join(' ')}`; await waitOn({ resources: [`http://localhost:${port}`], interval: 16, timeout: 200000 }); await exec( From 0372b09379a28ca64cd208f3de0489b60a816ad9 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 19 Feb 2026 15:32:03 +0100 Subject: [PATCH 76/81] Adjust version number in MIGRATION.MD --- MIGRATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index 1357d6ddd4d0..2b6ba2137b9c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -629,7 +629,7 @@ PopoverProvider is based on react-aria. It must have a single child that acts as ##### Added: ariaLabel -The `ariaLabel` prop was added in Storybook 10.2 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. +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 }> From a3aad1624a8a5951c8bc9e5f2a70cb9935fd9b47 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 19 Feb 2026 20:10:54 +0100 Subject: [PATCH 77/81] Move websocket token to a shared singleton to avoid circular dependency between: ws token -> server channel -> loading all presets -> core preset creates ws token -> ... Co-authored-by: Norbert de Langen --- code/core/src/core-server/build-dev.ts | 3 ++- .../src/core-server/presets/common-preset.ts | 5 ++--- code/core/src/core-server/presets/wsToken.ts | 22 +++++++++++++++++++ code/core/src/typings.d.ts | 2 ++ 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 code/core/src/core-server/presets/wsToken.ts diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 22c32cae18f2..27fbd87539f0 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -26,6 +26,7 @@ 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'; @@ -145,7 +146,7 @@ export async function buildDevStandalone( } catch (e) {} const server = await getServer(options); - const channel = getServerChannel(server); + 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 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/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: From 48f3d0868c3c094dd1eafecebafcd2a0be62df2e Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 20 Feb 2026 10:26:53 +0100 Subject: [PATCH 78/81] Revert "Merge pull request #33420 from Maelryn/fix/copy-button-overlap" This reverts commit ecc7fa889a1fedb789175e63bb3466b706571f02, reversing changes made to 518c70cab9787fe117e8a9d95a438a92f4b5072d. --- .../docs/src/blocks/components/Preview.tsx | 14 ++---------- .../syntaxhighlighter/syntaxhighlighter.tsx | 22 ++----------------- 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index 12246c013207..edaeb88a9876 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -38,7 +38,6 @@ const ChildrenContainer = styled.div( flexWrap: 'wrap', overflow: 'auto', flexDirection: isColumn ? 'column' : 'row', - width: 'fit-content', '& .innerZoomElementWrapper > *': isColumn ? { @@ -173,18 +172,9 @@ const PositionedToolbar = styled(Toolbar)({ height: 40, }); -const Relative = styled.div(({ theme }) => ({ +const Relative = styled.div({ overflow: 'hidden', position: 'relative', - display: 'flex', - flexWrap: 'wrap', - gap: theme.layoutMargin, -})); - -const RelativeActionBar = styled(ActionBar)({ - position: 'relative', - marginLeft: 'auto', - alignSelf: 'flex-end', }); /** @@ -288,7 +278,7 @@ export const Preview: FC = ({ )} - + {withSource && expanded && source} diff --git a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx index 209fb0e75b59..58286cec773e 100644 --- a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx +++ b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx @@ -69,9 +69,6 @@ const Wrapper = styled.div( ({ theme }) => ({ position: 'relative', overflow: 'hidden', - display: 'flex', - flexWrap: 'wrap', - gap: theme.layoutMargin, color: theme.color.defaultText, }), ({ theme, bordered }) => @@ -101,16 +98,6 @@ const UnstyledScroller = ({ children, className }: ScrollAreaProps) => ( const Scroller = styled(UnstyledScroller)( { position: 'relative', - width: 'fit-content', - maxWidth: '100%', - '> div': { - width: 'fit-content', - maxWidth: '100%', - '> div > pre': { - width: 'fit-content', - maxWidth: '100%', - }, - }, }, ({ theme }) => themedSyntax(theme) ); @@ -133,16 +120,11 @@ See https://github.com/storybookjs/storybook/issues/18090 const Code = styled.div(({ theme }) => ({ flex: 1, paddingLeft: 2, // TODO: To match theming/global.ts for now + paddingRight: theme.layoutMargin, opacity: 1, fontFamily: theme.typography.fonts.mono, })); -const RelativeActionBar = styled(ActionBar)({ - position: 'relative', - marginLeft: 'auto', - alignSelf: 'flex-end', -}); - const processLineNumber = (row: any) => { const children = [...row.children]; const lineNumberNode = children[0]; @@ -265,7 +247,7 @@ export const SyntaxHighlighter = ({ {copyable ? ( - + ) : null} ); From a083a04312b25a2c073f893e6ea0e669099c3b09 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 20 Feb 2026 10:42:53 +0100 Subject: [PATCH 79/81] Next.js: Fix failing postcss mutation --- code/builders/builder-vite/src/vite-config.ts | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 8a8b833962c0..807f50152219 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -60,6 +60,12 @@ export async function commonConfig( const sbConfig: InlineConfig = { configFile: false, plugins: await pluginConfig(options), + root: projectRoot, + // Allow storybook deployed as subfolder. See https://github.com/storybookjs/builder-vite/issues/238 + base: './', + ...(options.cacheKey + ? { cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey) } + : {}), // Pass build.target option from user's vite config build: { target: buildProperty?.target, @@ -72,28 +78,11 @@ export async function commonConfig( } export async function pluginConfig(options: Options) { - const projectRoot = resolve(options.configDir, '..'); - const plugins = [ // Shared core plugins (resolve conditions, envPrefix, fs.allow, externals, env vars, etc.) ...(await corePlugins([], options)), await storybookExternalGlobalsPlugin(options), await csfPlugin(options), - // Builder-specific: root, base, and cacheDir - { - name: 'storybook:builder-vite-config', - enforce: 'pre' as const, - config() { - return { - root: projectRoot, - // Allow storybook deployed as subfolder. See https://github.com/storybookjs/builder-vite/issues/238 - base: './', - ...(options.cacheKey - ? { cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey) } - : {}), - }; - }, - }, // Entry plugin: virtual modules for stories, addon setup, and main app entry ...(await storybookEntryPlugin(options)), // Builder-specific: webpack-compatible stats for turbosnap/chromatic From 5833e6b07543c5ce1b770fce458f0e975a31f665 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 20 Feb 2026 16:41:02 +0700 Subject: [PATCH 80/81] Fix manifest generation for stories without explicit title in meta When a story file has no explicit `title` in its meta (the common case), the CSF parser received 'No title' as fallback, producing wrong story IDs (e.g. `no-title--logged-in` instead of `header--logged-in`). These IDs didn't match the index entries, so `extractStories` filtered them all out, resulting in empty `stories: []` in the manifest. Use `entry.title` from the story index as fallback instead. --- .../src/componentManifest/generator.test.ts | 83 +++++++++++++++++++ .../react/src/componentManifest/generator.ts | 2 +- 2 files changed, 84 insertions(+), 1 deletion(-) 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]; From 77a63dde7b3c53d29f30973d154c9025d4662933 Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:05:36 +0000 Subject: [PATCH 81/81] Write changelog for 10.3.0-alpha.8 [skip ci] --- CHANGELOG.prerelease.md | 10 ++++++++++ code/package.json | 3 ++- docs/versions/next.json | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) 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/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/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