From a328ec0616a4917e337a9a9f321fc4a5ee294647 Mon Sep 17 00:00:00 2001 From: Seydi Charyyev Date: Tue, 19 May 2026 14:51:58 +0500 Subject: [PATCH] Addon-Docs: Resolve CSF4 module exports without a default export When a bundler splits a CSF4 story module (no `export default meta`) into a separate chunk, the namespace object passed to `` differs by object identity from the one Storybook registered. The existing default-export fallback in `DocsContext.resolveModuleExport` cannot handle this case because CSF4 modules have no `default` key. Fall back to identifying the CSF file via any of its story exports. Reject the lookup when story exports span multiple CSF files. Guard against individual CSF4 Story objects so `` keeps resolving to a story. Fixes #34159 Fixes #34373 --- .../docs-context/DocsContext.test.ts | 76 +++++++++++++++++++ .../preview-web/docs-context/DocsContext.ts | 41 +++++++++- .../preview-web/docs-context/test-utils.ts | 10 ++- 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.test.ts b/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.test.ts index b26c3d165d1a..585aab1f44d5 100644 --- a/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.test.ts +++ b/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.test.ts @@ -112,6 +112,50 @@ describe('referenceMeta', () => { ' must reference a CSF file module export or meta export. Did you mistakenly reference your component instead of your CSF file?' ); }); + + it('works with different module namespace objects when there is no default export', () => { + // Simulates CSF4 modules (no `export default meta`) split into a chunk: + // the MDX-imported namespace differs by identity from the one Storybook registered. + // Resolution should fall back to looking up the CSF file via any story export. + const { story, csfFile, storyExport } = csfFileParts('meta--story', 'meta', { + includeDefaultExport: false, + }); + const store = { + componentStoriesFromCSFFile: () => [story], + } as unknown as StoryStore; + const context = new DocsContext(channel, store, renderStoryToElement, [csfFile]); + + const differentModuleExports = { story: storyExport }; + + expect(() => context.referenceMeta(differentModuleExports, true)).not.toThrow(); + expect(context.storyById()).toEqual(story); + }); + + it('throws for module objects whose story exports span multiple CSF files', () => { + const firstParts = csfFileParts('first-meta--first-story', 'first-meta', { + includeDefaultExport: false, + }); + const secondParts = csfFileParts('second-meta--second-story', 'second-meta', { + includeDefaultExport: false, + }); + const store = { + componentStoriesFromCSFFile: ({ csfFile }: { csfFile: CSFFile }) => + csfFile === firstParts.csfFile ? [firstParts.story] : [secondParts.story], + } as unknown as StoryStore; + const context = new DocsContext(channel, store, renderStoryToElement, [ + firstParts.csfFile, + secondParts.csfFile, + ]); + + const mixedModuleExports = { + first: firstParts.storyExport, + second: secondParts.storyExport, + }; + + expect(() => context.referenceMeta(mixedModuleExports, true)).toThrow( + ' must reference a CSF file module export or meta export. Did you mistakenly reference your component instead of your CSF file?' + ); + }); }); describe('resolveOf', () => { @@ -157,6 +201,38 @@ describe('resolveOf', () => { }); }); + it('works for CSF4 module exports with different object identity and no default export', () => { + // CSF4 modules (no `export default meta`) may be split into a separate chunk, + // producing a namespace object whose identity differs from Storybook's record. + // Resolution falls back to identifying the CSF file via the story exports. + const noDefaultParts = csfFileParts('meta--no-default-story', 'meta-no-default', { + includeDefaultExport: false, + }); + const noDefaultStore = { + componentStoriesFromCSFFile: () => [noDefaultParts.story], + preparedMetaFromCSFFile: () => ({ prepareMeta: 'preparedMeta' }), + projectAnnotations, + } as unknown as StoryStore; + const noDefaultContext = new DocsContext(channel, noDefaultStore, renderStoryToElement, [ + noDefaultParts.csfFile, + ]); + noDefaultContext.attachCSFFile(noDefaultParts.csfFile); + + expect(noDefaultContext.resolveOf({ story: noDefaultParts.storyExport })).toEqual({ + type: 'meta', + csfFile: noDefaultParts.csfFile, + preparedMeta: expect.any(Object), + }); + }); + + it('resolves a CSF4 Story object to its story, not its containing CSF file', () => { + // A CSF4 Story (detected via `_tag === 'Story'`) is an individual export, + // not a namespace. The namespace fallback must skip it so that + // still resolves to the Primary story. + const csf4Story = { _tag: 'Story', input: storyExport }; + expect(context.resolveOf(csf4Story, ['story'])).toEqual({ type: 'story', story }); + }); + it('works for components', () => { expect(context.resolveOf(component)).toEqual({ type: 'component', diff --git a/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.ts b/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.ts index 803b9aca0fdd..00a7b87b7532 100644 --- a/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.ts +++ b/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.ts @@ -57,8 +57,11 @@ export class DocsContext implements DocsContextProps referenceCSFFile(csfFile: CSFFile) { this.exportsToCSFFile.set(csfFile.moduleExports, csfFile); // Also set the default export as the component's exports, - // to allow `import ButtonStories from './Button.stories'` - this.exportsToCSFFile.set(csfFile.moduleExports.default, csfFile); + // to allow `import ButtonStories from './Button.stories'`. + // CSF4 modules may not have a default export, so guard against it. + if ('default' in csfFile.moduleExports) { + this.exportsToCSFFile.set(csfFile.moduleExports.default, csfFile); + } const stories = this.store.componentStoriesFromCSFFile({ csfFile }); @@ -171,6 +174,40 @@ export class DocsContext implements DocsContextProps csfFile = this.exportsToCSFFile.get((moduleExportOrType as ModuleExports).default); } + // CSF4 modules don't have a default export, and when a bundler splits the + // story module into a separate chunk the namespace passed to + // may differ by object identity from the one Storybook registered. Fall back + // to resolving the CSF file via any of its story exports. + // Skip individual story objects (handled by the story lookup below). + if ( + !csfFile && + moduleExportOrType && + typeof moduleExportOrType === 'object' && + !isStory(moduleExportOrType) + ) { + let matchedCSFFile: CSFFile | undefined; + for (const exportValue of Object.values(moduleExportOrType as ModuleExports)) { + const story = this.exportToStory.get( + isStory(exportValue) ? exportValue.input : exportValue + ); + if (!story) { + continue; + } + const storyCSFFile = this.storyIdToCSFFile.get(story.id); + if (!storyCSFFile) { + continue; + } + if (!matchedCSFFile) { + matchedCSFFile = storyCSFFile; + } else if (matchedCSFFile !== storyCSFFile) { + // Story exports span multiple CSF files — ambiguous, reject. + matchedCSFFile = undefined; + break; + } + } + csfFile = matchedCSFFile; + } + if (csfFile) { return { type: 'meta', csfFile } as TResolvedExport; } diff --git a/code/core/src/preview-api/modules/preview-web/docs-context/test-utils.ts b/code/core/src/preview-api/modules/preview-web/docs-context/test-utils.ts index af0a6836388e..74727c3cdec6 100644 --- a/code/core/src/preview-api/modules/preview-web/docs-context/test-utils.ts +++ b/code/core/src/preview-api/modules/preview-web/docs-context/test-utils.ts @@ -1,11 +1,17 @@ import type { CSFFile, PreparedStory } from 'storybook/internal/types'; -export function csfFileParts(storyId = 'meta--story', metaId = 'meta') { +export function csfFileParts( + storyId = 'meta--story', + metaId = 'meta', + { includeDefaultExport = true }: { includeDefaultExport?: boolean } = {} +) { // These compose the raw exports of the CSF file const component = {}; const metaExport = { component }; const storyExport = {}; - const moduleExports = { default: metaExport, story: storyExport }; + const moduleExports = includeDefaultExport + ? { default: metaExport, story: storyExport } + : { story: storyExport }; // This is the prepared story + CSF file after SB has processed them const storyAnnotations = {