diff --git a/CHANGELOG.md b/CHANGELOG.md
index 64305104bdab..9717bfe4e938 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,15 @@
+## 10.0.4
+
+- CLI: Fix issue with running Storybook after being initialized - [#32929](https://github.com/storybookjs/storybook/pull/32929), thanks @yannbf!
+- CRA: Fix `module` not defined in ESM - [#32940](https://github.com/storybookjs/storybook/pull/32940), thanks @ndelangen!
+
+## 10.0.3
+
+- Core: Better handling for TypeScript satisfies/as syntaxes - [#32891](https://github.com/storybookjs/storybook/pull/32891), thanks @yannbf!
+- Core: Fix wrong import to fix Yarn PnP support - [#32928](https://github.com/storybookjs/storybook/pull/32928), thanks @yannbf!
+- ESlint: Update `@storybook/experimental-nextjs-vite` in `no-renderer-packages` rule - [#32909](https://github.com/storybookjs/storybook/pull/32909), thanks @ndelangen!
+- React Native: Update withStorybook setup instructions - [#32919](https://github.com/storybookjs/storybook/pull/32919), thanks @dannyhw!
+
## 10.0.2
- CLI: Fix glob string formatting in csf-factories codemod - [#32880](https://github.com/storybookjs/storybook/pull/32880), thanks @yannbf!
diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md
index b8fe0a2c3bae..73cebd0c2ee0 100644
--- a/CHANGELOG.prerelease.md
+++ b/CHANGELOG.prerelease.md
@@ -1,3 +1,10 @@
+## 10.1.0-alpha.5
+
+- CLI: Fix issue with running Storybook after being initialized - [#32929](https://github.com/storybookjs/storybook/pull/32929), thanks @yannbf!
+- CRA: Fix `module` not defined in ESM - [#32940](https://github.com/storybookjs/storybook/pull/32940), thanks @ndelangen!
+- React: Improve automatic component, automatic imports, support barrel files and enhance story filtering - [#32939](https://github.com/storybookjs/storybook/pull/32939), thanks @kasperpeulen!
+- Theming: Set `themes.normal` according to user preference and export `getPreferredColorScheme` - [#28721](https://github.com/storybookjs/storybook/pull/28721), thanks @elisezhg!
+
## 10.1.0-alpha.4
- Core: Better handling for TypeScript satisfies/as syntaxes - [#32891](https://github.com/storybookjs/storybook/pull/32891), thanks @yannbf!
diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts
index d5e117afdc74..279e8a138b51 100644
--- a/code/.storybook/main.ts
+++ b/code/.storybook/main.ts
@@ -138,7 +138,6 @@ const config = defineMain({
developmentModeForBuild: true,
experimentalTestSyntax: true,
experimentalComponentsManifest: true,
- experimentalCodeExamples: true,
},
staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }],
viteFinal: async (viteConfig, { configType }) => {
diff --git a/code/addons/vitest/tsconfig.json b/code/addons/vitest/tsconfig.json
index 8f0586c10653..d2318b7bb29f 100644
--- a/code/addons/vitest/tsconfig.json
+++ b/code/addons/vitest/tsconfig.json
@@ -5,5 +5,5 @@
"types": ["vitest"],
"strict": true
},
- "include": ["src/**/*", "./typings.d.ts"],
+ "include": ["src/**/*", "./typings.d.ts"]
}
diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts
index 4c373f9f0723..cd3c5d02f1b8 100644
--- a/code/core/src/core-server/dev-server.ts
+++ b/code/core/src/core-server/dev-server.ts
@@ -191,7 +191,8 @@ export async function storybookDevServer(options: Options) {
// logger?.error?.(e instanceof Error ? e : String(e));
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
- res.end(`
${e instanceof Error ? e.toString() : String(e)}`);
+ invariant(e instanceof Error);
+ res.end(`${e.stack}`);
}
});
}
diff --git a/code/core/src/core-server/manifest.ts b/code/core/src/core-server/manifest.ts
index 591d1f6aa382..6e6f18b23533 100644
--- a/code/core/src/core-server/manifest.ts
+++ b/code/core/src/core-server/manifest.ts
@@ -1,3 +1,5 @@
+import path from 'node:path';
+
import { groupBy } from 'storybook/internal/common';
import type { ComponentManifest, ComponentsManifest } from '../types';
@@ -13,7 +15,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
const totals = {
components: entries.length,
componentsWithPropTypeError: analyses.filter((a) => a.hasPropTypeError).length,
- warnings: analyses.filter((a) => a.hasWarns).length,
+ infos: analyses.filter((a) => a.hasWarns).length,
stories: analyses.reduce((sum, a) => sum + a.totalStories, 0),
storyErrors: analyses.reduce((sum, a) => sum + a.storyErrors, 0),
};
@@ -24,9 +26,9 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
totals.componentsWithPropTypeError > 0
? `${totals.componentsWithPropTypeError}/${totals.components} prop type ${plural(totals.componentsWithPropTypeError, 'error')}`
: `${totals.components} components ok`;
- const compWarningsPill =
- totals.warnings > 0
- ? `${totals.warnings}/${totals.components} ${plural(totals.warnings, 'warning')}`
+ const compInfosPill =
+ totals.infos > 0
+ ? `${totals.infos}/${totals.components} ${plural(totals.infos, 'info', 'infos')}`
: '';
const storiesPill =
totals.storyErrors > 0
@@ -40,7 +42,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
entries.map(([, it]) => it).filter((it) => it.error),
(manifest) => manifest.error?.name ?? 'Error'
)
- );
+ ).sort(([, a], [, b]) => b.length - a.length);
const errorGroupsHTML = errorGroups
.map(([error, grouped]) => {
@@ -76,10 +78,10 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
--muted: #9aa0a6;
--fg: #e8eaed;
--ok: #22c55e;
- --warn: #b08900;
+ --info: #1e88e5;
--err: #c62828;
--ok-bg: #0c1a13;
- --warn-bg: #1a1608;
+ --info-bg: #0c1624;
--err-bg: #1a0e0e;
--chip: #1f2330;
--border: #2b2f3a;
@@ -155,10 +157,10 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
background: color-mix(in srgb, var(--ok) 18%, #000);
}
- .filter-pill.warn {
- color: #ffd666;
- border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
- background: var(--warn-bg);
+ .filter-pill.info {
+ color: #b3d9ff;
+ border-color: color-mix(in srgb, var(--info) 55%, var(--border));
+ background: var(--info-bg);
}
.filter-pill.err {
@@ -187,7 +189,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
/* Selected top pill ring via :target */
#filter-all:target ~ header .filter-pill[data-k='all'],
#filter-errors:target ~ header .filter-pill[data-k='errors'],
- #filter-warnings:target ~ header .filter-pill[data-k='warnings'],
+ #filter-infos:target ~ header .filter-pill[data-k='infos'],
#filter-story-errors:target ~ header .filter-pill[data-k='story-errors'] {
box-shadow: 0 0 0 var(--active-ring) currentColor;
border-color: currentColor;
@@ -196,7 +198,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
/* Hidden targets for filtering */
#filter-all,
#filter-errors,
- #filter-warnings,
+ #filter-infos,
#filter-story-errors {
display: none;
}
@@ -292,9 +294,9 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
border-color: color-mix(in srgb, var(--ok) 55%, var(--border));
}
- .badge.warn {
- color: #ffd666;
- border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
+ .badge.info {
+ color: #b3d9ff;
+ border-color: color-mix(in srgb, var(--info) 55%, var(--border));
}
.badge.err {
@@ -308,7 +310,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
/* 1px ring on active toggle */
.tg-err:checked + label.as-toggle,
- .tg-warn:checked + label.as-toggle,
+ .tg-info:checked + label.as-toggle,
.tg-stories:checked + label.as-toggle,
.tg-props:checked + label.as-toggle {
box-shadow: 0 0 0 var(--active-ring) currentColor;
@@ -329,7 +331,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
display: grid;
}
- .tg-warn:checked ~ .panels .panel-warn {
+ .tg-info:checked ~ .panels .panel-info {
display: grid;
gap: 8px;
}
@@ -343,7 +345,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
display: grid;
}
- /* Colored notes for prop type error + warnings */
+ /* Colored notes for prop type error + info */
.note {
padding: 12px;
border: 1px solid var(--border);
@@ -356,10 +358,10 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
color: #ffd1d4;
}
- .note.warn {
- border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
- background: var(--warn-bg);
- color: #ffe9a6;
+ .note.info {
+ border-color: color-mix(in srgb, var(--info) 55%, var(--border));
+ background: var(--info-bg);
+ color: #d6e8ff;
}
.note.ok {
@@ -491,7 +493,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
display: none;
}
- #filter-warnings:target ~ main .card:not(.has-warn) {
+ #filter-infos:target ~ main .card:not(.has-info) {
display: none;
}
@@ -517,37 +519,64 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) {
.card > .tg-err:checked ~ .panels .panel-err {
display: grid;
}
-
- .card > .tg-warn:checked ~ .panels .panel-warn {
+
+ .card > .tg-info:checked ~ .panels .panel-info {
display: grid;
}
-
+
.card > .tg-stories:checked ~ .panels .panel-stories {
display: grid;
}
+ /* Add vertical spacing around panels only when any panel is visible */
+ .card > .tg-err:checked ~ .panels,
+ .card > .tg-info:checked ~ .panels,
+ .card > .tg-stories:checked ~ .panels,
+ .card > .tg-props:checked ~ .panels {
+ margin: 10px 0;
+ }
+
/* Optional: a subtle 1px ring on the active badge, using :has() if available */
@supports selector(.card:has(.tg-err:checked)) {
.card:has(.tg-err:checked) label[for$='-err'],
- .card:has(.tg-warn:checked) label[for$='-warn'],
+ .card:has(.tg-info:checked) label[for$='-info'],
.card:has(.tg-stories:checked) label[for$='-stories'],
.card:has(.tg-props:checked) label[for$='-props'] {
box-shadow: 0 0 0 1px currentColor;
border-color: currentColor;
}
}
+
+ /* Wrap long lines in code blocks at ~120 characters */
+ pre, code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ }
+ pre {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ overflow-x: auto; /* fallback for extremely long tokens */
+ margin: 8px 0 0;
+ }
+ pre > code {
+ display: block;
+ white-space: inherit;
+ overflow-wrap: inherit;
+ word-break: inherit;
+ inline-size: min(100%, 120ch);
+ }
-
+
@@ -580,6 +609,10 @@ function analyzeComponent(c: ComponentManifest) {
const hasPropTypeError = !!c.error;
const warns: string[] = [];
+ if (!c.description?.trim()) {
+ warns.push('No description found. Write a jsdoc comment such as /** Component description */.');
+ }
+
if (!c.import?.trim()) {
warns.push(
`Specify an @import jsdoc tag on your component or your stories meta such as @import import { ${c.name} } from 'my-design-system';`
@@ -603,7 +636,7 @@ function analyzeComponent(c: ComponentManifest) {
};
}
-function note(title: string, bodyHTML: string, kind: 'warn' | 'err') {
+function note(title: string, bodyHTML: string, kind: 'info' | 'err') {
return `
${esc(title)}
@@ -627,8 +660,8 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) {
? `
`
: '';
- const warningsBadge = a.hasWarns
- ? `
`
+ const infosBadge = a.hasWarns
+ ? `
`
: '';
const storiesBadge =
@@ -637,8 +670,8 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) {
: '';
// When there is no prop type error, try to read prop types from reactDocgen if present
- const hasDocgen = !a.hasPropTypeError && 'reactDocgen' in c && c.reactDocgen;
- const parsedDocgen = hasDocgen ? parseReactDocgen(c.reactDocgen) : undefined;
+ const reactDocgen: any = !a.hasPropTypeError && 'reactDocgen' in c && c.reactDocgen;
+ const parsedDocgen = reactDocgen ? parseReactDocgen(reactDocgen) : undefined;
const propEntries = parsedDocgen ? Object.entries(parsedDocgen.props ?? {}) : [];
const propTypesBadge =
!a.hasPropTypeError && propEntries.length > 0
@@ -657,8 +690,10 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) {
const optional = info?.required ? '' : '?';
const defaultVal = (info?.defaultValue ?? '').trim();
const def = defaultVal ? ` = ${defaultVal}` : '';
- const doc = description ? `/** ${description} */\n` : '';
- return `${doc}${propName}${optional}: ${t}${def}`;
+ const doc =
+ ['/**', ...description.split('\n').map((line) => ` * ${line}`), ' */'].join('\n') +
+ '\n';
+ return `${description ? doc : ''}${propName}${optional}: ${t}${def}`;
})
.join('\n\n')
: '';
@@ -679,7 +714,7 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) {
@@ -688,7 +723,7 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) {
${esc(c.name || key)}
${primaryBadge}
- ${warningsBadge}
+ ${infosBadge}
${storiesBadge}
@@ -700,7 +735,7 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) {
${a.hasPropTypeError ? `` : ''}
- ${a.hasWarns ? `` : ''}
+ ${a.hasWarns ? `` : ''}
${a.totalStories > 0 ? `` : ''}
${!a.hasPropTypeError && propEntries.length > 0 ? `` : ''}
@@ -716,8 +751,8 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) {
${
a.hasWarns
? `
-
- ${a.warns.map((w) => note('Warning', esc(w), 'warn')).join('')}
+
+ ${a.warns.map((w) => note('Info', esc(w), 'info')).join('')}
`
: ''
}
@@ -730,6 +765,8 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) {
Prop types
${propEntries.length} ${plural(propEntries.length, 'prop type')}
+ Component: ${reactDocgen?.definedInFile ? esc(path.relative(process.cwd(), reactDocgen.definedInFile)) : ''}${reactDocgen?.exportName ? '::' + esc(reactDocgen?.exportName) : ''}
+ Props:
${esc(propsCode)}
`
@@ -747,19 +784,36 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) {
${esc(ex.name)}
story error
+ ${ex?.summary ? `Summary: ${esc(ex.summary)}
` : ''}
+ ${ex?.description ? `${esc(ex.description)}
` : ''}
${ex?.snippet ? `${esc(ex.snippet)}
` : ''}
${ex?.error?.message ? `${esc(ex.error.message)}
` : ''}
`
)
.join('')}
+
+
+ ${
+ c.import
+ ? `
+
+ Imports
+
+
${c.import}
+
`
+ : ''
+ }
+
${okStories
.map(
- (ex, k) => `
+ (ex) => `
${esc(ex.name)}
story ok
+ ${ex?.summary ? `
${esc(ex.summary)}
` : ''}
+ ${ex?.description ? `
${esc(ex.description)}
` : ''}
${ex?.snippet ? `
${esc(ex.snippet)}
` : ''}
`
)
diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts
index dbfcca072246..c7de468b7254 100644
--- a/code/core/src/manager/globals/exports.ts
+++ b/code/core/src/manager/globals/exports.ts
@@ -370,6 +370,7 @@ export default {
'css',
'darken',
'ensure',
+ 'getPreferredColorScheme',
'ignoreSsrWarning',
'isPropValid',
'jsx',
diff --git a/code/core/src/node-logger/index.ts b/code/core/src/node-logger/index.ts
index ad95f58e1fc0..ef6b083e01c9 100644
--- a/code/core/src/node-logger/index.ts
+++ b/code/core/src/node-logger/index.ts
@@ -60,17 +60,17 @@ export const logger = {
npmLog.level = level;
newLogger.setLogLevel(level);
},
- error: (message: Error | string): void => {
+ error: (message: unknown): void => {
let msg: string;
if (message instanceof Error && message.stack) {
- msg = message.stack.toString();
- } else {
+ msg = message.stack.toString().replace(message.toString(), colors.red(message.toString()));
+ } else if (typeof message === 'string') {
msg = message.toString();
+ } else {
+ msg = String(message);
}
- newLogger.error(
- msg.replace(message.toString(), colors.red(message.toString())).replaceAll(process.cwd(), '.')
- );
+ newLogger.error(msg.replaceAll(process.cwd(), '.'));
},
};
diff --git a/code/core/src/theming/create.ts b/code/core/src/theming/create.ts
index b5f97a34aee5..8a33d0dce577 100644
--- a/code/core/src/theming/create.ts
+++ b/code/core/src/theming/create.ts
@@ -4,18 +4,28 @@ import lightThemeVars from './themes/light';
import type { ThemeVars, ThemeVarsPartial } from './types';
import { getPreferredColorScheme } from './utils';
-export const themes: { light: ThemeVars; dark: ThemeVars; normal: ThemeVars } = {
+interface Themes {
+ light: ThemeVars;
+ dark: ThemeVars;
+ normal: ThemeVars;
+}
+
+const themesBase: Omit = {
light: lightThemeVars,
dark: darkThemeVars,
- normal: lightThemeVars,
+};
+
+const preferredColorScheme = getPreferredColorScheme();
+
+export const themes: Themes = {
+ ...themesBase,
+ normal: themesBase[preferredColorScheme],
};
interface Rest {
[key: string]: any;
}
-const preferredColorScheme = getPreferredColorScheme();
-
export const create = (
vars: ThemeVarsPartial = { base: preferredColorScheme },
rest?: Rest
diff --git a/code/core/src/theming/index.ts b/code/core/src/theming/index.ts
index 8fa92ea794f9..bc92801f871e 100644
--- a/code/core/src/theming/index.ts
+++ b/code/core/src/theming/index.ts
@@ -40,7 +40,7 @@ export * from './create';
export * from './convert';
export * from './ensure';
-export { lightenColor as lighten, darkenColor as darken } from './utils';
+export { lightenColor as lighten, darkenColor as darken, getPreferredColorScheme } from './utils';
export const ignoreSsrWarning =
'/* emotion-disable-server-rendering-unsafe-selector-warning-please-do-not-use-this-the-warning-exists-for-a-reason */';
diff --git a/code/core/src/theming/tests/create.test.js b/code/core/src/theming/tests/create.test.js
index 524a424e6df0..1236300bc9f0 100644
--- a/code/core/src/theming/tests/create.test.js
+++ b/code/core/src/theming/tests/create.test.js
@@ -1,6 +1,11 @@
-import { describe, expect, it } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { create } from '../create';
+import { getPreferredColorScheme } from './../utils';
+
+vi.mock('./../utils', () => ({
+ getPreferredColorScheme: vi.fn().mockReturnValue('light'),
+}));
describe('create base', () => {
it('should create a theme with minimal viable theme', () => {
@@ -142,3 +147,25 @@ describe('create extend', () => {
expect(result.base).toEqual('light');
});
});
+
+describe('themes', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ it('should set `normal` to `light` theme when user preference is `light`', async () => {
+ getPreferredColorScheme.mockReturnValue('light');
+
+ const { themes } = await import('./../create');
+
+ expect(themes.normal).toBe(themes.light);
+ });
+
+ it('should set `normal` to `dark` theme when user preference is `dark`', async () => {
+ getPreferredColorScheme.mockReturnValue('dark');
+
+ const { themes } = await import('./../create');
+
+ expect(themes.normal).toBe(themes.dark);
+ });
+});
diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts
index 229e7489608e..2b7d75a0fe9a 100644
--- a/code/core/src/types/modules/core-common.ts
+++ b/code/core/src/types/modules/core-common.ts
@@ -352,7 +352,13 @@ export interface ComponentManifest {
description?: string;
import?: string;
summary?: string;
- stories: { name: string; snippet?: string; error?: { name: string; message: string } }[];
+ stories: {
+ name: string;
+ snippet?: string;
+ description?: string;
+ summary?: string;
+ error?: { name: string; message: string };
+ }[];
jsDocTags: Record;
error?: { name: string; message: string };
}
diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts
index eed6de8e1b7e..5833f1db2a7f 100644
--- a/code/lib/create-storybook/src/initiate.ts
+++ b/code/lib/create-storybook/src/initiate.ts
@@ -32,6 +32,8 @@ import { telemetry } from 'storybook/internal/telemetry';
import boxen from 'boxen';
import * as find from 'empathic/find';
+// eslint-disable-next-line depend/ban-dependencies
+import execa from 'execa';
import picocolors from 'picocolors';
import { getProcessAncestry } from 'process-ancestry';
import prompts from 'prompts';
@@ -807,45 +809,56 @@ export async function initiate(options: CommandOptions): Promise {
);
if (initiateResult?.shouldRunDev) {
- const { projectType, packageManager, storybookCommand } = initiateResult;
- logger.log('\nRunning Storybook');
+ await runStorybookDev(initiateResult);
+ }
+}
- try {
- const supportsOnboarding = [
- ProjectType.REACT_SCRIPTS,
- ProjectType.REACT,
- ProjectType.WEBPACK_REACT,
- ProjectType.REACT_PROJECT,
- ProjectType.NEXTJS,
- ProjectType.VUE3,
- ProjectType.ANGULAR,
- ].includes(projectType);
-
- const flags = [];
-
- // npm needs extra -- to pass flags to the command
- // in the case of Angular, we are calling `ng run` which doesn't need the extra `--`
- if (packageManager.type === 'npm' && projectType !== ProjectType.ANGULAR) {
- flags.push('--');
- }
+/** Run Storybook dev server after installation */
+async function runStorybookDev(result: {
+ projectType: ProjectType;
+ packageManager: JsPackageManager;
+ storybookCommand?: string;
+ shouldOnboard: boolean;
+}): Promise {
+ const { projectType, packageManager, storybookCommand, shouldOnboard } = result;
+
+ if (!storybookCommand) {
+ return;
+ }
- if (supportsOnboarding && initiateResult.shouldOnboard) {
- flags.push('--initial-path=/onboarding');
- }
+ try {
+ const supportsOnboarding = [
+ ProjectType.REACT_SCRIPTS,
+ ProjectType.REACT,
+ ProjectType.WEBPACK_REACT,
+ ProjectType.REACT_PROJECT,
+ ProjectType.NEXTJS,
+ ProjectType.VUE3,
+ ProjectType.ANGULAR,
+ ].includes(projectType);
+
+ const flags = [];
+
+ // npm needs extra -- to pass flags to the command
+ // in the case of Angular, we are calling `ng run` which doesn't need the extra `--`
+ if (packageManager.type === 'npm' && projectType !== ProjectType.ANGULAR) {
+ flags.push('--');
+ }
- flags.push('--quiet');
-
- // instead of calling 'dev' automatically, we spawn a subprocess so that it gets
- // executed directly in the user's project directory. This avoid potential issues
- // with packages running in npxs' node_modules
- packageManager.runPackageCommandSync(
- storybookCommand.replace(/^yarn /, ''),
- flags,
- undefined,
- 'inherit'
- );
- } catch (e) {
- // Do nothing here, as the command above will spawn a `storybook dev` process which does the error handling already. Else, the error will get bubbled up and sent to crash reports twice
+ if (supportsOnboarding && shouldOnboard) {
+ flags.push('--initial-path=/onboarding');
}
+
+ flags.push('--quiet');
+
+ // instead of calling 'dev' automatically, we spawn a subprocess so that it gets
+ // executed directly in the user's project directory. This avoid potential issues
+ // with packages running in npxs' node_modules
+ logger.log('\nRunning Storybook');
+ execa.command(`${storybookCommand} ${flags.join(' ')}`, {
+ stdio: 'inherit',
+ });
+ } catch {
+ // Do nothing here, as the command above will spawn a `storybook dev` process which does the error handling already
}
}
diff --git a/code/package.json b/code/package.json
index 50eedd49282a..abecc3cf5855 100644
--- a/code/package.json
+++ b/code/package.json
@@ -283,5 +283,6 @@
"Dependency Upgrades"
]
]
- }
+ },
+ "deferredNextVersion": "10.1.0-alpha.5"
}
diff --git a/code/presets/create-react-app/build-config.ts b/code/presets/create-react-app/build-config.ts
index 5a4fae21a633..0dc5856b2427 100644
--- a/code/presets/create-react-app/build-config.ts
+++ b/code/presets/create-react-app/build-config.ts
@@ -4,7 +4,7 @@ const config: BuildEntries = {
entries: {
node: [
{
- exportEntries: ['./index'],
+ exportEntries: ['./index', './preset'],
entryPoint: './src/index.ts',
dts: false,
},
diff --git a/code/presets/create-react-app/package.json b/code/presets/create-react-app/package.json
index 35857d72345c..d4940c4fb477 100644
--- a/code/presets/create-react-app/package.json
+++ b/code/presets/create-react-app/package.json
@@ -22,7 +22,8 @@
"type": "module",
"exports": {
"./index": "./dist/index.js",
- "./package.json": "./package.json"
+ "./package.json": "./package.json",
+ "./preset": "./dist/index.js"
},
"files": [
"dist/**/*",
diff --git a/code/presets/create-react-app/src/index.ts b/code/presets/create-react-app/src/index.ts
index 469d46f95fd7..e978a8f2ed8a 100644
--- a/code/presets/create-react-app/src/index.ts
+++ b/code/presets/create-react-app/src/index.ts
@@ -1,3 +1,4 @@
+import module from 'node:module';
import { dirname, join, relative } from 'node:path';
import { logger } from 'storybook/internal/node-logger';
diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json
index a5de0f1e68e2..27a63b1b52f3 100644
--- a/code/renderers/react/package.json
+++ b/code/renderers/react/package.json
@@ -67,6 +67,7 @@
"acorn-walk": "^7.2.0",
"babel-plugin-react-docgen": "^4.2.1",
"comment-parser": "^1.4.1",
+ "empathic": "^2.0.0",
"es-toolkit": "^1.36.0",
"escodegen": "^2.1.0",
"expect-type": "^0.15.0",
diff --git a/code/renderers/react/src/componentManifest/fixtures.ts b/code/renderers/react/src/componentManifest/fixtures.ts
new file mode 100644
index 000000000000..075557eb84d1
--- /dev/null
+++ b/code/renderers/react/src/componentManifest/fixtures.ts
@@ -0,0 +1,193 @@
+import { dedent } from 'ts-dedent';
+
+export const fsMocks = {
+ ['./package.json']: JSON.stringify({ name: 'some-package' }),
+ ['./src/stories/Button.stories.ts']: dedent`
+ import type { Meta, StoryObj } from '@storybook/react';
+ import { fn } from 'storybook/test';
+ import { Button } from './Button';
+
+ const meta = {
+ component: Button,
+ args: { onClick: fn() },
+ } satisfies Meta;
+ export default meta;
+ type Story = StoryObj;
+
+ export const Primary: Story = { args: { primary: true, label: 'Button' } };
+ export const Secondary: Story = { args: { label: 'Button' } };
+ export const Large: Story = { args: { size: 'large', label: 'Button' } };
+ export const Small: Story = { args: { size: 'small', label: 'Button' } };`,
+ ['./src/stories/Button.tsx']: dedent`
+ import React from 'react';
+ export interface ButtonProps {
+ /** Description of primary */
+ primary?: boolean;
+ backgroundColor?: string;
+ size?: 'small' | 'medium' | 'large';
+ label: string;
+ onClick?: () => void;
+ }
+
+ /**
+ * Primary UI component for user interaction
+ * @import import { Button } from '@design-system/components/Button';
+ */
+ export const Button = ({
+ primary = false,
+ size = 'medium',
+ backgroundColor,
+ label,
+ ...props
+ }: ButtonProps) => {
+ const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
+ return (
+
+ );
+ };`,
+ ['./src/stories/Header.stories.ts']: dedent`
+ import type { Meta, StoryObj } from '@storybook/react';
+ import { fn } from 'storybook/test';
+ import Header from './Header';
+
+ /**
+ * Description from meta and very long.
+ * @summary Component summary
+ * @import import { Header } from '@design-system/components/Header';
+ */
+ const meta = {
+ component: Header,
+ args: {
+ onLogin: fn(),
+ onLogout: fn(),
+ onCreateAccount: fn(),
+ }
+ } satisfies Meta;
+ export default meta;
+ type Story = StoryObj;
+ export const LoggedIn: Story = { args: { user: { name: 'Jane Doe' } } };
+ export const LoggedOut: Story = {};
+ `,
+ ['./src/stories/Header.tsx']: dedent`
+ import { Button } from './Button';
+
+ export interface HeaderProps {
+ user?: User;
+ onLogin?: () => void;
+ onLogout?: () => void;
+ onCreateAccount?: () => void;
+ }
+
+ /**
+ * @import import { Header } from '@design-system/components/Header';
+ */
+ export default ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => (
+
+ );`,
+};
+
+export const indexJson = {
+ v: 5,
+ entries: {
+ 'example-button--primary': {
+ type: 'story',
+ subtype: 'story',
+ id: 'example-button--primary',
+ name: 'Primary',
+ title: 'Example/Button',
+ importPath: './src/stories/Button.stories.ts',
+ componentPath: './src/stories/Button.tsx',
+ tags: ['dev', 'test', 'vitest', 'autodocs'],
+ exportName: 'Primary',
+ },
+ 'example-button--secondary': {
+ type: 'story',
+ subtype: 'story',
+ id: 'example-button--secondary',
+ name: 'Secondary',
+ title: 'Example/Button',
+ importPath: './src/stories/Button.stories.ts',
+ componentPath: './src/stories/Button.tsx',
+ tags: ['dev', 'test', 'vitest', 'autodocs'],
+ exportName: 'Secondary',
+ },
+ 'example-button--large': {
+ type: 'story',
+ subtype: 'story',
+ id: 'example-button--large',
+ name: 'Large',
+ title: 'Example/Button',
+ importPath: './src/stories/Button.stories.ts',
+ componentPath: './src/stories/Button.tsx',
+ tags: ['dev', 'test', 'vitest', 'autodocs'],
+ exportName: 'Large',
+ },
+ 'example-button--small': {
+ type: 'story',
+ subtype: 'story',
+ id: 'example-button--small',
+ name: 'Small',
+ title: 'Example/Button',
+ importPath: './src/stories/Button.stories.ts',
+ componentPath: './src/stories/Button.tsx',
+ tags: ['dev', 'test', 'vitest', 'autodocs'],
+ exportName: 'Small',
+ },
+ 'example-header--docs': {
+ id: 'example-header--docs',
+ title: 'Example/Header',
+ name: 'Docs',
+ importPath: './src/stories/Header.stories.ts',
+ type: 'docs',
+ tags: ['dev', 'test', 'vitest', 'autodocs'],
+ storiesImports: [],
+ },
+ 'example-header--logged-in': {
+ type: 'story',
+ subtype: 'story',
+ id: 'example-header--logged-in',
+ name: 'Logged In',
+ title: 'Example/Header',
+ importPath: './src/stories/Header.stories.ts',
+ componentPath: './src/stories/Header.tsx',
+ tags: ['dev', 'test', 'vitest', 'autodocs'],
+ exportName: 'LoggedIn',
+ },
+ 'example-header--logged-out': {
+ type: 'story',
+ subtype: 'story',
+ id: 'example-header--logged-out',
+ name: 'Logged Out',
+ title: 'Example/Header',
+ importPath: './src/stories/Header.stories.ts',
+ componentPath: './src/stories/Header.tsx',
+ tags: ['dev', 'test', 'vitest', 'autodocs'],
+ exportName: 'LoggedOut',
+ },
+ },
+};
diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts
index fa13535eb68c..405034954ed9 100644
--- a/code/renderers/react/src/componentManifest/generator.test.ts
+++ b/code/renderers/react/src/componentManifest/generator.test.ts
@@ -5,208 +5,17 @@ import { type StoryIndexGenerator } from 'storybook/internal/core-server';
import { vol } from 'memfs';
import { dedent } from 'ts-dedent';
+import { fsMocks, indexJson } from './fixtures';
import { componentManifestGenerator } from './generator';
-vi.mock('node:fs/promises', async () => (await import('memfs')).fs.promises);
-vi.mock('node:fs', async () => (await import('memfs')).fs);
-vi.mock('tsconfig-paths', () => ({ loadConfig: () => ({ resultType: null!, message: null! }) }));
-
-// Use the provided indexJson from this file
-const indexJson = {
- v: 5,
- entries: {
- 'example-button--primary': {
- type: 'story',
- subtype: 'story',
- id: 'example-button--primary',
- name: 'Primary',
- title: 'Example/Button',
- importPath: './src/stories/Button.stories.ts',
- componentPath: './src/stories/Button.tsx',
- tags: ['dev', 'test', 'vitest', 'autodocs'],
- exportName: 'Primary',
- },
- 'example-button--secondary': {
- type: 'story',
- subtype: 'story',
- id: 'example-button--secondary',
- name: 'Secondary',
- title: 'Example/Button',
- importPath: './src/stories/Button.stories.ts',
- componentPath: './src/stories/Button.tsx',
- tags: ['dev', 'test', 'vitest', 'autodocs'],
- exportName: 'Secondary',
- },
- 'example-button--large': {
- type: 'story',
- subtype: 'story',
- id: 'example-button--large',
- name: 'Large',
- title: 'Example/Button',
- importPath: './src/stories/Button.stories.ts',
- componentPath: './src/stories/Button.tsx',
- tags: ['dev', 'test', 'vitest', 'autodocs'],
- exportName: 'Large',
- },
- 'example-button--small': {
- type: 'story',
- subtype: 'story',
- id: 'example-button--small',
- name: 'Small',
- title: 'Example/Button',
- importPath: './src/stories/Button.stories.ts',
- componentPath: './src/stories/Button.tsx',
- tags: ['dev', 'test', 'vitest', 'autodocs'],
- exportName: 'Small',
- },
- 'example-header--docs': {
- id: 'example-header--docs',
- title: 'Example/Header',
- name: 'Docs',
- importPath: './src/stories/Header.stories.ts',
- type: 'docs',
- tags: ['dev', 'test', 'vitest', 'autodocs'],
- storiesImports: [],
- },
- 'example-header--logged-in': {
- type: 'story',
- subtype: 'story',
- id: 'example-header--logged-in',
- name: 'Logged In',
- title: 'Example/Header',
- importPath: './src/stories/Header.stories.ts',
- componentPath: './src/stories/Header.tsx',
- tags: ['dev', 'test', 'vitest', 'autodocs'],
- exportName: 'LoggedIn',
- },
- 'example-header--logged-out': {
- type: 'story',
- subtype: 'story',
- id: 'example-header--logged-out',
- name: 'Logged Out',
- title: 'Example/Header',
- importPath: './src/stories/Header.stories.ts',
- componentPath: './src/stories/Header.tsx',
- tags: ['dev', 'test', 'vitest', 'autodocs'],
- exportName: 'LoggedOut',
- },
- },
-};
-
beforeEach(() => {
vi.spyOn(process, 'cwd').mockReturnValue('/app');
- vol.fromJSON(
- {
- ['./src/stories/Button.stories.ts']: dedent`
- import type { Meta, StoryObj } from '@storybook/react';
- import { fn } from 'storybook/test';
- import { Button } from './Button';
-
- const meta = {
- component: Button,
- args: { onClick: fn() },
- } satisfies Meta;
- export default meta;
- type Story = StoryObj;
-
- export const Primary: Story = { args: { primary: true, label: 'Button' } };
- export const Secondary: Story = { args: { label: 'Button' } };
- export const Large: Story = { args: { size: 'large', label: 'Button' } };
- export const Small: Story = { args: { size: 'small', label: 'Button' } };`,
- ['./src/stories/Button.tsx']: dedent`
- import React from 'react';
- export interface ButtonProps {
- /** Description of primary */
- primary?: boolean;
- backgroundColor?: string;
- size?: 'small' | 'medium' | 'large';
- label: string;
- onClick?: () => void;
- }
-
- /** Primary UI component for user interaction */
- export const Button = ({
- primary = false,
- size = 'medium',
- backgroundColor,
- label,
- ...props
- }: ButtonProps) => {
- const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
- return (
-
- );
- };`,
- ['./src/stories/Header.stories.ts']: dedent`
- import type { Meta, StoryObj } from '@storybook/react';
- import { fn } from 'storybook/test';
- import Header from './Header';
-
- /**
- * Description from meta and very long.
- * @summary Component summary
- * @import import { Header } from '@design-system/components/Header';
- */
- const meta = {
- component: Header,
- args: {
- onLogin: fn(),
- onLogout: fn(),
- onCreateAccount: fn(),
- }
- } satisfies Meta;
- export default meta;
- type Story = StoryObj;
- export const LoggedIn: Story = { args: { user: { name: 'Jane Doe' } } };
- export const LoggedOut: Story = {};
- `,
- ['./src/stories/Header.tsx']: dedent`
- import { Button } from './Button';
-
- export interface HeaderProps {
- user?: User;
- onLogin?: () => void;
- onLogout?: () => void;
- onCreateAccount?: () => void;
- }
-
- export default ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => (
-
- );`,
- },
- '/app'
- );
- return () => vol.reset();
+ vol.fromJSON(fsMocks, '/app');
});
test('componentManifestGenerator generates correct id, name, description and examples ', async () => {
- const generator = await componentManifestGenerator();
- const manifest = await generator({
+ const generator = await componentManifestGenerator(undefined, { configDir: '.storybook' } as any);
+ const manifest = await generator?.({
getIndex: async () => indexJson,
} as unknown as StoryIndexGenerator);
@@ -217,14 +26,19 @@ test('componentManifestGenerator generates correct id, name, description and exa
"description": "Primary UI component for user interaction",
"error": undefined,
"id": "example-button",
- "import": undefined,
- "jsDocTags": {},
+ "import": "import { Button } from \"@design-system/components/Button\";",
+ "jsDocTags": {
+ "import": [
+ "import { Button } from '@design-system/components/Button';",
+ ],
+ },
"name": "Button",
"path": "./src/stories/Button.stories.ts",
"reactDocgen": {
"actualName": "Button",
- "definedInFile": "/app/src/stories/Button.tsx",
- "description": "Primary UI component for user interaction",
+ "definedInFile": "./src/stories/Button.tsx",
+ "description": "Primary UI component for user interaction
+ @import import { Button } from '@design-system/components/Button';",
"displayName": "Button",
"exportName": "Button",
"methods": [],
@@ -299,20 +113,28 @@ test('componentManifestGenerator generates correct id, name, description and exa
},
"stories": [
{
+ "description": undefined,
"name": "Primary",
"snippet": "const Primary = () => ;",
+ "summary": undefined,
},
{
+ "description": undefined,
"name": "Secondary",
"snippet": "const Secondary = () => ;",
+ "summary": undefined,
},
{
+ "description": undefined,
"name": "Large",
"snippet": "const Large = () => ;",
+ "summary": undefined,
},
{
+ "description": undefined,
"name": "Small",
"snippet": "const Small = () => ;",
+ "summary": undefined,
},
],
"summary": undefined,
@@ -321,7 +143,7 @@ test('componentManifestGenerator generates correct id, name, description and exa
"description": "Description from meta and very long.",
"error": undefined,
"id": "example-header",
- "import": "import { Header } from '@design-system/components/Header';",
+ "import": "import { Header } from \"@design-system/components/Header\";",
"jsDocTags": {
"import": [
"import { Header } from '@design-system/components/Header';",
@@ -334,8 +156,8 @@ test('componentManifestGenerator generates correct id, name, description and exa
"path": "./src/stories/Header.stories.ts",
"reactDocgen": {
"actualName": "",
- "definedInFile": "/app/src/stories/Header.tsx",
- "description": "",
+ "definedInFile": "./src/stories/Header.tsx",
+ "description": "@import import { Header } from '@design-system/components/Header';",
"exportName": "default",
"methods": [],
"props": {
@@ -395,16 +217,20 @@ test('componentManifestGenerator generates correct id, name, description and exa
},
"stories": [
{
+ "description": undefined,
"name": "LoggedIn",
"snippet": "const LoggedIn = () => ;",
+ "summary": undefined,
},
{
+ "description": undefined,
"name": "LoggedOut",
"snippet": "const LoggedOut = () => ;",
+ "summary": undefined,
},
],
"summary": "Component summary",
@@ -418,6 +244,7 @@ test('componentManifestGenerator generates correct id, name, description and exa
async function getManifestForStory(code: string) {
vol.fromJSON(
{
+ ['./package.json']: JSON.stringify({ name: 'some-package' }),
['./src/stories/Button.stories.ts']: code,
['./src/stories/Button.tsx']: dedent`
import React from 'react';
@@ -441,7 +268,7 @@ async function getManifestForStory(code: string) {
'/app'
);
- const generator = await componentManifestGenerator();
+ const generator = await componentManifestGenerator(undefined, { configDir: '.storybook' } as any);
const indexJson = {
v: 5,
entries: {
@@ -459,11 +286,11 @@ async function getManifestForStory(code: string) {
},
};
- const manifest = await generator({
+ const manifest = await generator?.({
getIndex: async () => indexJson,
} as unknown as StoryIndexGenerator);
- return manifest.components['example-button'];
+ return manifest?.components?.['example-button'];
}
function withCSF3(body: string) {
@@ -497,13 +324,13 @@ test('fall back to index title when no component name', async () => {
"description": "Primary UI component for user interaction",
"error": undefined,
"id": "example-button",
- "import": undefined,
+ "import": "import { Button } from \"some-package\";",
"jsDocTags": {},
"name": "Button",
"path": "./src/stories/Button.stories.ts",
"reactDocgen": {
"actualName": "Button",
- "definedInFile": "/app/src/stories/Button.tsx",
+ "definedInFile": "./src/stories/Button.tsx",
"description": "Primary UI component for user interaction",
"displayName": "Button",
"exportName": "Button",
@@ -524,8 +351,10 @@ test('fall back to index title when no component name', async () => {
},
"stories": [
{
+ "description": undefined,
"name": "Primary",
"snippet": "const Primary = () => ;",
+ "summary": undefined,
},
],
"summary": undefined,
@@ -542,13 +371,13 @@ test('component exported from other file', async () => {
"description": "Primary UI component for user interaction",
"error": undefined,
"id": "example-button",
- "import": undefined,
+ "import": "import { Button } from "some-package";",
"jsDocTags": {},
"name": "Button",
"path": "./src/stories/Button.stories.ts",
"reactDocgen": {
"actualName": "Button",
- "definedInFile": "/app/src/stories/Button.tsx",
+ "definedInFile": "./src/stories/Button.tsx",
"description": "Primary UI component for user interaction",
"displayName": "Button",
"exportName": "Button",
@@ -594,13 +423,13 @@ test('unknown expressions', async () => {
"description": "Primary UI component for user interaction",
"error": undefined,
"id": "example-button",
- "import": undefined,
+ "import": "import { Button } from "some-package";",
"jsDocTags": {},
"name": "Button",
"path": "./src/stories/Button.stories.ts",
"reactDocgen": {
"actualName": "Button",
- "definedInFile": "/app/src/stories/Button.tsx",
+ "definedInFile": "./src/stories/Button.tsx",
"description": "Primary UI component for user interaction",
"displayName": "Button",
"exportName": "Button",
diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts
index 2f1a1df70f73..fee51c167004 100644
--- a/code/renderers/react/src/componentManifest/generator.ts
+++ b/code/renderers/react/src/componentManifest/generator.ts
@@ -1,25 +1,36 @@
-import { readFile } from 'node:fs/promises';
-
import { recast } from 'storybook/internal/babel';
-import { loadCsf } from 'storybook/internal/csf-tools';
-import { extractDescription } from 'storybook/internal/csf-tools';
-import { type ComponentManifestGenerator } from 'storybook/internal/types';
-import { type ComponentManifest } from 'storybook/internal/types';
+import { combineTags } from 'storybook/internal/csf';
+import { extractDescription, loadCsf } from 'storybook/internal/csf-tools';
+import { logger } from 'storybook/internal/node-logger';
+import {
+ type ComponentManifest,
+ type ComponentManifestGenerator,
+ type PresetPropertyFn,
+} from 'storybook/internal/types';
import path from 'pathe';
import { getCodeSnippet } from './generateCodeSnippet';
+import { getComponents, getImports } from './getComponentImports';
import { extractJSDocInfo } from './jsdocTags';
-import { type DocObj, getMatchingDocgen, parseWithReactDocgen } from './reactDocgen';
-import { groupBy, invariant } from './utils';
+import { type DocObj } from './reactDocgen';
+import { cachedFindUp, cachedReadFileSync, groupBy, invalidateCache, invariant } from './utils';
interface ReactComponentManifest extends ComponentManifest {
reactDocgen?: DocObj;
}
-export const componentManifestGenerator = async () => {
+export const componentManifestGenerator: PresetPropertyFn<
+ 'experimental_componentManifestGenerator'
+> = async () => {
return (async (storyIndexGenerator) => {
+ invalidateCache();
+
+ const startIndex = performance.now();
const index = await storyIndexGenerator.getIndex();
+ logger.verbose(`Story index generation took ${performance.now() - startIndex}ms`);
+
+ const startPerformance = performance.now();
const groupByComponentId = groupBy(
Object.values(index.entries)
@@ -30,117 +41,136 @@ export const componentManifestGenerator = async () => {
const singleEntryPerComponent = Object.values(groupByComponentId).flatMap((group) =>
group && group?.length > 0 ? [group[0]] : []
);
- const components = await Promise.all(
- singleEntryPerComponent.flatMap(async (entry): Promise => {
- const storyFile = await readFile(path.join(process.cwd(), entry.importPath), 'utf-8');
- const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse();
- const name = csf._meta?.component ?? entry.title.split('/').at(-1)!;
- const id = entry.id.split('--')[0];
- const importPath = entry.importPath;
-
- const stories = Object.keys(csf._stories)
- .map((storyName) => {
- try {
- return {
- name: storyName,
- snippet: recast.print(getCodeSnippet(csf, storyName, name)).code,
- };
- } catch (e) {
- invariant(e instanceof Error);
- return {
- name: storyName,
- error: { name: e.name, message: e.message },
- };
- }
- })
- .filter(Boolean);
-
- const base = {
- id,
- name,
- path: importPath,
- stories,
- jsDocTags: {},
- } satisfies Partial;
-
- if (!entry.componentPath) {
- const componentName = csf._meta?.component;
-
- const error = !componentName
- ? {
- name: 'No meta.component specified',
- message: 'Specify meta.component for the component to be included in the manifest.',
- }
- : {
- name: 'No component import found',
- message: `No component file found for the "${componentName}" component.`,
- };
- return {
- ...base,
- name,
- stories,
- error: {
- name: error.name,
- message:
- csf._metaStatementPath?.buildCodeFrameError(error.message).message ?? error.message,
- },
- };
- }
-
- let componentFile;
-
- try {
- componentFile = await readFile(path.join(process.cwd(), entry.componentPath!), 'utf-8');
- } catch (e) {
- invariant(e instanceof Error);
- return {
- ...base,
- name,
- stories,
- error: {
- name: 'Component file could not be read',
- message: `Could not read the component file located at "${entry.componentPath}".\nPrefer relative imports.`,
- },
- };
- }
-
- const docgens = await parseWithReactDocgen({
- code: componentFile,
- filename: path.join(process.cwd(), entry.componentPath),
- });
- const docgen = getMatchingDocgen(docgens, csf);
-
- const error = !docgen
- ? {
- name: 'Docgen evaluation failed',
- message:
- `Could not parse props information for the component file located at "${entry.componentPath}"\n` +
- `Avoid barrel files when importing your component file.\n` +
- `Prefer relative imports if possible.\n` +
- `Avoid pointing to transpiled files.\n` +
- `You can debug your component file in this playground: https://react-docgen.dev/playground`,
- }
+ const components = singleEntryPerComponent.map((entry): ReactComponentManifest | undefined => {
+ const absoluteImportPath = path.join(process.cwd(), entry.importPath);
+ const storyFile = cachedReadFileSync(absoluteImportPath, 'utf-8') as string;
+ const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse();
+
+ const manifestEnabled = csf.stories
+ .map((it) => combineTags('manifest', ...(csf.meta.tags ?? []), ...(it.tags ?? [])))
+ .some((it) => it.includes('manifest'));
+
+ if (!manifestEnabled) {
+ return;
+ }
+ const componentName = csf._meta?.component;
+
+ const id = entry.id.split('--')[0];
+ const importPath = entry.importPath;
+
+ const components = getComponents({ csf, storyFilePath: absoluteImportPath });
+
+ const trimmedTitle = entry.title.replace(/\s+/g, '');
+
+ const component = components.find((it) => {
+ return componentName
+ ? [it.componentName, it.localImportName, it.importName].includes(componentName)
+ : trimmedTitle.includes(it.componentName) ||
+ (it.localImportName && trimmedTitle.includes(it.localImportName)) ||
+ (it.importName && trimmedTitle.includes(it.importName));
+ });
+
+ const stories = Object.keys(csf._stories)
+ .map((storyName) => {
+ const story = csf._stories[storyName];
+
+ const manifestEnabled = combineTags(
+ 'manifest',
+ ...(csf.meta.tags ?? []),
+ ...(story.tags ?? [])
+ ).includes('manifest');
+
+ if (!manifestEnabled) {
+ return;
+ }
+ try {
+ const jsdocComment = extractDescription(csf._storyStatements[storyName]);
+ const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {};
+ const finalDescription = (tags?.describe?.[0] || tags?.desc?.[0]) ?? description;
+
+ return {
+ name: storyName,
+ snippet: recast.print(getCodeSnippet(csf, storyName, component?.componentName)).code,
+ description: finalDescription?.trim(),
+ summary: tags.summary?.[0],
+ };
+ } catch (e) {
+ invariant(e instanceof Error);
+ return {
+ name: storyName,
+ error: { name: e.name, message: e.message },
+ };
+ }
+ })
+ .filter((it) => it != null);
+
+ const nearestPkg = cachedFindUp('package.json', {
+ cwd: path.dirname(component?.path ?? absoluteImportPath),
+ });
+
+ let packageName;
+ try {
+ packageName = nearestPkg
+ ? JSON.parse(cachedReadFileSync(nearestPkg, 'utf-8') as string).name
: undefined;
+ } catch {}
+
+ const fallbackImport =
+ packageName && componentName ? `import { ${componentName} } from "${packageName}";` : '';
+
+ const imports = getImports({ components, packageName }).join('\n').trim() || fallbackImport;
- const metaDescription = extractDescription(csf._metaStatement);
- const jsdocComment = metaDescription || docgen?.description;
- const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {};
+ const title = entry.title.split('/').at(-1)!.replace(/\s+/g, '');
- const manifestDescription = (tags?.describe?.[0] || tags?.desc?.[0]) ?? description;
+ const base = {
+ id,
+ name: componentName ?? title,
+ path: importPath,
+ stories,
+ import: imports,
+ jsDocTags: {},
+ } satisfies Partial;
+ if (!component?.reactDocgen) {
+ const error = !component
+ ? {
+ name: 'No meta.component specified',
+ message: 'Specify meta.component for the component to be included in the manifest.',
+ }
+ : {
+ name: 'No component import found',
+ message: `No component file found for the "${component.componentName}" component.`,
+ };
return {
...base,
- name,
- description: manifestDescription?.trim(),
- summary: tags.summary?.[0],
- import: tags.import?.[0],
- reactDocgen: docgen,
- jsDocTags: tags,
- stories,
- error,
+ error: {
+ name: error.name,
+ message:
+ csf._metaStatementPath?.buildCodeFrameError(error.message).message ?? error.message,
+ },
};
- })
- );
+ }
+
+ const docgenResult = component.reactDocgen;
+
+ const docgen = docgenResult.type === 'success' ? docgenResult.data : undefined;
+ const error = docgenResult.type === 'error' ? docgenResult.error : undefined;
+
+ const jsdocComment = extractDescription(csf._metaStatement) || docgen?.description;
+ const { tags = {}, description } = jsdocComment ? extractJSDocInfo(jsdocComment) : {};
+
+ return {
+ ...base,
+ description: ((tags?.describe?.[0] || tags?.desc?.[0]) ?? description)?.trim(),
+ summary: tags.summary?.[0],
+ import: imports,
+ reactDocgen: docgen,
+ jsDocTags: tags,
+ error,
+ };
+ });
+
+ logger.verbose(`Component manifest generation took ${performance.now() - startPerformance}ms`);
return {
v: 0,
diff --git a/code/renderers/react/src/componentManifest/getComponentImports.test.ts b/code/renderers/react/src/componentManifest/getComponentImports.test.ts
new file mode 100644
index 000000000000..0d8da43def52
--- /dev/null
+++ b/code/renderers/react/src/componentManifest/getComponentImports.test.ts
@@ -0,0 +1,886 @@
+import { beforeEach, expect, test, vi } from 'vitest';
+
+import { loadCsf } from 'storybook/internal/csf-tools';
+
+import { vol } from 'memfs';
+import { dedent } from 'ts-dedent';
+
+import { fsMocks } from './fixtures';
+import { getImports as buildImports, getComponentData } from './getComponentImports';
+
+beforeEach(() => {
+ vi.spyOn(process, 'cwd').mockReturnValue('/app');
+ vol.fromJSON(fsMocks, '/app');
+});
+
+const getImports = (code: string, packageName?: string, storyFilePath?: string) =>
+ getComponentData({
+ csf: loadCsf(code, { makeTitle: (t?: string) => t ?? 'title' }).parse(),
+ packageName,
+ storyFilePath,
+ });
+
+test('Get imports from multiple components', () => {
+ const code = dedent`
+ import type { Meta } from '@storybook/react';
+ import { ButtonGroup } from '@design-system/button-group';
+ import { Button } from '@design-system/button';
+
+ const meta: Meta = {
+ component: Button,
+ args: {
+ children: 'Click me'
+ }
+ };
+ export default meta;
+ export const Default: Story = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@design-system/button",
+ "importName": "Button",
+ "localImportName": "Button",
+ },
+ {
+ "componentName": "ButtonGroup",
+ "importId": "@design-system/button-group",
+ "importName": "ButtonGroup",
+ "localImportName": "ButtonGroup",
+ },
+ ],
+ "imports": [
+ "import { Button } from "@design-system/button";",
+ "import { ButtonGroup } from "@design-system/button-group";",
+ ],
+ }
+ `
+ );
+});
+
+test('Namespace import with member usage', () => {
+ const code = dedent`
+ import * as Accordion from '@ds/accordion';
+
+ const meta = {};
+ export default meta;
+ export const S = Hi;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Accordion.Root",
+ "importId": "@ds/accordion",
+ "importName": "Root",
+ "localImportName": "Accordion",
+ "namespace": "Accordion",
+ },
+ ],
+ "imports": [
+ "import * as Accordion from "@ds/accordion";",
+ ],
+ }
+ `
+ );
+});
+
+test('Named import used as namespace object', () => {
+ const code = dedent`
+ import { Accordion } from '@ds/accordion';
+
+ const meta = {};
+ export default meta;
+ export const S = Hi;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Accordion.Root",
+ "importId": "@ds/accordion",
+ "importName": "Accordion",
+ "localImportName": "Accordion",
+ },
+ ],
+ "imports": [
+ "import { Accordion } from "@ds/accordion";",
+ ],
+ }
+ `
+ );
+});
+
+test('Default import', () => {
+ const code = dedent`
+ import Button from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "default",
+ "localImportName": "Button",
+ },
+ ],
+ "imports": [
+ "import Button from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Alias named import and meta.component inclusion', () => {
+ const code = dedent`
+ import DefaultComponent, { Button as Btn, Other } from '@ds/button';
+
+ const meta = { component: Btn };
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Btn",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Btn",
+ },
+ {
+ "componentName": "Other",
+ "importId": "@ds/button",
+ "importName": "Other",
+ "localImportName": "Other",
+ },
+ ],
+ "imports": [
+ "import { Button as Btn, Other } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Strip unused specifiers from the same import statement', () => {
+ const code = dedent`
+ import { Button as Btn, useSomeHook } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Btn",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Btn",
+ },
+ ],
+ "imports": [
+ "import { Button as Btn } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Meta component with member and star import', () => {
+ const code = dedent`
+ import * as Accordion from '@ds/accordion';
+
+ const meta = { component: Accordion.Root };
+ export default meta;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Accordion.Root",
+ "importId": "@ds/accordion",
+ "importName": "Root",
+ "localImportName": "Accordion",
+ "namespace": "Accordion",
+ },
+ ],
+ "imports": [
+ "import * as Accordion from "@ds/accordion";",
+ ],
+ }
+ `
+ );
+});
+
+test('Keeps multiple named specifiers and drops unused ones from same import', () => {
+ const code = dedent`
+ import { Button, ButtonGroup, useHook } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S =
;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Button",
+ },
+ {
+ "componentName": "ButtonGroup",
+ "importId": "@ds/button",
+ "importName": "ButtonGroup",
+ "localImportName": "ButtonGroup",
+ },
+ ],
+ "imports": [
+ "import { Button, ButtonGroup } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Mixed default + named import: keep only default when only default used', () => {
+ const code = dedent`
+ import Button, { useHook } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "default",
+ "localImportName": "Button",
+ },
+ ],
+ "imports": [
+ "import Button from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Mixed default + named import: keep only named when only named (alias) used', () => {
+ const code = dedent`
+ import Button, { Button as Btn } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Btn",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Btn",
+ },
+ ],
+ "imports": [
+ "import { Button as Btn } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Per-specifier type import is dropped when mixing with value specifiers', () => {
+ const code = dedent`
+ import type { Meta } from '@storybook/react';
+ import { type Meta as M, Button } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Button",
+ },
+ ],
+ "imports": [
+ "import { Button } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Namespace import used for multiple members kept once', () => {
+ const code = dedent`
+ import * as DS from '@ds/ds';
+
+ const meta = {};
+ export default meta;
+ export const S =
;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "DS.A",
+ "importId": "@ds/ds",
+ "importName": "A",
+ "localImportName": "DS",
+ "namespace": "DS",
+ },
+ {
+ "componentName": "DS.B",
+ "importId": "@ds/ds",
+ "importName": "B",
+ "localImportName": "DS",
+ "namespace": "DS",
+ },
+ ],
+ "imports": [
+ "import * as DS from "@ds/ds";",
+ ],
+ }
+ `
+ );
+});
+
+test('Default import kept when referenced only via meta.component', () => {
+ const code = dedent`
+ import Button from '@ds/button';
+
+ const meta = { component: Button };
+ export default meta;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "default",
+ "localImportName": "Button",
+ },
+ ],
+ "imports": [
+ "import Button from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Side-effect-only import is ignored', () => {
+ const code = dedent`
+ import '@ds/global.css';
+ import { Button } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Button",
+ },
+ ],
+ "imports": [
+ "import { Button } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+// New tests for packageName behavior
+
+test('Converts default relative import to import override when provided', () => {
+ const code = dedent`
+ import Header from './Header';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(
+ getImports(code, 'my-package', '/app/src/stories/Header.stories.tsx')
+ ).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Header",
+ "importId": "./Header",
+ "importName": "default",
+ "importOverride": "import { Header } from '@design-system/components/Header';",
+ "localImportName": "Header",
+ "path": "./src/stories/Header.tsx",
+ "reactDocgen": {
+ "data": {
+ "actualName": "",
+ "definedInFile": "./src/stories/Header.tsx",
+ "description": "@import import { Header } from '@design-system/components/Header';",
+ "exportName": "default",
+ "methods": [],
+ "props": {
+ "onCreateAccount": {
+ "description": "",
+ "required": false,
+ "tsType": {
+ "name": "signature",
+ "raw": "() => void",
+ "signature": {
+ "arguments": [],
+ "return": {
+ "name": "void",
+ },
+ },
+ "type": "function",
+ },
+ },
+ "onLogin": {
+ "description": "",
+ "required": false,
+ "tsType": {
+ "name": "signature",
+ "raw": "() => void",
+ "signature": {
+ "arguments": [],
+ "return": {
+ "name": "void",
+ },
+ },
+ "type": "function",
+ },
+ },
+ "onLogout": {
+ "description": "",
+ "required": false,
+ "tsType": {
+ "name": "signature",
+ "raw": "() => void",
+ "signature": {
+ "arguments": [],
+ "return": {
+ "name": "void",
+ },
+ },
+ "type": "function",
+ },
+ },
+ "user": {
+ "description": "",
+ "required": false,
+ "tsType": {
+ "name": "User",
+ },
+ },
+ },
+ },
+ "type": "success",
+ },
+ },
+ ],
+ "imports": [
+ "import { Header } from "@design-system/components/Header";",
+ ],
+ }
+ `
+ );
+});
+
+test('Keeps relative import when packageName is missing', () => {
+ const code = dedent`
+ import { Button } from './components/Button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "./components/Button",
+ "importName": "Button",
+ "localImportName": "Button",
+ },
+ ],
+ "imports": [
+ "import { Button } from "./components/Button";",
+ ],
+ }
+ `
+ );
+});
+
+test('Non-relative import remains unchanged even if packageName provided', () => {
+ const code = dedent`
+ import { Button } from '@ds/button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code, 'my-package')).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Button",
+ "importId": "@ds/button",
+ "importName": "Button",
+ "localImportName": "Button",
+ },
+ ],
+ "imports": [
+ "import { Button } from "@ds/button";",
+ ],
+ }
+ `
+ );
+});
+
+// Merging imports from same package
+
+test('Merges multiple imports from the same package (defaults and named)', () => {
+ const code = dedent`
+ import { CopilotIcon } from '@primer/octicons-react';
+ import { Banner } from "@primer/react";
+ import Link from "@primer/react";
+ import { Dialog } from "@primer/react";
+ import { Stack } from "@primer/react";
+ import { Heading } from "@primer/react";
+
+ const meta = {};
+ export default meta;
+ export const S =
;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Banner",
+ "importId": "@primer/react",
+ "importName": "Banner",
+ "localImportName": "Banner",
+ },
+ {
+ "componentName": "CopilotIcon",
+ "importId": "@primer/octicons-react",
+ "importName": "CopilotIcon",
+ "localImportName": "CopilotIcon",
+ },
+ {
+ "componentName": "Dialog",
+ "importId": "@primer/react",
+ "importName": "Dialog",
+ "localImportName": "Dialog",
+ },
+ {
+ "componentName": "Heading",
+ "importId": "@primer/react",
+ "importName": "Heading",
+ "localImportName": "Heading",
+ },
+ {
+ "componentName": "Link",
+ "importId": "@primer/react",
+ "importName": "default",
+ "localImportName": "Link",
+ },
+ {
+ "componentName": "Stack",
+ "importId": "@primer/react",
+ "importName": "Stack",
+ "localImportName": "Stack",
+ },
+ ],
+ "imports": [
+ "import Link, { Banner, Dialog, Heading, Stack } from "@primer/react";",
+ "import { CopilotIcon } from "@primer/octicons-react";",
+ ],
+ }
+ `
+ );
+});
+
+test('Handle namespace with default and separates named for same package', () => {
+ const code = dedent`
+ import * as PR from '@primer/react';
+ import { Banner } from '@primer/react';
+ import Link from '.';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Banner",
+ "importId": "@primer/react",
+ "importName": "Banner",
+ "localImportName": "Banner",
+ },
+ {
+ "componentName": "Link",
+ "importId": ".",
+ "importName": "default",
+ "localImportName": "Link",
+ },
+ {
+ "componentName": "PR.Box",
+ "importId": "@primer/react",
+ "importName": "Box",
+ "localImportName": "PR",
+ "namespace": "PR",
+ },
+ ],
+ "imports": [
+ "import * as PR from "@primer/react";",
+ "import { Banner } from "@primer/react";",
+ "import Link from ".";",
+ ],
+ }
+ `
+ );
+});
+
+test('Component not imported returns undefined importId and importName', () => {
+ const code = dedent`
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "Missing",
+ },
+ ],
+ "imports": [],
+ }
+ `
+ );
+});
+
+test('Namespace component not imported returns undefined importId and importName', () => {
+ const code = dedent`
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [
+ {
+ "componentName": "PR.Box",
+ },
+ ],
+ "imports": [],
+ }
+ `
+ );
+});
+
+test('Filters out locally defined components', () => {
+ const code = dedent`
+ const Local = () => ;
+
+ const meta = { component: Local };
+ export default meta;
+ export const S = ;
+ `;
+ expect(getImports(code)).toMatchInlineSnapshot(
+ `
+ {
+ "components": [],
+ "imports": [],
+ }
+ `
+ );
+});
+
+test('importOverride: default override forces default import (keeps local name)', () => {
+ const code = dedent`
+ import { Button } from './Button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ const csf = loadCsf(code, { makeTitle: (t) => t ?? 'No title' }).parse();
+ const base = getComponentData({
+ csf,
+ packageName: 'my-package',
+ storyFilePath: '/app/src/stories/Button.stories.tsx',
+ });
+ const patched = base.components.map((c) =>
+ c.componentName === 'Button' ? { ...c, importOverride: "import Button from '@pkg/button';" } : c
+ );
+ const out = buildImports({ components: patched, packageName: 'my-package' });
+ expect(out).toMatchInlineSnapshot(`
+ [
+ "import Button from \"@pkg/button\";",
+ ]
+ `);
+});
+
+test('importOverride: named override aliases imported to local name', () => {
+ const code = dedent`
+ import Button from './Button';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ const csf = loadCsf(code, { makeTitle: (t) => t ?? 'No title' }).parse();
+ const base = getComponentData({
+ csf,
+ packageName: 'pkg',
+ storyFilePath: '/app/src/stories/Button.stories.tsx',
+ });
+ const patched = base.components.map((c) =>
+ c.componentName === 'Button'
+ ? { ...c, importOverride: "import { DSButton } from '@pkg/button';" }
+ : c
+ );
+ const out = buildImports({ components: patched, packageName: 'pkg' });
+ expect(out).toMatchInlineSnapshot(`
+ [
+ "import { DSButton as Button } from \"@pkg/button\";",
+ ]
+ `);
+});
+
+test('importOverride: uses namespace override as-is', () => {
+ const code = dedent`
+ import * as UI from './ui';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ const csf = loadCsf(code, { makeTitle: (t) => t ?? 'No title' }).parse();
+ const discovered = getComponentData({
+ csf,
+ packageName: 'pkg',
+ storyFilePath: '/app/src/stories/ui.stories.tsx',
+ });
+ const patched = discovered.components.map((c) =>
+ c.componentName === 'UI.Button' ? { ...c, importOverride: "import * as UI from '@pkg/ui';" } : c
+ );
+ const out = buildImports({ components: patched, packageName: 'pkg' });
+ expect(out).toMatchInlineSnapshot(`
+ [
+ "import * as UI from \"@pkg/ui\";",
+ ]
+ `);
+});
+
+test('importOverride: malformed string is ignored and behavior falls back', () => {
+ const code = dedent`
+ import { Header } from './Header';
+
+ const meta = {};
+ export default meta;
+ export const S = ;
+ `;
+ const csf = loadCsf(code, { makeTitle: (t) => t ?? 'No title' }).parse();
+ const base = getComponentData({
+ csf,
+ packageName: 'pkg',
+ storyFilePath: '/app/src/stories/Header.stories.tsx',
+ });
+ const patched = base.components.map((c) =>
+ c.componentName === 'Header' ? { ...c, importOverride: 'import oops not valid' } : c
+ );
+ const out = buildImports({ components: patched, packageName: 'pkg' });
+ expect(out).toMatchInlineSnapshot(`
+ [
+ "import { Header } from \"pkg\";",
+ ]
+ `);
+});
+
+test('importOverride: merges multiple components into a single declaration per source', () => {
+ const code = dedent`
+ import Button from './Button';
+ import { Header } from './Header';
+
+ const meta = {};
+ export default meta;
+ export const A = ;
+ export const B = ;
+ `;
+ const csf = loadCsf(code, { makeTitle: (t) => t ?? 'No title' }).parse();
+ const base = getComponentData({
+ csf,
+ packageName: 'pkg',
+ storyFilePath: '/app/src/stories/multi.stories.tsx',
+ });
+ const patched = base.components.map((c) =>
+ c.componentName === 'Button'
+ ? { ...c, importOverride: "import { DSButton } from '@ds/ui';" }
+ : c.componentName === 'Header'
+ ? { ...c, importOverride: "import { Header } from '@ds/ui';" }
+ : c
+ );
+ const out = buildImports({ components: patched, packageName: 'pkg' });
+ expect(out).toMatchInlineSnapshot(`
+ [
+ "import { DSButton as Button, Header } from \"@ds/ui\";",
+ ]
+ `);
+});
diff --git a/code/renderers/react/src/componentManifest/getComponentImports.ts b/code/renderers/react/src/componentManifest/getComponentImports.ts
new file mode 100644
index 000000000000..259c810d159a
--- /dev/null
+++ b/code/renderers/react/src/componentManifest/getComponentImports.ts
@@ -0,0 +1,528 @@
+import { dirname } from 'node:path';
+
+import { type NodePath, babelParse, recast, types as t } from 'storybook/internal/babel';
+import { type CsfFile } from 'storybook/internal/csf-tools';
+import { logger } from 'storybook/internal/node-logger';
+
+import { getImportTag, getReactDocgen, matchPath } from './reactDocgen';
+import { cachedResolveImport } from './utils';
+
+// Public component metadata type used across passes
+export type ComponentRef = {
+ componentName: string;
+ localImportName?: string;
+ importId?: string;
+ importOverride?: string;
+ importName?: string;
+ namespace?: string;
+ path?: string;
+ reactDocgen?: ReturnType;
+};
+
+const baseIdentifier = (component: string) => component.split('.')[0] ?? component;
+
+const isTypeSpecifier = (
+ s: t.ImportSpecifier | t.ImportDefaultSpecifier | t.ImportNamespaceSpecifier
+) => t.isImportSpecifier(s) && s.importKind === 'type';
+
+const importedName = (im: t.Identifier | t.StringLiteral) =>
+ t.isIdentifier(im) ? im.name : im.value;
+
+const addUniqueBy = (arr: T[], item: T, eq: (a: T) => boolean) => {
+ if (!arr.find(eq)) {
+ arr.push(item);
+ }
+};
+
+/**
+ * Collects all React component references used by a CSF story file and resolves as much import and
+ * docgen information as possible.
+ *
+ * Behavior:
+ *
+ * - Scans the AST for JSX opening elements and meta.component to discover component identifiers.
+ * - Filters out components that are locally defined without an import (these are not public imports).
+ * - Maps local identifiers back to their import source/specifier when available.
+ * - Optionally resolves the absolute file path of each component import (using storyFilePath) and
+ * augments the result with react-docgen info and an import override tag when present.
+ *
+ * Notes:
+ *
+ * - Member expressions like Foo.Bar are supported; namespace imports are represented accordingly.
+ * - If react-docgen determines a package import override, it is stored in `importOverride`.
+ *
+ * @param csf The parsed CSF file instance whose AST will be inspected.
+ * @param storyFilePath Optional absolute path of the story file to resolve relative imports
+ * against.
+ * @returns An array of component references sorted by componentName.
+ * @public
+ */
+export const getComponents = ({
+ csf,
+ storyFilePath,
+}: {
+ csf: CsfFile;
+ storyFilePath?: string;
+}): ComponentRef[] => {
+ const program: NodePath = csf._file.path;
+
+ const componentSet = new Set();
+ const localToImport = new Map();
+
+ // Gather components from all JSX opening elements
+ program.traverse({
+ JSXOpeningElement(p) {
+ const n = p.node.name;
+ if (t.isJSXIdentifier(n)) {
+ const name = n.name;
+ if (name && /[A-Z]/.test(name.charAt(0))) {
+ componentSet.add(name);
+ }
+ } else if (t.isJSXMemberExpression(n)) {
+ const jsxNameToString = (name: t.JSXIdentifier | t.JSXMemberExpression): string =>
+ t.isJSXIdentifier(name)
+ ? name.name
+ : `${jsxNameToString(name.object)}.${jsxNameToString(name.property)}`;
+ const full = jsxNameToString(n);
+ componentSet.add(full);
+ }
+ },
+ });
+
+ // Add meta.component if present
+ const metaComp = csf._meta?.component;
+ if (metaComp) {
+ componentSet.add(metaComp);
+ }
+
+ const components = Array.from(componentSet).sort((a, b) => a.localeCompare(b));
+
+ const body = program.get('body');
+
+ // Collect import local bindings for component resolution (no package rewrite here)
+ for (const stmt of body) {
+ if (!stmt.isImportDeclaration()) {
+ continue;
+ }
+ const decl = stmt.node;
+
+ if (decl.importKind === 'type') {
+ continue;
+ }
+ const specifiers = decl.specifiers ?? [];
+
+ if (specifiers.length === 0) {
+ continue;
+ }
+
+ for (const s of specifiers) {
+ if (!('local' in s) || !s.local) {
+ continue;
+ }
+
+ if (isTypeSpecifier(s)) {
+ continue;
+ }
+
+ const importId = decl.source.value;
+ if (t.isImportDefaultSpecifier(s)) {
+ localToImport.set(s.local.name, { importId, importName: 'default' });
+ } else if (t.isImportNamespaceSpecifier(s)) {
+ localToImport.set(s.local.name, { importId, importName: '*' });
+ } else if (t.isImportSpecifier(s)) {
+ const imported = importedName(s.imported);
+ localToImport.set(s.local.name, { importId, importName: imported });
+ }
+ }
+ }
+
+ // Filter out locally defined components (those whose base identifier has a local, non-import binding)
+ const isLocallyDefinedWithoutImport = (base: string): boolean => {
+ const binding = program.scope.getBinding(base);
+
+ if (!binding) {
+ return false;
+ } // missing binding -> keep (will become null import) // missing binding -> keep (will become null import)
+ const isImportBinding = Boolean(
+ binding.path.isImportSpecifier?.() ||
+ binding.path.isImportDefaultSpecifier?.() ||
+ binding.path.isImportNamespaceSpecifier?.()
+ );
+ return !isImportBinding;
+ };
+
+ const filteredComponents = components.filter(
+ (c) => !isLocallyDefinedWithoutImport(baseIdentifier(c))
+ );
+
+ const componentObjs = filteredComponents
+ .map((c) => {
+ const dot = c.indexOf('.');
+ if (dot !== -1) {
+ const ns = c.slice(0, dot);
+ const member = c.slice(dot + 1);
+ const direct = localToImport.get(ns);
+ return !direct
+ ? { componentName: c }
+ : direct.importName === '*'
+ ? {
+ componentName: c,
+ localImportName: ns,
+ importId: direct.importId,
+ importName: member,
+ namespace: ns,
+ }
+ : {
+ componentName: c,
+ localImportName: ns,
+ importId: direct.importId,
+ importName: direct.importName,
+ };
+ }
+ const direct = localToImport.get(c);
+ return direct
+ ? {
+ componentName: c,
+ localImportName: c,
+ importId: direct.importId,
+ importName: direct.importName,
+ }
+ : { componentName: c };
+ })
+ .map((component) => {
+ let path;
+ try {
+ if (component.importId && storyFilePath) {
+ path = cachedResolveImport(matchPath(component.importId, dirname(storyFilePath)), {
+ basedir: dirname(storyFilePath),
+ });
+ }
+ } catch (e) {
+ logger.error(e);
+ }
+ if (path) {
+ const reactDocgen = getReactDocgen(path, component);
+ return {
+ ...component,
+ path,
+ reactDocgen,
+ importOverride:
+ reactDocgen.type === 'success' ? getImportTag(reactDocgen.data) : undefined,
+ };
+ }
+ return component;
+ })
+ .sort((a, b) => a.componentName.localeCompare(b.componentName));
+
+ return componentObjs;
+};
+
+/**
+ * Builds a minimal, deduplicated list of import declarations required for the given components.
+ *
+ * Behavior:
+ *
+ * - Components are grouped by their (possibly rewritten) source package/path.
+ * - If `packageName` is provided, relative imports are rewritten to that package name.
+ * - If a component provides `importOverride`, its source and specifier are respected.
+ * - Namespace imports are preserved unless a rewrite forces them to named members actually used.
+ * - Default imports rewritten to a package become named imports using their local identifier.
+ *
+ * Output order:
+ *
+ * - Buckets preserve first-seen order of sources to keep declarations stable between runs.
+ * - Within a bucket, namespace imports are emitted first (optionally coalesced with a default),
+ * followed by named-only, then any remaining defaults/namespaces one-per-declaration.
+ *
+ * @param components Component references to emit imports for. Only those with an importId are
+ * considered.
+ * @param packageName Optional package name to rewrite relative imports to.
+ * @returns An array of import declaration strings, formatted by recast.
+ * @public
+ */
+export const getImports = ({
+ components,
+ packageName,
+}: {
+ components: ComponentRef[];
+ packageName?: string;
+}): string[] => {
+ // Group by source (after potential rewrite)
+ type Bucket = {
+ source: t.StringLiteral;
+ defaults: t.Identifier[];
+ namespaces: t.Identifier[];
+ named: t.ImportSpecifier[];
+ order: number;
+ };
+
+ const isRelative = (id: string) => id.startsWith('.') || id === '.';
+
+ const withSource = components
+ .filter((c) => Boolean(c.importId))
+ .map((c, idx) => {
+ const importId = c.importId!;
+ // If an importOverride is provided (and not a namespace import), override only the package/source
+ const overrideSource = (() => {
+ if (!c.importOverride) {
+ return undefined;
+ }
+ try {
+ const parsed = babelParse(c.importOverride);
+ const decl = parsed.program.body.find((n) => t.isImportDeclaration(n)) as
+ | t.ImportDeclaration
+ | undefined;
+ const src = decl?.source?.value;
+ return typeof src === 'string' ? src : undefined;
+ } catch {
+ return undefined;
+ }
+ })();
+ const rewritten =
+ overrideSource !== undefined
+ ? overrideSource
+ : packageName && isRelative(importId)
+ ? packageName
+ : importId;
+ return { c, src: t.stringLiteral(rewritten), key: rewritten, ord: idx };
+ });
+
+ const orderOfSource: Record = {};
+ for (const w of withSource) {
+ if (orderOfSource[w.key] === undefined) {
+ orderOfSource[w.key] = w.ord;
+ }
+ }
+
+ const buckets = new Map();
+
+ const ensureBucket = (key: string, src: t.StringLiteral): Bucket => {
+ const prev = buckets.get(key);
+
+ if (prev) {
+ return prev;
+ }
+ const b: Bucket = {
+ source: src,
+ defaults: [],
+ namespaces: [],
+ named: [],
+ order: orderOfSource[key] ?? 0,
+ };
+ buckets.set(key, b);
+ return b;
+ };
+
+ for (const { c, src, key } of withSource) {
+ const b = ensureBucket(key, src);
+
+ // Determine if this bucket was rewritten
+ const rewritten = src.value !== c.importId;
+
+ // If an importOverride provides a concrete specifier (default, named, or namespace), respect it.
+ // Do not try to match locals beyond using the bucketed structure. For namespace, just emit as-is.
+ const overrideSpec = (() => {
+ if (!c.importOverride) {
+ return undefined;
+ }
+ try {
+ const parsed = babelParse(c.importOverride);
+ const decl = parsed.program.body.find((n) => t.isImportDeclaration(n)) as
+ | t.ImportDeclaration
+ | undefined;
+ if (!decl) {
+ return undefined;
+ }
+ const spec = (decl.specifiers ?? []).find((s) => !isTypeSpecifier(s as any));
+ if (!spec) {
+ return undefined;
+ }
+ if (t.isImportNamespaceSpecifier(spec)) {
+ return { kind: 'namespace' as const, local: spec.local.name };
+ }
+ if (t.isImportDefaultSpecifier(spec)) {
+ return { kind: 'default' as const };
+ }
+ if (t.isImportSpecifier(spec)) {
+ const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
+ return { kind: 'named' as const, imported };
+ }
+ return undefined;
+ } catch {
+ return undefined;
+ }
+ })();
+
+ if (overrideSpec) {
+ if (overrideSpec.kind === 'namespace') {
+ const ns = t.identifier(overrideSpec.local);
+ addUniqueBy(b.namespaces, ns, (n) => n.name === ns.name);
+ continue;
+ }
+ if (!c.localImportName) {
+ continue;
+ }
+ if (overrideSpec.kind === 'default') {
+ const id = t.identifier(c.localImportName);
+ addUniqueBy(b.defaults, id, (d) => d.name === id.name);
+ continue;
+ }
+ if (overrideSpec.kind === 'named') {
+ const local = t.identifier(c.localImportName);
+ const imported = t.identifier(overrideSpec.imported);
+ addUniqueBy(
+ b.named,
+ t.importSpecifier(local, imported),
+ (n) => n.local.name === local.name && importedName(n.imported) === imported.name
+ );
+ continue;
+ }
+ }
+
+ if (c.namespace) {
+ // Real namespace import usage (only present for `* as` imports)
+ if (rewritten) {
+ // Convert to named members actually used; require a concrete member name
+ if (!c.importName) {
+ continue;
+ }
+ const member = c.importName;
+ const id = t.identifier(member);
+ addUniqueBy(
+ b.named,
+ t.importSpecifier(id, id),
+ (n) => n.local.name === member && importedName(n.imported) === member
+ );
+ } else {
+ // Keep namespace import by base identifier once
+ const ns = t.identifier(c.namespace);
+ addUniqueBy(b.namespaces, ns, (n) => n.name === ns.name);
+ }
+ continue;
+ }
+
+ if (c.importName === 'default') {
+ // localImportName is only emitted for imported components; add a defensive guard for TS
+ if (!c.localImportName) {
+ continue;
+ }
+ if (rewritten) {
+ // default from relative becomes named using local identifier
+ const id = t.identifier(c.localImportName);
+ addUniqueBy(
+ b.named,
+ t.importSpecifier(id, id),
+ (n) => n.local.name === id.name && importedName(n.imported) === id.name
+ );
+ } else {
+ const id = t.identifier(c.localImportName);
+ addUniqueBy(b.defaults, id, (d) => d.name === id.name);
+ }
+ continue;
+ }
+
+ if (c.importName) {
+ // named import (including named used as namespace base)
+ if (!c.localImportName) {
+ continue;
+ }
+ const local = t.identifier(c.localImportName);
+ const imported = t.identifier(c.importName);
+ addUniqueBy(
+ b.named,
+ t.importSpecifier(local, imported),
+ (n) => n.local.name === local.name && importedName(n.imported) === imported.name
+ );
+ continue;
+ }
+ }
+
+ // Print merged declarations
+ const merged: string[] = [];
+ const printDecl = (
+ specs: (t.ImportDefaultSpecifier | t.ImportNamespaceSpecifier | t.ImportSpecifier)[],
+ src: t.StringLiteral
+ ) => {
+ const node = t.importDeclaration(specs, src);
+ const code = recast.print(node, {}).code;
+ merged.push(code);
+ };
+
+ const sortedBuckets = Array.from(buckets.values()).sort((a, b) => a.order - b.order);
+ for (const bucket of sortedBuckets) {
+ const { source, defaults, namespaces, named } = bucket;
+
+ if (defaults.length === 0 && namespaces.length === 0 && named.length === 0) {
+ continue;
+ }
+
+ if (namespaces.length > 0) {
+ const firstSpecs: (t.ImportDefaultSpecifier | t.ImportNamespaceSpecifier)[] = [];
+
+ if (defaults[0]) {
+ firstSpecs.push(t.importDefaultSpecifier(defaults[0]));
+ }
+ firstSpecs.push(t.importNamespaceSpecifier(namespaces[0]));
+ printDecl(firstSpecs, source);
+
+ if (named.length > 0) {
+ printDecl(named, source);
+ }
+
+ for (const d of defaults.slice(1)) {
+ printDecl([t.importDefaultSpecifier(d)], source);
+ }
+
+ for (const ns of namespaces.slice(1)) {
+ printDecl([t.importNamespaceSpecifier(ns)], source);
+ }
+ } else {
+ if (defaults.length > 0 || named.length > 0) {
+ const specs: (t.ImportDefaultSpecifier | t.ImportSpecifier)[] = [];
+
+ if (defaults[0]) {
+ specs.push(t.importDefaultSpecifier(defaults[0]));
+ }
+ specs.push(...named);
+ printDecl(specs, source);
+ }
+
+ for (const d of defaults.slice(1)) {
+ printDecl([t.importDefaultSpecifier(d)], source);
+ }
+ }
+ }
+
+ return merged;
+};
+
+/**
+ * Convenience helper that combines `getComponents` and `getImports` in one call.
+ *
+ * It first discovers component references from the CSF file and then derives the minimal set of
+ * import declarations for those components, applying the same rewrite/override rules as
+ * `getImports`.
+ *
+ * @param csf The parsed CSF file instance.
+ * @param packageName Optional package name used to rewrite relative imports.
+ * @param storyFilePath Optional absolute path of the story file for resolving component import
+ * paths.
+ * @returns An object containing the discovered components and the corresponding import statements.
+ * @public
+ */
+export function getComponentData({
+ csf,
+ packageName,
+ storyFilePath,
+}: {
+ csf: CsfFile;
+ packageName?: string;
+ storyFilePath?: string;
+}): {
+ components: ComponentRef[];
+ imports: string[];
+} {
+ const components = getComponents({ csf, storyFilePath });
+ const imports = getImports({ components, packageName });
+ return { components, imports };
+}
diff --git a/code/renderers/react/src/componentManifest/reactDocgen.test.ts b/code/renderers/react/src/componentManifest/reactDocgen.test.ts
index e17922bfde73..4e1bdee58294 100644
--- a/code/renderers/react/src/componentManifest/reactDocgen.test.ts
+++ b/code/renderers/react/src/componentManifest/reactDocgen.test.ts
@@ -6,7 +6,7 @@ import { parseWithReactDocgen } from './reactDocgen';
async function parse(code: string, name = 'Component.tsx') {
const filename = `/virtual/${name}`;
- return parseWithReactDocgen({ code, filename });
+ return parseWithReactDocgen(code, filename);
}
describe('parseWithReactDocgen exportName coverage', () => {
diff --git a/code/renderers/react/src/componentManifest/reactDocgen.ts b/code/renderers/react/src/componentManifest/reactDocgen.ts
index 8031d64e8a8b..45c457b26aa6 100644
--- a/code/renderers/react/src/componentManifest/reactDocgen.ts
+++ b/code/renderers/react/src/componentManifest/reactDocgen.ts
@@ -1,15 +1,12 @@
import { existsSync } from 'node:fs';
-import { sep } from 'node:path';
+import { dirname, sep } from 'node:path';
-import { types as t } from 'storybook/internal/babel';
-import { getProjectRoot } from 'storybook/internal/common';
-import { supportedExtensions } from 'storybook/internal/common';
-import { resolveImport } from 'storybook/internal/common';
-import { type CsfFile } from 'storybook/internal/csf-tools';
+import { babelParse, types as t } from 'storybook/internal/babel';
+import { getProjectRoot, supportedExtensions } from 'storybook/internal/common';
import * as find from 'empathic/find';
-import { type Documentation, ERROR_CODES } from 'react-docgen';
import {
+ type Documentation,
builtinHandlers as docgenHandlers,
builtinResolvers as docgenResolver,
makeFsImporter,
@@ -17,9 +14,12 @@ import {
} from 'react-docgen';
import * as TsconfigPaths from 'tsconfig-paths';
+import { type ComponentRef } from './getComponentImports';
+import { extractJSDocInfo } from './jsdocTags';
import actualNameHandler from './reactDocgen/actualNameHandler';
import { ReactDocgenResolveError } from './reactDocgen/docgenResolver';
import exportNameHandler from './reactDocgen/exportNameHandler';
+import { cached, cachedReadFileSync, cachedResolveImport } from './utils';
export type DocObj = Documentation & {
actualName: string;
@@ -32,72 +32,191 @@ const defaultHandlers = Object.values(docgenHandlers).map((handler) => handler);
const defaultResolver = new docgenResolver.FindExportedDefinitionsResolver();
const handlers = [...defaultHandlers, actualNameHandler, exportNameHandler];
-export function getMatchingDocgen(docgens: DocObj[], csf: CsfFile) {
- const componentName = csf._meta?.component;
+export function getMatchingDocgen(docgens: DocObj[], component: ComponentRef) {
if (docgens.length === 1) {
return docgens[0];
}
- const importSpecifier = csf._componentImportSpecifier;
-
- let importName: string;
- if (t.isImportSpecifier(importSpecifier)) {
- const imported = importSpecifier.imported;
- importName = t.isIdentifier(imported) ? imported.name : imported.value;
- } else if (t.isImportDefaultSpecifier(importSpecifier)) {
- importName = 'default';
- }
- const docgen = docgens.find((docgen) => docgen.exportName === importName);
- if (docgen || !componentName) {
- return docgen;
- }
- return docgens.find(
- (docgen) => docgen.displayName === componentName || docgen.actualName === componentName
- );
+ const matchingDocgen =
+ docgens.find((docgen) =>
+ [component.importName, component.localImportName].includes(docgen.exportName)
+ ) ??
+ docgens.find(
+ (docgen) =>
+ [component.importName, component.localImportName, component.componentName].includes(
+ docgen.displayName
+ ) ||
+ [component.importName, component.localImportName, component.componentName].includes(
+ docgen.actualName
+ )
+ );
+
+ return matchingDocgen ?? docgens[0];
}
-export async function parseWithReactDocgen({ code, filename }: { code: string; filename: string }) {
- const tsconfigPath = find.up('tsconfig.json', { cwd: process.cwd(), last: getProjectRoot() });
- const tsconfig = TsconfigPaths.loadConfig(tsconfigPath);
-
- let matchPath: TsconfigPaths.MatchPath | undefined;
+export function matchPath(id: string, basedir?: string) {
+ basedir ??= process.cwd();
+ const tsconfig = getTsConfig(basedir);
if (tsconfig.resultType === 'success') {
- matchPath = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [
+ const match = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [
'browser',
'module',
'main',
]);
+ return match(id, undefined, undefined, supportedExtensions) ?? id;
}
+ return id;
+}
- try {
+export const getTsConfig = cached(
+ (cwd: string) => {
+ const tsconfigPath = find.up('tsconfig.json', { cwd, last: getProjectRoot() });
+ return TsconfigPaths.loadConfig(tsconfigPath);
+ },
+ { name: 'getTsConfig' }
+);
+
+export const parseWithReactDocgen = cached(
+ (code: string, path: string) => {
return parse(code, {
resolver: defaultResolver,
handlers,
- importer: getReactDocgenImporter(matchPath),
- filename,
+ importer: getReactDocgenImporter(),
+ filename: path,
}) as DocObj[];
- } catch (e) {
- // Ignore the error when react-docgen cannot find a react component
- if (!(e instanceof Error && 'code' in e && e.code === ERROR_CODES.MISSING_DEFINITION)) {
- console.error(e);
+ },
+ { key: (code, path) => path, name: 'parseWithReactDocgen' }
+);
+
+const getExportPaths = cached(
+ (code: string, filePath: string) => {
+ const ast = (() => {
+ try {
+ return babelParse(code);
+ } catch (_) {
+ return undefined;
+ }
+ })();
+
+ if (!ast) {
+ return [] as string[];
}
- return [];
- }
-}
+ const basedir = dirname(filePath);
+ const body = ast.program.body;
+ return body
+ .flatMap((n) =>
+ t.isExportAllDeclaration(n)
+ ? [n.source.value]
+ : t.isExportNamedDeclaration(n) && !!n.source && !n.declaration
+ ? [n.source.value]
+ : []
+ )
+ .map((s) => matchPath(s, basedir))
+ .map((s) => {
+ try {
+ return cachedResolveImport(s, { basedir });
+ } catch {
+ return undefined;
+ }
+ })
+ .filter((p): p is string => !!p && !p.includes('node_modules'));
+ },
+ { name: 'getExportPaths' }
+);
+
+const gatherDocgensForPath = cached(
+ (
+ filePath: string,
+ depth: number
+ ): { docgens: DocObj[]; analyzed: { path: string; code: string }[] } => {
+ if (depth > 5 || filePath.includes('node_modules')) {
+ return { docgens: [], analyzed: [] };
+ }
+
+ let code: string | undefined;
+ try {
+ code = cachedReadFileSync(filePath, 'utf-8') as string;
+ } catch {}
-export function getReactDocgenImporter(matchPath: TsconfigPaths.MatchPath | undefined) {
+ if (!code) {
+ return { docgens: [], analyzed: [{ path: filePath, code: '/* File not found */' }] };
+ }
+
+ const reexportResults = getExportPaths(code, filePath).map((p) =>
+ gatherDocgensForPath(p, depth + 1)
+ );
+ const fromReexports = reexportResults.flatMap((r) => r.docgens);
+ const analyzedChildren = reexportResults.flatMap((r) => r.analyzed);
+
+ let locals: DocObj[];
+ try {
+ locals = parseWithReactDocgen(code as string, filePath);
+ } catch {
+ locals = [];
+ }
+
+ return {
+ docgens: [...locals, ...fromReexports],
+ analyzed: [{ path: filePath, code }, ...analyzedChildren],
+ };
+ },
+ { name: 'gatherDocgensWithTrace', key: (filePath) => filePath }
+);
+
+export const getReactDocgen = cached(
+ (
+ path: string,
+ component: ComponentRef
+ ):
+ | { type: 'success'; data: DocObj }
+ | { type: 'error'; error: { name: string; message: string } } => {
+ if (path.includes('node_modules')) {
+ return {
+ type: 'error',
+ error: {
+ name: 'Component file in node_modules',
+ message: `Component files in node_modules are not supported. Please import your component file directly.`,
+ },
+ };
+ }
+
+ const docgenWithInfo = gatherDocgensForPath(path, 0);
+ const docgens = docgenWithInfo.docgens;
+
+ const noCompDefError = {
+ type: 'error' as const,
+ error: {
+ name: 'No component definition found',
+ message:
+ `Could not find a component definition.\n` +
+ `Prefer relative imports if possible.\n` +
+ `Avoid pointing to transpiled files.\n` +
+ `You can debug your component file in this playground: https://react-docgen.dev/playground\n\n` +
+ docgenWithInfo.analyzed.map(({ path, code }) => `File: ${path}\n${code}`).join('\n'),
+ },
+ };
+
+ if (!docgens || docgens.length === 0) {
+ return noCompDefError;
+ }
+
+ const docgen = getMatchingDocgen(docgens, component);
+ if (!docgen) {
+ return noCompDefError;
+ }
+ return { type: 'success', data: docgen };
+ },
+ { name: 'getReactDocgen', key: (path, component) => path + JSON.stringify(component) }
+);
+
+export function getReactDocgenImporter() {
return makeFsImporter((filename, basedir) => {
const mappedFilenameByPaths = (() => {
- if (matchPath) {
- const match = matchPath(filename, undefined, undefined, supportedExtensions);
- return match || filename;
- } else {
- return filename;
- }
+ return matchPath(filename, basedir);
})();
- const result = resolveImport(mappedFilenameByPaths, { basedir });
+ const result = cachedResolveImport(mappedFilenameByPaths, { basedir });
if (result.includes(`${sep}react-native${sep}index.js`)) {
const replaced = result.replace(
@@ -117,3 +236,9 @@ export function getReactDocgenImporter(matchPath: TsconfigPaths.MatchPath | unde
throw new ReactDocgenResolveError(filename);
});
}
+
+export function getImportTag(docgen: DocObj) {
+ const jsdocComment = docgen?.description;
+ const tags = jsdocComment ? extractJSDocInfo(jsdocComment).tags : undefined;
+ return tags?.import?.[0];
+}
diff --git a/code/renderers/react/src/componentManifest/utils.test.ts b/code/renderers/react/src/componentManifest/utils.test.ts
new file mode 100644
index 000000000000..7b89145e8276
--- /dev/null
+++ b/code/renderers/react/src/componentManifest/utils.test.ts
@@ -0,0 +1,131 @@
+import { expect, test, vi } from 'vitest';
+
+import { cached, groupBy, invalidateCache, invariant } from './utils';
+
+// Helpers
+const calls = () => {
+ let n = 0;
+ return {
+ inc: () => ++n,
+ count: () => n,
+ };
+};
+
+test('groupBy groups items by key function', () => {
+ const items = [
+ { k: 'a', v: 1 },
+ { k: 'b', v: 2 },
+ { k: 'a', v: 3 },
+ ];
+ const grouped = groupBy(items, (it) => it.k);
+ expect(grouped).toMatchInlineSnapshot(`
+ {
+ "a": [
+ {
+ "k": "a",
+ "v": 1,
+ },
+ {
+ "k": "a",
+ "v": 3,
+ },
+ ],
+ "b": [
+ {
+ "k": "b",
+ "v": 2,
+ },
+ ],
+ }
+ `);
+});
+
+test('invariant throws only when condition is falsy and lazily evaluates message', () => {
+ const spy = vi.fn(() => 'Expensive message');
+
+ // True branch: does not throw and does not call message factory
+ expect(() => invariant(true, spy)).not.toThrow();
+ expect(spy).not.toHaveBeenCalled();
+
+ // False branch: throws and evaluates message lazily
+ expect(() => invariant(false, spy)).toThrowError('Expensive message');
+ expect(spy).toHaveBeenCalledTimes(1);
+});
+
+test('cached memoizes by default on first argument value', () => {
+ const c = calls();
+ const fn = (x: number) => (c.inc(), x * 2);
+ const m = cached(fn);
+
+ expect(m(2)).toBe(4);
+ expect(m(2)).toBe(4);
+ expect(m(3)).toBe(6);
+ expect(m(3)).toBe(6);
+
+ // Underlying function should have been called only once per distinct key (2 keys => 2 calls)
+ expect(c.count()).toBe(2);
+});
+
+test('cached supports custom key selector', () => {
+ const c = calls();
+ const fn = (x: number, y: number) => (c.inc(), x + y);
+ // Cache only by the first arg
+ const m = cached(fn, { key: (x) => `${x}` });
+
+ expect(m(1, 10)).toBe(11);
+ expect(m(1, 99)).toBe(11); // cached by key 1, result should be from first call
+ expect(m(2, 5)).toBe(7);
+ expect(m(2, 8)).toBe(7);
+
+ expect(c.count()).toBe(2);
+});
+
+test('cached stores and returns undefined results without recomputing', () => {
+ const c = calls();
+ const fn = (x: string) => {
+ c.inc();
+ return x === 'hit' ? undefined : x.toUpperCase();
+ };
+ const m = cached(fn);
+
+ expect(m('hit')).toBeUndefined();
+ expect(m('hit')).toBeUndefined();
+ expect(m('miss')).toBe('MISS');
+ expect(m('miss')).toBe('MISS');
+
+ expect(c.count()).toBe(2);
+});
+
+test('cached shares cache across wrappers of the same function', () => {
+ const c = calls();
+ const f = (x: string) => (c.inc(), x.length);
+
+ const m1 = cached(f, { key: (x) => x });
+ const m2 = cached(f, { key: (x) => x });
+
+ // First computes via m1 and caches the value 3 for key 'foo'
+ expect(m1('foo')).toBe(3);
+ // m2 should now return the cached value (from shared module store), not call f again
+ expect(m2('foo')).toBe(3);
+
+ // Verify call counts: underlying function called once
+ expect(c.count()).toBe(1);
+});
+
+test('invalidateCache clears the module-level memo store', () => {
+ const c = calls();
+ const f = (x: number) => (c.inc(), x * 2);
+ const m = cached(f);
+
+ expect(m(2)).toBe(4);
+ expect(c.count()).toBe(1);
+
+ // Cached result
+ expect(m(2)).toBe(4);
+ expect(c.count()).toBe(1);
+
+ // Invalidate and ensure it recomputes
+ invalidateCache();
+ expect(m(2)).toBe(4);
+ expect(c.count()).toBe(2);
+});
diff --git a/code/renderers/react/src/componentManifest/utils.ts b/code/renderers/react/src/componentManifest/utils.ts
index bd61797f98ad..b83e83e8eb04 100644
--- a/code/renderers/react/src/componentManifest/utils.ts
+++ b/code/renderers/react/src/componentManifest/utils.ts
@@ -1,11 +1,20 @@
// Object.groupBy polyfill
+import { readFileSync } from 'node:fs';
+
+import { resolveImport } from 'storybook/internal/common';
+import { logger } from 'storybook/internal/node-logger';
+
+import * as find from 'empathic/find';
+
export const groupBy = (
items: T[],
keySelector: (item: T, index: number) => K
) => {
return items.reduce>>((acc = {}, item, index) => {
const key = keySelector(item, index);
- acc[key] ??= [];
+ if (!Array.isArray(acc[key])) {
+ acc[key] = [];
+ }
acc[key].push(item);
return acc;
}, {});
@@ -21,3 +30,64 @@ export function invariant(
}
throw new Error((typeof message === 'function' ? message() : message) ?? 'Invariant failed');
}
+
+// Module-level cache store: per-function caches keyed by derived string keys
+let memoStore: WeakMap<(...args: any[]) => any, Map> = new WeakMap();
+
+// Generic cache/memoization helper (synchronous only)
+// - Caches by a derived key from the function arguments (must be a string)
+// - Supports caching of `undefined` results (uses Map.has to distinguish)
+// - Uses module-level store so multiple wrappers around the same function share cache
+export const cached = (
+ fn: (...args: A) => R,
+ opts: { key?: (...args: A) => string; name?: string } = {}
+): ((...args: A) => R) => {
+ const keyOf: (...args: A) => string =
+ opts.key ??
+ ((...args: A) => {
+ try {
+ // Prefer a stable string key based on the full arguments list
+ return JSON.stringify(args);
+ } catch {
+ // Fallback: use the first argument if it is not serializable
+ return String(args[0]);
+ }
+ });
+
+ return (...args: A) => {
+ const k = keyOf(...args);
+ const name = fn.name || opts.name || 'anonymous';
+
+ // Ensure store exists for this function
+ let store = memoStore.get(fn);
+ if (!store) {
+ store = new Map();
+ memoStore.set(fn, store);
+ }
+
+ // Fast path: cached
+ if (store.has(k)) {
+ logger.verbose(`[cache] hit ${name} key=${k}`);
+ return store.get(k) as R;
+ }
+
+ // Compute result with benchmarking
+ const start = Date.now();
+ const result = fn(...args);
+ const duration = Date.now() - start;
+ store.set(k, result as unknown);
+ logger.verbose(`[cache] miss ${name} took ${duration}ms key=${k}`);
+ return result;
+ };
+};
+
+export const invalidateCache = () => {
+ // Reinitialize the module-level store
+ memoStore = new WeakMap();
+};
+
+export const cachedReadFileSync = cached(readFileSync, { name: 'cachedReadFile' });
+
+export const cachedFindUp = cached(find.up, { name: 'findUp' });
+
+export const cachedResolveImport = cached(resolveImport, { name: 'resolveImport' });
diff --git a/code/renderers/react/src/enrichCsf.test.ts b/code/renderers/react/src/enrichCsf.test.ts
index ed25d1de06a3..38b998782744 100644
--- a/code/renderers/react/src/enrichCsf.test.ts
+++ b/code/renderers/react/src/enrichCsf.test.ts
@@ -1,6 +1,4 @@
-import { register } from 'node:module';
-
-import { beforeEach, expect, test, vi } from 'vitest';
+import { expect, test, vi } from 'vitest';
import { generate } from 'storybook/internal/babel';
import { type InterPresetOptions, getPresets } from 'storybook/internal/common';
@@ -10,15 +8,10 @@ import { dedent } from 'ts-dedent';
import { enrichCsf } from './enrichCsf';
-vi.mock('node:module', { spy: true });
vi.mock('my-preset', () => ({
default: { experimental_enrichCsf: enrichCsf, features: { experimentalCodeExamples: true } },
}));
-beforeEach(() => {
- vi.mocked(register).mockImplementation(() => {});
-});
-
test('should enrich csf with code parameters', async () => {
const presets = await getPresets(['my-preset'], { isCritical: true } as InterPresetOptions);
const enrichCsf = await presets.apply('experimental_enrichCsf');
diff --git a/code/renderers/react/src/extractArgTypes.test.ts b/code/renderers/react/src/extractArgTypes.test.ts
index 2d10e00c6bca..4bfebf36a2d7 100644
--- a/code/renderers/react/src/extractArgTypes.test.ts
+++ b/code/renderers/react/src/extractArgTypes.test.ts
@@ -1,7 +1,6 @@
-import { readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { normalizeNewlines } from 'storybook/internal/docs-tools';
import type { Renderer } from 'storybook/internal/types';
@@ -60,13 +59,16 @@ const skippedTests = [
'js-proptypes',
];
-describe('react component properties', () => {
+const fs = await vi.importActual('node:fs');
+
+describe('react component properties', async () => {
// Fixture files are in template/stories
const fixturesDir = resolve(__dirname, '../template/stories/docgen-components');
- readdirSync(fixturesDir, { withFileTypes: true }).forEach((testEntry) => {
+
+ fs.readdirSync(fixturesDir, { withFileTypes: true }).forEach((testEntry) => {
if (testEntry.isDirectory()) {
const testDir = join(fixturesDir, testEntry.name);
- const testFile = readdirSync(testDir).find((fileName) => inputRegExp.test(fileName));
+ const testFile = fs.readdirSync(testDir).find((fileName) => inputRegExp.test(fileName));
if (testFile) {
if (skippedTests.includes(testEntry.name)) {
it.skip(`${testEntry.name}`, () => {});
diff --git a/code/renderers/react/vitest.setup.ts b/code/renderers/react/vitest.setup.ts
index 3245d9759235..cc54490c79e7 100644
--- a/code/renderers/react/vitest.setup.ts
+++ b/code/renderers/react/vitest.setup.ts
@@ -1,6 +1,48 @@
-import { afterEach, vi } from 'vitest';
+import { register } from 'node:module';
-afterEach(() => {
- // can not run in beforeEach because then all { spy: true } mocks get removed
- vi.restoreAllMocks();
+import { beforeEach, vi } from 'vitest';
+
+import { type JsPackageManager, JsPackageManagerFactory } from 'storybook/internal/common';
+
+import { vol } from 'memfs';
+import { loadConfig } from 'tsconfig-paths';
+
+import { cachedFindUp, cachedResolveImport, invalidateCache } from './src/componentManifest/utils';
+
+vi.mock('node:fs/promises', async () => {
+ const fs = (await import('memfs')).fs.promises;
+ return { default: fs, ...fs };
+});
+vi.mock('node:fs', async () => {
+ const fs = (await import('memfs')).fs;
+ return { default: fs, ...fs };
+});
+
+vi.mock('node:module', { spy: true });
+
+vi.mock(import('./src/componentManifest/utils'), { spy: true });
+vi.mock('storybook/internal/common', { spy: true });
+vi.mock('empathic/find', { spy: true });
+vi.mock('tsconfig-paths', { spy: true });
+
+beforeEach(() => {
+ vol.reset();
+ vi.resetAllMocks();
+ invalidateCache();
+
+ vi.mocked(loadConfig).mockImplementation(() => ({ resultType: 'failed' as const, message: '' }));
+ vi.mocked(cachedFindUp).mockImplementation(() => '/app/package.json');
+ vi.mocked(JsPackageManagerFactory.getPackageManager).mockImplementation(
+ () =>
+ ({
+ primaryPackageJson: { packageJson: { name: 'some-package' } },
+ }) as unknown as JsPackageManager
+ );
+ vi.mocked(cachedResolveImport).mockImplementation((id) => {
+ return {
+ './Button': './src/stories/Button.tsx',
+ './Header': './src/stories/Header.tsx',
+ }[id]!;
+ });
+ vi.mocked(register).mockImplementation(() => {});
});
diff --git a/code/vitest-setup.ts b/code/vitest-setup.ts
index a13755321eb5..c0b325e01289 100644
--- a/code/vitest-setup.ts
+++ b/code/vitest-setup.ts
@@ -90,6 +90,7 @@ vi.mock('storybook/internal/node-logger', async (importOriginal) => {
info: vi.fn(),
trace: vi.fn(),
debug: vi.fn(),
+ verbose: vi.fn(),
logBox: vi.fn(),
intro: vi.fn(),
outro: vi.fn(),
diff --git a/code/yarn.lock b/code/yarn.lock
index d6028e53f751..d324b60ab7ad 100644
--- a/code/yarn.lock
+++ b/code/yarn.lock
@@ -6824,6 +6824,7 @@ __metadata:
acorn-walk: "npm:^7.2.0"
babel-plugin-react-docgen: "npm:^4.2.1"
comment-parser: "npm:^1.4.1"
+ empathic: "npm:^2.0.0"
es-toolkit: "npm:^1.36.0"
escodegen: "npm:^2.1.0"
expect-type: "npm:^0.15.0"
diff --git a/docs/_snippets/storybook-disable-telemetry-env.md b/docs/_snippets/storybook-disable-telemetry-env.md
index a6b2a0a94a45..a314e0bb2885 100644
--- a/docs/_snippets/storybook-disable-telemetry-env.md
+++ b/docs/_snippets/storybook-disable-telemetry-env.md
@@ -1,3 +1,3 @@
```shell renderer="common" language="js"
-STORYBOOK_DISABLE_TELEMETRY=1 yarn storybook
+STORYBOOK_DISABLE_TELEMETRY=true yarn storybook
```
diff --git a/docs/addons/addon-migration-guide.mdx b/docs/addons/addon-migration-guide.mdx
index 14a4fcb1f250..ca15e5876ca3 100644
--- a/docs/addons/addon-migration-guide.mdx
+++ b/docs/addons/addon-migration-guide.mdx
@@ -104,7 +104,7 @@ export default defineConfig(async (options) => {
+ const packageJson = (
+ await import("./package.json", { with: { type: "json" } })
+ ).default;
-+
++
const {
bundler: {
- exportEntries = [],
@@ -131,7 +131,7 @@ export default defineConfig(async (options) => {
};
const configs: Options[] = [];
--
+-
- // export entries are entries meant to be manually imported by the user
- // they are not meant to be loaded by the manager or preview
- // they'll be usable in both node and browser environments, depending on which features and modules they depend on
@@ -225,13 +225,18 @@ Update `tsconfig.json`.
```diff title="tsconfig.json"
{
"compilerOptions": {
- // ...
-- "target": "es2023",
+ // …
++ "moduleResolution": "bundler",
+ // …
+- "module": "commonjs",
++ "module": "preserve",
+ // …
+- "target": "ES2020",
+ "target": "esnext",
- // ...
-- "lib": ["es2023", "dom", "dom.iterable"],
+ // …
+- "lib": ["es2020", "dom", "dom.iterable"],
+ "lib": ["esnext", "dom", "dom.iterable"],
- // ...
+ // …
- "rootDir": "./src",
+ "rootDir": ".",
},
@@ -292,7 +297,7 @@ With CSF Factories, users can chain their preview configuration and benefit from
- export default {};
+ import { definePreviewAddon } from "storybook/internal/csf";
+ import addonAnnotations from "./preview";
-+
++
+ export default () => definePreviewAddon(addonAnnotations);
```
@@ -356,7 +361,7 @@ For a full list of changes, please visit the [Migration.md](https://github.com/s
## Migration example
-For a complete example of an addon updated to support Storybook 10.0, refer to the [Addon Kit migration PR](https://github.com/storybookjs/addon-kit/pull/82).
+For a complete example of an addon updated to support Storybook 10.0, refer to the [Addon Kit migration PR](https://github.com/storybookjs/addon-kit/pull/82).
Once merged, it will demonstrate all the necessary and recommended changes for Storybook 10.
## Releasing
diff --git a/docs/configure/user-interface/theming.mdx b/docs/configure/user-interface/theming.mdx
index d1b78d791399..4af2f214d2fd 100644
--- a/docs/configure/user-interface/theming.mdx
+++ b/docs/configure/user-interface/theming.mdx
@@ -11,7 +11,7 @@ Storybook is theme-able using a lightweight theming API.
It's possible to theme Storybook globally.
-Storybook includes two themes that look good out of the box: "light" and "dark". Unless you've set your preferred color scheme as dark, Storybook will use the light theme as default.
+Storybook includes a set of built-in themes that you can use to customize the appearance of your Storybook UI. The built-in themes are light, dark, and the "normal" theme that matches your preferred color scheme. Unless you specify otherwise, Storybook uses the normal theme by default.
As an example, you can tell Storybook to use the "dark" theme by modifying [`.storybook/manager.js`](./features-and-behavior.mdx):
@@ -25,7 +25,7 @@ When setting a theme, set a complete theme object. The theme is replaced, not co
## Theming docs
-[Storybook Docs](../../writing-docs/index.mdx) uses the same theme system as Storybook’s UI but is themed independently from the main UI.
+[Storybook Docs](../../writing-docs/index.mdx) uses the same theme system as Storybook’s UI but is themed independently from the main UI. The default theme for Docs is always the "light" theme, regardless of the main UI theme.
Supposing you have a Storybook theme defined for the main UI in [`.storybook/manager.js`](./features-and-behavior.mdx):
diff --git a/docs/versions/next.json b/docs/versions/next.json
index 45b732eaf1d1..d63c0fed6ae8 100644
--- a/docs/versions/next.json
+++ b/docs/versions/next.json
@@ -1 +1 @@
-{"version":"10.1.0-alpha.4","info":{"plain":"- Core: Better handling for TypeScript satisfies/as syntaxes - [#32891](https://github.com/storybookjs/storybook/pull/32891), thanks @yannbf!\n- Core: Fix wrong import to fix Yarn PnP support - [#32928](https://github.com/storybookjs/storybook/pull/32928), thanks @yannbf!\n- ESlint: Update `@storybook/experimental-nextjs-vite` in `no-renderer-packages` rule - [#32909](https://github.com/storybookjs/storybook/pull/32909), thanks @ndelangen!\n- React Native: Update withStorybook setup instructions - [#32919](https://github.com/storybookjs/storybook/pull/32919), thanks @dannyhw!\n- React: Change examples to stories in manifests and show correct examples and prop types - [#32908](https://github.com/storybookjs/storybook/pull/32908), thanks @kasperpeulen!"}}
\ No newline at end of file
+{"version":"10.1.0-alpha.5","info":{"plain":"- CLI: Fix issue with running Storybook after being initialized - [#32929](https://github.com/storybookjs/storybook/pull/32929), thanks @yannbf!\n- CRA: Fix `module` not defined in ESM - [#32940](https://github.com/storybookjs/storybook/pull/32940), thanks @ndelangen!\n- React: Improve automatic component, automatic imports, support barrel files and enhance story filtering - [#32939](https://github.com/storybookjs/storybook/pull/32939), thanks @kasperpeulen!\n- Theming: Set `themes.normal` according to user preference and export `getPreferredColorScheme` - [#28721](https://github.com/storybookjs/storybook/pull/28721), thanks @elisezhg!"}}
\ No newline at end of file