From 4df04f6af7151bfc9adaf89cb3c86ba94ebabfdd Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 9 Jun 2026 15:01:22 +0200 Subject: [PATCH 1/4] React: fix subcomponent prop extraction without JSX usage RCM Path 2 now only applies meta.component props when the ref matches the meta component. Subcomponents resolve through Path 3 so args tables show the correct props for each subcomponent entry. Co-authored-by: Cursor --- .../ComponentMetaProject.test.ts | 84 +++++++++ .../componentMeta/ComponentMetaProject.ts | 33 +++- .../componentMeta/componentMetaExtractor.ts | 159 +++++++++++++++--- 3 files changed, 252 insertions(+), 24 deletions(-) diff --git a/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.test.ts b/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.test.ts index 8c36ec565e15..a530e79d83f9 100644 --- a/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.test.ts +++ b/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.test.ts @@ -1,8 +1,15 @@ +import * as fs from 'node:fs'; + +import { loadCsf } from 'storybook/internal/csf-tools'; + import { describe, expect, it } from 'vitest'; import { dedent } from 'ts-dedent'; import type { StoryRef } from '../getComponentImports.ts'; +import { getComponents } from '../getComponentImports.ts'; +import { findMatchingComponent } from '../resolveComponents.ts'; +import { extractDeclaredSubcomponents, findExactComponentMatch } from '../subcomponents.ts'; import { extractFromStory, withProject } from './componentMetaExtractor.test-helpers.ts'; describe('compound component extraction', () => { @@ -378,6 +385,83 @@ describe('compound component extraction', () => { ); }); + it('extracts declared subcomponents from meta without JSX', async () => { + await withProject( + { + 'controls-parameters.tsx': dedent` + import React from 'react'; + + type MainProps = { a?: string; b: string }; + export const ControlsParameters = ({ a = 'a', b }: MainProps) =>
{a}{b}
; + + type SubcomponentAProps = { e: boolean; c: boolean; d?: boolean }; + export const SubcomponentA = ({ d = false }: SubcomponentAProps) =>
; + + type SubcomponentBProps = { g: number; h: number; f?: number }; + export const SubcomponentB = ({ f = 42 }: SubcomponentBProps) =>
; + `, + 'controls-parameters.stories.tsx': dedent` + import type { Meta } from '@storybook/react'; + import { ControlsParameters, SubcomponentA, SubcomponentB } from './controls-parameters'; + + const meta = { + title: 'Example/ControlsParameters', + component: ControlsParameters, + subcomponents: { SubcomponentA, SubcomponentB }, + } satisfies Meta; + + export default meta; + `, + }, + async (project, filePaths) => { + const storyPath = filePaths['controls-parameters.stories.tsx']; + const storyFile = fs.readFileSync(storyPath, 'utf-8'); + const csf = loadCsf(storyFile, { makeTitle: () => 'Example/ControlsParameters' }).parse(); + const declaredSubcomponents = extractDeclaredSubcomponents(csf); + const components = await getComponents({ + csf, + storyFilePath: storyPath, + docgenEngine: 'react-component-meta', + additionalComponentNames: declaredSubcomponents.map( + (subcomponent) => subcomponent.componentName + ), + }); + + const mainComponent = findMatchingComponent( + components, + csf._meta?.component, + 'ControlsParameters' + ); + const subcomponentEntries = declaredSubcomponents.map((declared) => ({ + storyPath, + component: findExactComponentMatch(components, declared.componentName), + })); + + project.extractPropsFromStories([ + { storyPath, component: mainComponent }, + ...subcomponentEntries, + ]); + + expect(mainComponent?.reactComponentMeta?.props?.a).toBeDefined(); + expect(mainComponent?.reactComponentMeta?.props?.b).toBeDefined(); + expect(mainComponent?.reactComponentMeta?.props?.e).toBeUndefined(); + + const subcomponentA = subcomponentEntries[0].component; + const subcomponentB = subcomponentEntries[1].component; + + expect(subcomponentA?.reactComponentMeta?.props?.e).toBeDefined(); + expect(subcomponentA?.reactComponentMeta?.props?.c).toBeDefined(); + expect(subcomponentA?.reactComponentMeta?.props?.d).toBeDefined(); + expect(subcomponentA?.reactComponentMeta?.props?.a).toBeUndefined(); + + expect(subcomponentB?.reactComponentMeta?.props?.g).toBeDefined(); + expect(subcomponentB?.reactComponentMeta?.props?.h).toBeDefined(); + expect(subcomponentB?.reactComponentMeta?.props?.f).toBeDefined(); + expect(subcomponentB?.reactComponentMeta?.props?.b).toBeUndefined(); + } + ); + }); + it('extracts description, @import, and @summary from component JSDoc', async () => { const entry = await extractFromStory( { diff --git a/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.ts b/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.ts index a2dc4b902f7f..a087640e56d5 100644 --- a/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.ts +++ b/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.ts @@ -30,6 +30,8 @@ import type ts from 'typescript'; import type { StoryRef } from '../getComponentImports.ts'; import type { ComponentRef, ResolvedComponentTarget } from '../types.ts'; import { + metaComponentMatchesRef, + resolvePropsFromComponentExport, resolvePropsFromComponentType, resolvePropsFromStoryFile, serializeComponentDoc, @@ -314,7 +316,7 @@ export class ComponentMetaProject { } // Path 2: Fallback — resolve from meta.component in the story file. - // Only fires when the user explicitly set `component:` in the meta object. + // Only applies to the meta component itself, not declared subcomponents. if (!resolvedComponent) { resolvedComponent = this.resolveFromMetaComponent( checker, @@ -323,6 +325,16 @@ export class ComponentMetaProject { ); } + // Path 3: Resolve directly from the component module export (declared subcomponents). + if (!resolvedComponent) { + resolvedComponent = resolvePropsFromComponentExport( + this.typescript, + checker, + componentSourceFile, + entryComponent + ); + } + if (!resolvedComponent) { continue; } @@ -415,7 +427,24 @@ export class ComponentMetaProject { const metaType = checker.getTypeOfSymbol(defaultExport); const componentProp = metaType.getProperty('component'); - if (!componentProp) { + if ( + !componentProp?.valueDeclaration || + !this.typescript.isPropertyAssignment(componentProp.valueDeclaration) + ) { + return undefined; + } + + const metaComponentInitializer = componentProp.valueDeclaration.initializer; + if ( + !metaComponentInitializer || + !metaComponentMatchesRef( + this.typescript, + checker, + storySourceFile, + componentRef, + metaComponentInitializer + ) + ) { return undefined; } diff --git a/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts b/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts index e821795d0cc3..d4359c53f66c 100644 --- a/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts +++ b/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts @@ -251,12 +251,13 @@ function resolveComponentSymbol( * @param importName - The export name of the component (e.g., 'Button', 'default') * @param memberAccess - For compound components (e.g., 'Root' in ``) */ -export function resolvePropsFromStoryFile( +/** Finds the story-local import binding for a {@link ComponentRef}. */ +export function findImportSymbolInStoryFile( typescript: typeof ts, checker: ts.TypeChecker, storySourceFile: ts.SourceFile, componentRef: ComponentRef -): ResolvedComponentTarget | undefined { +): ts.Symbol | undefined { const importSpecifier = componentRef.importId; const importName = componentRef.importName; const memberAccess = componentRef.member; @@ -264,10 +265,6 @@ export function resolvePropsFromStoryFile( return undefined; } - // Step 1: Find the import binding symbol in the story file. - // This is the local symbol that the story uses in JSX (e.g., `Button` from `import { Button } from './Button'`). - let importSymbol: ts.Symbol | undefined; - for (const stmt of storySourceFile.statements) { if (!typescript.isImportDeclaration(stmt)) { continue; @@ -285,12 +282,12 @@ export function resolvePropsFromStoryFile( continue; } + let importSymbol: ts.Symbol | undefined; + if (importName === 'default') { - // Default import: import Button from '...' if (clause.name) { importSymbol = checker.getSymbolAtLocation(clause.name); } - // Also check named imports for `{ default as Button }` pattern if ( !importSymbol && clause.namedBindings && @@ -304,22 +301,16 @@ export function resolvePropsFromStoryFile( } } } - } else { - // Named import: import { Button } from '...' or import { Button as Btn } from '...' - if (clause.namedBindings && typescript.isNamedImports(clause.namedBindings)) { - for (const spec of clause.namedBindings.elements) { - const originalName = (spec.propertyName ?? spec.name).text; - if (originalName === importName) { - importSymbol = checker.getSymbolAtLocation(spec.name); - break; - } + } else if (clause.namedBindings && typescript.isNamedImports(clause.namedBindings)) { + for (const spec of clause.namedBindings.elements) { + const originalName = (spec.propertyName ?? spec.name).text; + if (originalName === importName) { + importSymbol = checker.getSymbolAtLocation(spec.name); + break; } } } - // Namespace import: import * as Ns from '...' - // Only applies when memberAccess is set (compound components accessed as ). - // Without this guard, a namespace import from the same module could shadow a named - // import we're actually looking for (e.g. `import * as X from './m'; import { Y } from './m'`). + if ( !importSymbol && memberAccess && @@ -330,10 +321,60 @@ export function resolvePropsFromStoryFile( } if (importSymbol) { - break; + return importSymbol; } } + return undefined; +} + +/** Returns whether `componentRef` is the story meta's `component`, not a declared subcomponent. */ +export function metaComponentMatchesRef( + typescript: typeof ts, + checker: ts.TypeChecker, + storySourceFile: ts.SourceFile, + componentRef: ComponentRef, + metaComponentInitializer: ts.Expression +): boolean { + const refSymbol = findImportSymbolInStoryFile(typescript, checker, storySourceFile, componentRef); + const metaSymbol = resolveComponentSymbolFromNode(typescript, checker, metaComponentInitializer); + + if (refSymbol && metaSymbol) { + return ( + resolveAliasedSymbol(typescript, checker, refSymbol) === + resolveAliasedSymbol(typescript, checker, metaSymbol) + ); + } + + if (typescript.isIdentifier(metaComponentInitializer)) { + return componentRef.componentName === metaComponentInitializer.text; + } + + if ( + typescript.isPropertyAccessExpression(metaComponentInitializer) && + typescript.isIdentifier(metaComponentInitializer.expression) + ) { + const metaName = `${metaComponentInitializer.expression.text}.${metaComponentInitializer.name.text}`; + return componentRef.componentName === metaName; + } + + return false; +} + +export function resolvePropsFromStoryFile( + typescript: typeof ts, + checker: ts.TypeChecker, + storySourceFile: ts.SourceFile, + componentRef: ComponentRef +): ResolvedComponentTarget | undefined { + const memberAccess = componentRef.member; + const importSymbol = findImportSymbolInStoryFile( + typescript, + checker, + storySourceFile, + componentRef + ); + if (!importSymbol) { return undefined; } @@ -460,6 +501,80 @@ export function resolvePropsFromComponentType( return undefined; } +/** + * Path 3 fallback: resolve props from the component module export directly. + * + * Used for declared subcomponents that only appear in `meta.subcomponents` and have no JSX in the + * story file. Without this, Path 2 would incorrectly reuse `meta.component`'s props for every + * batch entry. + */ +export function resolvePropsFromComponentExport( + typescript: typeof ts, + checker: ts.TypeChecker, + componentSourceFile: ts.SourceFile, + componentRef: ComponentRef +): ResolvedComponentTarget | undefined { + const moduleSymbol = checker.getSymbolAtLocation(componentSourceFile); + if (!moduleSymbol) { + return undefined; + } + + const exports = checker.getExportsOfModule(checker.getMergedSymbol(moduleSymbol)); + const exportName = componentRef.importName ?? componentRef.componentName.split('.').at(-1); + if (!exportName) { + return undefined; + } + + let exportSymbol: ts.Symbol | undefined; + let componentType: ts.Type | undefined; + + if (componentRef.namespace && componentRef.member) { + const namespaceSymbol = exports.find((symbol) => symbol.getName() === componentRef.namespace); + if (!namespaceSymbol) { + return undefined; + } + const namespaceType = checker.getTypeOfSymbol(namespaceSymbol); + const memberSymbol = namespaceType.getProperty(componentRef.member); + if (!memberSymbol) { + return undefined; + } + exportSymbol = memberSymbol; + componentType = checker.getTypeOfSymbol(memberSymbol); + } else { + exportSymbol = + exportName === 'default' + ? exports.find((symbol) => symbol.getName() === 'default') + : exports.find((symbol) => symbol.getName() === exportName); + + if (!exportSymbol) { + return undefined; + } + + componentType = checker.getTypeOfSymbol(exportSymbol); + + if (componentRef.member) { + const memberSymbol = componentType.getProperty(componentRef.member); + if (!memberSymbol) { + return undefined; + } + exportSymbol = memberSymbol; + componentType = checker.getTypeOfSymbol(memberSymbol); + } + } + + const propsType = resolvePropsFromComponentType(typescript, checker, componentType); + const contextNode = exportSymbol ? getSymbolContextNode(exportSymbol) : undefined; + if (!propsType || !exportSymbol || !contextNode) { + return undefined; + } + + return { + componentRef, + propsType, + symbol: resolveComponentSymbol(typescript, checker, exportSymbol, contextNode), + }; +} + // --------------------------------------------------------------------------- // Parent / source info per property // --------------------------------------------------------------------------- From 065be4464a640558e78f7eb04c8ee178bd58d80a Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 9 Jun 2026 15:40:48 +0200 Subject: [PATCH 2/4] React: restore original RCM inline comments after refactor Bring back the Step 1 import-binding comments and resolvePropsFromStoryFile JSDoc that were dropped when extracting findImportSymbolInStoryFile. Keep the new Path 2/3 comments alongside the original Path 2 note. Co-authored-by: Cursor --- .../componentMeta/ComponentMetaProject.ts | 1 + .../componentMeta/componentMetaExtractor.ts | 39 ++++++++++++------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.ts b/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.ts index a087640e56d5..3f4823b2c241 100644 --- a/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.ts +++ b/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.ts @@ -316,6 +316,7 @@ export class ComponentMetaProject { } // Path 2: Fallback — resolve from meta.component in the story file. + // Only fires when the user explicitly set `component:` in the meta object. // Only applies to the meta component itself, not declared subcomponents. if (!resolvedComponent) { resolvedComponent = this.resolveFromMetaComponent( diff --git a/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts b/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts index d4359c53f66c..fd04c057970c 100644 --- a/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts +++ b/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts @@ -237,20 +237,6 @@ function resolveComponentSymbol( // Story-based prop extraction (probe-free) // --------------------------------------------------------------------------- -/** - * Resolves the selected component symbol and props type by finding JSX usage of the target - * component in a story file. - * - * Story files already contain JSX like `