Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions code/core/src/core-server/utils/StoryIndexGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
12 changes: 10 additions & 2 deletions code/core/src/core-server/utils/StoryIndexGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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`
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

<Meta of={AStories}/>

# 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.
95 changes: 95 additions & 0 deletions code/renderers/react/src/componentManifest/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Primary>;
export default meta;

export const Default = () => <Primary title="Primary title" />;
`,
['./src/Primary/Primary.tsx']: dedent`
import React from 'react';

export interface PrimaryProps {
title: string;
}

/** Primary component description */
export const Primary = ({ title }: PrimaryProps) => <div>{title}</div>;
`,
['./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<typeof OtherFile>;
export default meta;

export const Default = () => <OtherFile label="Other file label" />;
`,
['./src/OtherFile/OtherFile.tsx']: dedent`
import React from 'react';

export interface OtherFileProps {
label: string;
}

/** Other file component description */
export const OtherFile = ({ label }: OtherFileProps) => (
<button type="button">{label}</button>
);
`,
},
'/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('<Primary');
});

test('stories are populated when meta has no explicit title', async () => {
vol.fromJSON(
{
Expand Down
49 changes: 36 additions & 13 deletions code/renderers/react/src/componentManifest/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,6 +23,41 @@ interface ReactComponentManifest extends ComponentManifest {
reactDocgenTypescript?: ComponentDocWithExportName;
}

function selectComponentEntries(manifestEntries: IndexEntry[]) {
const entriesByComponentId = new Map<string, IndexEntry>();

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<typeof getComponents>,
componentName: string | undefined,
Expand Down Expand Up @@ -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 => {
Expand Down
Loading