From fb9a74b84473907e4860e201a09fd5eb6b1c8e8f Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 11 Mar 2026 11:56:05 +0100 Subject: [PATCH 1/3] fix attached MDX files causing wrong component entries in manifests --- .../src/componentManifest/generator.test.ts | 95 +++++++++++++++++++ .../react/src/componentManifest/generator.ts | 49 +++++++--- 2 files changed, 131 insertions(+), 13 deletions(-) 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 => { From 3baeeeb3de29d17f05a18457dbab46c0a86649a3 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 11 Mar 2026 11:56:39 +0100 Subject: [PATCH 2/3] Fix storiesImports not correctly putting the first in the array --- .../utils/StoryIndexGenerator.test.ts | 25 +++++++++++++++++++ .../core-server/utils/StoryIndexGenerator.ts | 12 +++++++-- .../complex/MetaOfImportOrder.mdx | 9 +++++++ 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx 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..5d33133db848 --- /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 last import. \ No newline at end of file From 6ae40b393d6a41a6da91fb443ba35eaec514c51f Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 11 Mar 2026 13:40:01 +0100 Subject: [PATCH 3/3] Update code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../utils/__mockdata__/complex/MetaOfImportOrder.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx b/code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx index 5d33133db848..2774c1863d95 100644 --- a/code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx +++ b/code/core/src/core-server/utils/__mockdata__/complex/MetaOfImportOrder.mdx @@ -6,4 +6,4 @@ 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 last import. \ No newline at end of file +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