diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json
index 30f224c160b7..33337e29a1f3 100644
--- a/code/renderers/react/package.json
+++ b/code/renderers/react/package.json
@@ -74,6 +74,7 @@
"escodegen": "^2.1.0",
"expect-type": "^0.15.0",
"html-tags": "^3.1.0",
+ "memfs": "^4.11.1",
"prop-types": "^15.7.2",
"react-element-to-jsx-string": "patch:react-element-to-jsx-string@npm%3A@7rulnik/react-element-to-jsx-string@15.0.1#~/.yarn/patches/@7rulnik-react-element-to-jsx-string-npm-15.0.1-e53b67c4b3.patch",
"require-from-string": "^2.0.2",
diff --git a/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.test.ts b/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.test.ts
index 8c36ec565e15..52dbeb5b177c 100644
--- a/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.test.ts
+++ b/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.test.ts
@@ -1,9 +1,20 @@
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it } from 'vitest';
import { dedent } from 'ts-dedent';
import type { StoryRef } from '../getComponentImports.ts';
-import { extractFromStory, withProject } from './componentMetaExtractor.test-helpers.ts';
+import { findMatchingComponent } from '../resolveComponents.ts';
+import { findExactComponentMatch } from '../subcomponents.ts';
+import {
+ extractFromStory,
+ loadDeclaredSubcomponentComponents,
+ resetProjectVolume,
+ withProject,
+} from './componentMetaExtractor.test-helpers.ts';
+
+afterEach(() => {
+ resetProjectVolume();
+});
describe('compound component extraction', () => {
it('extracts props for Accordion.Root, not Item or Trigger', async () => {
@@ -378,6 +389,205 @@ describe('compound component extraction', () => {
);
});
+ it('extracts declared compound subcomponent without JSX when meta.component is the base export', async () => {
+ await withProject(
+ {
+ 'button.tsx': dedent`
+ import React from 'react';
+
+ interface ButtonProps {
+ variant?: 'solid' | 'outline';
+ }
+ const Button = (props: ButtonProps) => ;
+
+ interface AlignerProps {
+ side?: 'start' | 'end';
+ }
+ const Aligner = (props: AlignerProps) =>
;
+
+ const ButtonRoot = Button as typeof Button & {
+ Aligner: typeof Aligner;
+ };
+ ButtonRoot.Aligner = Aligner;
+
+ export default ButtonRoot;
+ `,
+ 'button.stories.tsx': dedent`
+ import type { Meta } from '@storybook/react';
+ import Button from './button';
+
+ const meta = {
+ title: 'Example/Button',
+ component: Button,
+ subcomponents: { Aligner: Button.Aligner },
+ } satisfies Meta;
+
+ export default meta;
+ `,
+ },
+ async (project, filePaths) => {
+ const { storyPath, csf, components } = await loadDeclaredSubcomponentComponents({
+ filePaths,
+ storyFileName: 'button.stories.tsx',
+ title: 'Example/Button',
+ });
+
+ const mainComponent = findMatchingComponent(components, csf._meta?.component, 'Button');
+ const alignerEntry = {
+ storyPath,
+ component: findExactComponentMatch(components, 'Button.Aligner'),
+ };
+
+ project.extractPropsFromStories([{ storyPath, component: mainComponent }, alignerEntry]);
+
+ expect(mainComponent?.reactComponentMeta?.props?.variant).toBeDefined();
+ expect(mainComponent?.reactComponentMeta?.props?.side).toBeUndefined();
+
+ expect(alignerEntry.component?.reactComponentMeta?.props?.side).toBeDefined();
+ expect(alignerEntry.component?.reactComponentMeta?.props?.variant).toBeUndefined();
+ }
+ );
+ });
+
+ it('extracts declared subcomponents from namespace import 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 * as UI from './controls-parameters';
+
+ const meta = {
+ title: 'Example/ControlsParameters',
+ component: UI.ControlsParameters,
+ subcomponents: { SubcomponentA: UI.SubcomponentA, SubcomponentB: UI.SubcomponentB },
+ } satisfies Meta;
+
+ export default meta;
+ `,
+ },
+ async (project, filePaths) => {
+ const { storyPath, csf, declaredSubcomponents, components } =
+ await loadDeclaredSubcomponentComponents({
+ filePaths,
+ storyFileName: 'controls-parameters.stories.tsx',
+ title: 'Example/ControlsParameters',
+ });
+
+ 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();
+
+ 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?.a).toBeUndefined();
+
+ expect(subcomponentB?.reactComponentMeta?.props?.g).toBeDefined();
+ expect(subcomponentB?.reactComponentMeta?.props?.h).toBeDefined();
+ expect(subcomponentB?.reactComponentMeta?.props?.b).toBeUndefined();
+ }
+ );
+ });
+
+ 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, csf, declaredSubcomponents, components } =
+ await loadDeclaredSubcomponentComponents({
+ filePaths,
+ storyFileName: 'controls-parameters.stories.tsx',
+ title: 'Example/ControlsParameters',
+ });
+
+ 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..3f4823b2c241 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,
@@ -315,6 +317,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 +326,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 +428,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.test-helpers.ts b/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.test-helpers.ts
index da9233e9779d..9f6f4fd2d325 100644
--- a/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.test-helpers.ts
+++ b/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.test-helpers.ts
@@ -1,12 +1,13 @@
-import * as fs from 'node:fs';
import * as path from 'node:path';
import { loadCsf } from 'storybook/internal/csf-tools';
+import { vol } from 'memfs';
import ts from 'typescript';
import { type StoryRef, getComponents } from '../getComponentImports.ts';
import { findMatchingComponent } from '../resolveComponents.ts';
+import { extractDeclaredSubcomponents } from '../subcomponents.ts';
import { ComponentMetaProject } from './ComponentMetaProject.ts';
import { createTempProject, writeFiles } from './test-helpers.ts';
@@ -29,12 +30,26 @@ const sharedProject = new ComponentMetaProject(
fsFileSnapshots
);
+/** Reads a project file from the in-memory mirror populated by {@link withProject}. */
+export function readProjectFile(filePath: string): string {
+ return vol.readFileSync(filePath, 'utf-8') as string;
+}
+
+/** Resets the memfs mirror between tests. */
+export function resetProjectVolume(): void {
+ vol.reset();
+}
+
/** Write files into the shared project, invalidate caches, and run a callback. */
export async function withProject(
files: Record,
fn: (project: ComponentMetaProject, filePaths: Record) => T | Promise
): Promise {
+ vol.reset();
const filePaths = writeFiles(projectDir, files);
+ vol.fromNestedJSON(
+ Object.fromEntries(Object.entries(filePaths).map(([name, filePath]) => [filePath, files[name]]))
+ );
for (const fp of Object.values(filePaths)) {
fsFileSnapshots.delete(fp);
}
@@ -42,6 +57,31 @@ export async function withProject(
return fn(sharedProject, filePaths);
}
+/** Parses a story file and resolves declared subcomponents for extraction tests. */
+export async function loadDeclaredSubcomponentComponents({
+ filePaths,
+ storyFileName,
+ title,
+}: {
+ filePaths: Record;
+ storyFileName: string;
+ title: string;
+}) {
+ const storyPath = filePaths[storyFileName];
+ const csf = loadCsf(readProjectFile(storyPath), { makeTitle: () => title }).parse();
+ const declaredSubcomponents = extractDeclaredSubcomponents(csf);
+ const components = await getComponents({
+ csf,
+ storyFilePath: storyPath,
+ docgenEngine: 'react-component-meta',
+ additionalComponentNames: declaredSubcomponents.map(
+ (subcomponent) => subcomponent.componentName
+ ),
+ });
+
+ return { storyPath, csf, declaredSubcomponents, components };
+}
+
/**
* Full production flow: loadCsf → getComponents → extractPropsFromStories. Title is auto-derived
* from the story file name for findMatchingComponent.
@@ -54,7 +94,7 @@ export async function extractFromStory(
return withProject(files, async (project, filePaths) => {
const storyPath = filePaths[storyFileName];
const title = path.basename(storyFileName).replace(/\.stories\.\w+$/, '');
- const csf = loadCsf(fs.readFileSync(storyPath, 'utf-8'), {
+ const csf = loadCsf(readProjectFile(storyPath), {
makeTitle: () => title,
}).parse();
diff --git a/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts b/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts
index e821795d0cc3..8c1269e0123e 100644
--- a/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts
+++ b/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts
@@ -237,26 +237,13 @@ 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 `` that TypeScript has resolved. This function
- * walks the story AST to find a JSX element matching the target component and extracts the props
- * type via `getResolvedSignature()` — the same mechanism as autocomplete and the former probe
- * approach.
- *
- * @param importSpecifier - The import specifier as written in the story file (e.g., './Button',
- * '@mantine/core')
- * @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;
@@ -266,7 +253,6 @@ export function resolvePropsFromStoryFile(
// 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)) {
@@ -285,6 +271,8 @@ export function resolvePropsFromStoryFile(
continue;
}
+ let importSymbol: ts.Symbol | undefined;
+
if (importName === 'default') {
// Default import: import Button from '...'
if (clause.name) {
@@ -304,15 +292,13 @@ export function resolvePropsFromStoryFile(
}
}
}
- } else {
+ } else if (clause.namedBindings && typescript.isNamedImports(clause.namedBindings)) {
// 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;
- }
+ for (const spec of clause.namedBindings.elements) {
+ const originalName = (spec.propertyName ?? spec.name).text;
+ if (originalName === importName) {
+ importSymbol = checker.getSymbolAtLocation(spec.name);
+ break;
}
}
}
@@ -330,10 +316,76 @@ 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);
+
+ // Plain symbol equality only when the ref is not a member expression — otherwise
+ // `Button.Aligner` would match `meta.component: Button` because both resolve to the Button import.
+ if (refSymbol && metaSymbol && !componentRef.member) {
+ 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;
+}
+
+/**
+ * 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 `` that TypeScript has resolved. This function
+ * walks the story AST to find a JSX element matching the target component and extracts the props
+ * type via `getResolvedSignature()` — the same mechanism as autocomplete and the former probe
+ * approach.
+ *
+ * @param importSpecifier - The import specifier as written in the story file (e.g., './Button',
+ * '@mantine/core')
+ * @param importName - The export name of the component (e.g., 'Button', 'default')
+ * @param memberAccess - For compound components (e.g., 'Root' in ``)
+ */
+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 +512,81 @@ 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) {
+ // `import * as UI` — importName is the module export, not the local namespace alias.
+ const namespaceExportName = componentRef.importName ?? componentRef.member;
+ exportSymbol =
+ namespaceExportName === 'default'
+ ? exports.find((symbol) => symbol.getName() === 'default')
+ : exports.find((symbol) => symbol.getName() === namespaceExportName);
+
+ if (!exportSymbol) {
+ return undefined;
+ }
+
+ componentType = checker.getTypeOfSymbol(exportSymbol);
+ } 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
// ---------------------------------------------------------------------------
diff --git a/yarn.lock b/yarn.lock
index 664999e3247c..6635182c9967 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9072,6 +9072,7 @@ __metadata:
escodegen: "npm:^2.1.0"
expect-type: "npm:^0.15.0"
html-tags: "npm:^3.1.0"
+ memfs: "npm:^4.11.1"
prop-types: "npm:^15.7.2"
react-docgen: "npm:^8.0.2"
react-docgen-typescript: "npm:^2.2.2"