Skip to content
Open
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
2 changes: 1 addition & 1 deletion code/core/src/common/utils/select-component-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function isAttachedDocsEntry(
);
}

function isEligibleStoryEntry(entry: IndexEntry): boolean {
export function isEligibleStoryEntry(entry: IndexEntry): boolean {
return entry.type === 'story' && entry.subtype === 'story';
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -63,6 +63,93 @@ describe('story-docs open service', () => {
expect(provider).toHaveBeenCalledWith({ entry });
});

describe('shared component ids across CSF files', () => {
let provider: Mock<StoryDocsProvider>;

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.
Expand Down
63 changes: 59 additions & 4 deletions code/core/src/shared/open-service/services/story-docs/server.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<StoryIndex>
): 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,
Expand Down
Loading