diff --git a/code/core/src/common/utils/select-component-entry.ts b/code/core/src/common/utils/select-component-entry.ts index e5d4cf8dc99f..d1e49e9188b4 100644 --- a/code/core/src/common/utils/select-component-entry.ts +++ b/code/core/src/common/utils/select-component-entry.ts @@ -24,7 +24,7 @@ function isAttachedDocsEntry( ); } -function isEligibleStoryEntry(entry: IndexEntry): boolean { +export function isEligibleStoryEntry(entry: IndexEntry): boolean { return entry.type === 'story' && entry.subtype === 'story'; } diff --git a/code/core/src/shared/open-service/services/story-docs/server.test.ts b/code/core/src/shared/open-service/services/story-docs/server.test.ts index 0b69dfa1e4ee..31c148f939c8 100644 --- a/code/core/src/shared/open-service/services/story-docs/server.test.ts +++ b/code/core/src/shared/open-service/services/story-docs/server.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import type { IndexEntry, StoryIndex } from '../../../../types/modules/indexer.ts'; import { clearRegistry, getService } from '../../server.ts'; @@ -63,6 +63,93 @@ describe('story-docs open service', () => { expect(provider).toHaveBeenCalledWith({ entry }); }); + describe('shared component ids across CSF files', () => { + let provider: Mock; + + beforeEach(() => { + provider = vi.fn(); + }); + + function makeSiblingEntries() { + // Same componentId ('vega'), different files. Insertion order makes the + // second file the selected winner, mirroring selectComponentEntriesByComponentId. + const fileA = { + ...makeStoryEntry('vega--a', 'Vega'), + importPath: './a.stories.tsx', + }; + const fileB = { + ...makeStoryEntry('vega--b', 'Vega'), + importPath: './b.stories.tsx', + }; + return { fileA, fileB }; + } + + it('merges stories from every file sharing the component id', async () => { + const { fileA, fileB } = makeSiblingEntries(); + vi.mocked(provider).mockImplementation(async ({ entry }) => + makeStoryDocsPayload({ + id: 'vega', + name: 'Vega', + path: entry.importPath, + stories: { [entry.id]: { id: entry.id, name: entry.id } }, + }) + ); + + const service = registerStoryDocsService({ + getIndex: makeGetIndex([fileA, fileB]), + storyDocsProvider: provider, + }); + + const result = await service.commands.extractStoryDocs({ id: 'vega' }); + expect(Object.keys(result?.stories ?? {}).sort()).toEqual(['vega--a', 'vega--b']); + // Winner file (fileB) keeps its identity fields. + expect(result?.path).toBe('./b.stories.tsx'); + }); + + it('prefers the winning file on story-id collisions', async () => { + const { fileA, fileB } = makeSiblingEntries(); + vi.mocked(provider).mockImplementation(async ({ entry }) => + makeStoryDocsPayload({ + id: 'vega', + name: 'Vega', + path: entry.importPath, + stories: { 'vega--shared': { id: 'vega--shared', name: entry.importPath } }, + }) + ); + + const service = registerStoryDocsService({ + getIndex: makeGetIndex([fileA, fileB]), + storyDocsProvider: provider, + }); + + const result = await service.commands.extractStoryDocs({ id: 'vega' }); + expect(result?.stories['vega--shared']?.name).toBe('./b.stories.tsx'); + }); + + it('keeps the winning file stories when a sibling extraction fails', async () => { + const { fileA, fileB } = makeSiblingEntries(); + vi.mocked(provider).mockImplementation(async ({ entry }) => { + if (entry.id === 'vega--a') { + throw new Error('sibling boom'); + } + return makeStoryDocsPayload({ + id: 'vega', + name: 'Vega', + path: entry.importPath, + stories: { [entry.id]: { id: entry.id, name: entry.id } }, + }); + }); + + const service = registerStoryDocsService({ + getIndex: makeGetIndex([fileA, fileB]), + storyDocsProvider: provider, + }); + + const result = await service.commands.extractStoryDocs({ id: 'vega' }); + expect(Object.keys(result?.stories ?? {})).toEqual(['vega--b']); + }); + }); + describe('module graph hot refresh', () => { // Snippets come from the story file's own source. Already-extracted components must re-extract // when their story file changes so snippets stay fresh after the edit. diff --git a/code/core/src/shared/open-service/services/story-docs/server.ts b/code/core/src/shared/open-service/services/story-docs/server.ts index 68b0883864b5..32ee04c5d5f8 100644 --- a/code/core/src/shared/open-service/services/story-docs/server.ts +++ b/code/core/src/shared/open-service/services/story-docs/server.ts @@ -1,8 +1,12 @@ -import { getStoryImportPathFromEntry } from '../../../../common/utils/select-component-entry.ts'; -import type { StoryIndex } from '../../../../types/modules/indexer.ts'; +import { getComponentIdFromEntry } from '../../../../common/utils/component-id.ts'; +import { + getStoryImportPathFromEntry, + isEligibleStoryEntry, +} from '../../../../common/utils/select-component-entry.ts'; +import type { IndexEntry, StoryIndex } from '../../../../types/modules/indexer.ts'; import { registerExtractionService } from '../extraction-service.server.ts'; import { storyDocsServiceDef } from './definition.ts'; -import type { StoryDocsProvider } from './types.ts'; +import type { StoryDocsPayload, StoryDocsProvider } from './types.ts'; export type RegisterStoryDocsServiceOptions = { workingDir?: string; @@ -18,12 +22,63 @@ export type RegisterStoryDocsServiceOptions = { storyDocsProvider: StoryDocsProvider; }; +/** + * Sibling story entries sharing the winning entry's componentId: other CSF files collapsed onto + * the same component by sharing a title. The selection in `selectComponentEntriesByComponentId` + * can only represent one file per id, so without this their stories never reach `docs list`. + */ +function findSiblingStoryEntries(index: StoryIndex, entry: IndexEntry): IndexEntry[] { + const componentId = getComponentIdFromEntry(entry); + return Object.values(index.entries).filter( + (candidate) => + candidate.id !== entry.id && + isEligibleStoryEntry(candidate) && + getComponentIdFromEntry(candidate) === componentId + ); +} + +/** + * Wraps the composed story-docs provider so a component's payload includes stories from every + * CSF file sharing its componentId, not just the winning file. Docgen, props tables, and the + * manifest keep the single-file rule; only the `stories` record is merged. On story-id + * collisions the winning file's story wins, matching the precedence everywhere else. + * + * Sibling extractions run settled: a failing sibling must not discard the winning file's + * stories the way an unguarded fan-out would. + */ +function withSiblingStoryMerge( + provider: StoryDocsProvider, + getIndex: () => Promise +): StoryDocsProvider { + return async (input) => { + const payload = await provider(input); + if (!payload) { + return payload; + } + const siblings = findSiblingStoryEntries(await getIndex(), input.entry); + if (siblings.length === 0) { + return payload; + } + const results = await Promise.allSettled( + siblings.map((entry) => provider({ entry })) + ); + const merged: StoryDocsPayload['stories'] = {}; + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + Object.assign(merged, result.value.stories); + } + } + Object.assign(merged, payload.stories); + return { ...payload, stories: merged }; + }; +} + /** Registers the `core/story-docs` open service against the process-global registry. */ export function registerStoryDocsService(options: RegisterStoryDocsServiceOptions) { return registerExtractionService(storyDocsServiceDef, { workingDir: options.workingDir ?? process.cwd(), getIndex: options.getIndex, - provider: options.storyDocsProvider, + provider: withSiblingStoryMerge(options.storyDocsProvider, options.getIndex), buildErrorPayload: ({ id, entry, error }) => ({ id, name: entry.title,