diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts
index 6692ea1881b4..ea3bfc2d51c8 100644
--- a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts
+++ b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts
@@ -1956,6 +1956,31 @@ describe('StoryIndexGenerator', () => {
}
`);
});
+
+ it('puts the Meta of stories file first in storiesImports even when it is not the last import', async () => {
+ const csfSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(
+ './src/*.stories.(js|ts)',
+ options
+ );
+
+ const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(
+ './complex/MetaOfImportOrder.mdx',
+ options
+ );
+
+ const generator = new StoryIndexGenerator([csfSpecifier, docsSpecifier], options);
+ await generator.initialize();
+
+ const { storyIndex } = await generator.getIndexAndStats();
+ const docsEntry = storyIndex.entries['a--metaofimportorder'];
+
+ expect(docsEntry).toMatchObject({
+ type: 'docs',
+ title: 'A',
+ importPath: './complex/MetaOfImportOrder.mdx',
+ storiesImports: ['./src/A.stories.js', './src/B.stories.ts'],
+ });
+ });
});
describe('errors', () => {
diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts
index 11a7b9d4981a..6882ffdce05e 100644
--- a/code/core/src/core-server/utils/StoryIndexGenerator.ts
+++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts
@@ -553,6 +553,8 @@ export class StoryIndexGenerator {
let csfEntry: StoryIndexEntryWithExtra | undefined;
if (result.of) {
const absoluteOf = makeAbsolute(result.of, normalizedPath, this.options.workingDir);
+ let metaDependency: StoriesCacheEntry | undefined;
+
dependencies.forEach((dep) => {
if (dep.entries.length > 0) {
const first = dep.entries.find((e) => e.type !== 'docs') as StoryIndexEntryWithExtra;
@@ -563,12 +565,18 @@ export class StoryIndexGenerator {
)
) {
csfEntry = first;
+ metaDependency = dep;
}
}
-
- sortedDependencies = [dep, ...dependencies.filter((d) => d !== dep)];
});
+ if (metaDependency) {
+ sortedDependencies = [
+ metaDependency,
+ ...dependencies.filter((d) => d !== metaDependency),
+ ];
+ }
+
invariant(
csfEntry,
dedent`
diff --git a/code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx b/code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx
new file mode 100644
index 000000000000..2774c1863d95
--- /dev/null
+++ b/code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx
@@ -0,0 +1,9 @@
+{/* References BStories first, but is attached to A */}
+import * as BStories from '../src/B.stories';
+import * as AStories from '../src/A.stories';
+
+
+
+# This file references two story files
+
+It is important that A.stories is the first listed in `storiesImports` even when it is not the first import.
\ No newline at end of file
diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts
index a9140d47e4b1..71473530eab5 100644
--- a/code/renderers/react/src/componentManifest/generator.test.ts
+++ b/code/renderers/react/src/componentManifest/generator.test.ts
@@ -578,6 +578,101 @@ test('should create component manifest when only attached-mdx docs have manifest
`);
});
+test('should prefer story entries over attached-mdx docs entries for the same component id', async () => {
+ vol.fromJSON(
+ {
+ ['./package.json']: JSON.stringify({ name: 'some-package' }),
+ ['./src/Primary/Primary.stories.tsx']: dedent`
+ import type { Meta } from '@storybook/react';
+ import { Primary } from './Primary';
+
+ const meta = {
+ title: 'Example/Primary',
+ component: Primary,
+ } satisfies Meta;
+ export default meta;
+
+ export const Default = () => ;
+ `,
+ ['./src/Primary/Primary.tsx']: dedent`
+ import React from 'react';
+
+ export interface PrimaryProps {
+ title: string;
+ }
+
+ /** Primary component description */
+ export const Primary = ({ title }: PrimaryProps) => {title}
;
+ `,
+ ['./src/OtherFile/OtherFile.stories.tsx']: dedent`
+ import type { Meta } from '@storybook/react';
+ import { OtherFile } from './OtherFile';
+
+ const meta = {
+ title: 'Example/Other File',
+ component: OtherFile,
+ } satisfies Meta;
+ export default meta;
+
+ export const Default = () => ;
+ `,
+ ['./src/OtherFile/OtherFile.tsx']: dedent`
+ import React from 'react';
+
+ export interface OtherFileProps {
+ label: string;
+ }
+
+ /** Other file component description */
+ export const OtherFile = ({ label }: OtherFileProps) => (
+
+ );
+ `,
+ },
+ '/app'
+ );
+
+ const manifestEntries = [
+ {
+ type: 'docs',
+ id: 'example-primary--docs',
+ name: 'Docs',
+ title: 'Example/Primary',
+ importPath: './src/Primary/Primary.mdx',
+ tags: [Tag.DEV, Tag.TEST, Tag.MANIFEST, Tag.ATTACHED_MDX],
+ storiesImports: [
+ './src/OtherFile/OtherFile.stories.tsx',
+ './src/Primary/Primary.stories.tsx',
+ ],
+ },
+ {
+ type: 'story',
+ subtype: 'story',
+ id: 'example-primary--default',
+ name: 'Default',
+ title: 'Example/Primary',
+ importPath: './src/Primary/Primary.stories.tsx',
+ componentPath: './src/Primary/Primary.tsx',
+ tags: [Tag.DEV, Tag.TEST, Tag.MANIFEST],
+ exportName: 'Default',
+ },
+ ];
+
+ const result = await manifests(undefined, { manifestEntries } as any);
+
+ const component = result?.components?.components?.['example-primary'];
+
+ expect(component?.name).toBe('Primary');
+ expect(component?.path).toBe('./src/Primary/Primary.stories.tsx');
+ expect(component?.stories).toMatchObject([
+ {
+ id: 'example-primary--default',
+ name: 'Default',
+ },
+ ]);
+ expect(component?.stories[0]?.snippet).toContain(' {
vol.fromJSON(
{
diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts
index cab8ceea09f2..2c613863c048 100644
--- a/code/renderers/react/src/componentManifest/generator.ts
+++ b/code/renderers/react/src/componentManifest/generator.ts
@@ -9,7 +9,6 @@ import {
type StorybookConfigRaw,
} from 'storybook/internal/types';
-import { uniqBy } from 'es-toolkit/array';
import path from 'pathe';
import { getCodeSnippet } from './generateCodeSnippet';
@@ -24,6 +23,41 @@ interface ReactComponentManifest extends ComponentManifest {
reactDocgenTypescript?: ComponentDocWithExportName;
}
+function selectComponentEntries(manifestEntries: IndexEntry[]) {
+ const entriesByComponentId = new Map();
+
+ manifestEntries
+ .filter(
+ (entry) =>
+ (entry.type === 'story' && entry.subtype === 'story') ||
+ // Attached docs entries are the only docs entries that can contribute to a
+ // component manifest, because they point back to a story file through storiesImports.
+ (entry.type === 'docs' &&
+ entry.tags?.includes(Tag.ATTACHED_MDX) &&
+ entry.storiesImports.length > 0)
+ )
+ .forEach((entry) => {
+ const componentId = entry.id.split('--')[0];
+ const existingEntry = entriesByComponentId.get(componentId);
+
+ if (!existingEntry) {
+ // Keep the first eligible entry as a fallback so docs-only manifest coverage
+ // continues to work when no story entry for that component carries the manifest tag.
+ entriesByComponentId.set(componentId, entry);
+ return;
+ }
+
+ if (existingEntry.type === 'docs' && entry.type === 'story') {
+ // When both entries exist for the same component id, the story entry is authoritative.
+ // Attached docs may list unrelated stories first in storiesImports, so using the story
+ // entry avoids resolving the manifest from the wrong file.
+ entriesByComponentId.set(componentId, entry);
+ }
+ });
+
+ return [...entriesByComponentId.values()];
+}
+
function findMatchingComponent(
components: ReturnType,
componentName: string | undefined,
@@ -114,18 +148,7 @@ export const manifests: PresetPropertyFn<
const startTime = performance.now();
- const entriesByUniqueComponent = uniqBy(
- manifestEntries.filter(
- (entry) =>
- (entry.type === 'story' && entry.subtype === 'story') ||
- // addon-docs will add docs entries to these manifest entries afterwards
- // Docs entries have importPath pointing to MDX file, but storiesImports[0] points to the story file
- (entry.type === 'docs' &&
- entry.tags?.includes(Tag.ATTACHED_MDX) &&
- entry.storiesImports.length > 0)
- ),
- (entry) => entry.id.split('--')[0]
- );
+ const entriesByUniqueComponent = selectComponentEntries(manifestEntries);
const components = entriesByUniqueComponent
.map((entry): ReactComponentManifest | undefined => {