Skip to content
Merged
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
1 change: 1 addition & 0 deletions code/renderers/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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) => <button />;

interface AlignerProps {
side?: 'start' | 'end';
}
const Aligner = (props: AlignerProps) => <div />;

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<typeof Button>;

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) => <div>{a}{b}</div>;

type SubcomponentAProps = { e: boolean; c: boolean; d?: boolean };
export const SubcomponentA = ({ d = false }: SubcomponentAProps) => <div />;

type SubcomponentBProps = { g: number; h: number; f?: number };
export const SubcomponentB = ({ f = 42 }: SubcomponentBProps) => <div />;
`,
'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<typeof UI.ControlsParameters>;

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) => <div>{a}{b}</div>;

type SubcomponentAProps = { e: boolean; c: boolean; d?: boolean };
export const SubcomponentA = ({ d = false }: SubcomponentAProps) => <div />;

type SubcomponentBProps = { g: number; h: number; f?: number };
export const SubcomponentB = ({ f = 42 }: SubcomponentBProps) => <div />;
`,
'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<typeof ControlsParameters>;

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(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -29,19 +30,58 @@ 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<T>(
files: Record<string, string>,
fn: (project: ComponentMetaProject, filePaths: Record<string, string>) => T | Promise<T>
): Promise<T> {
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);
}
(sharedProject as any).projectVersion++;
return fn(sharedProject, filePaths);
}

/** Parses a story file and resolves declared subcomponents for extraction tests. */
export async function loadDeclaredSubcomponentComponents({
filePaths,
storyFileName,
title,
}: {
filePaths: Record<string, string>;
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.
Expand All @@ -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();

Expand Down
Loading