diff --git a/.changeset/csf-v3-support.md b/.changeset/csf-v3-support.md new file mode 100644 index 00000000..d7b5d298 --- /dev/null +++ b/.changeset/csf-v3-support.md @@ -0,0 +1,16 @@ +--- +"@ladle/react": patch +--- + +fix(app): support CSF v3 stories in `composeEnhancers` (#602) + +Storybook's Component Story Format v3 (the default since Storybook 7) describes a story as a plain object — `{ args?, render?, ... }` — rather than a React function component with property attachments (CSF v2). Ladle's `composeEnhancers` previously passed `module[storyName]` directly as `component` to `ArgsProvider`, whose `React.createElement(component, props)` then threw `Element type is invalid: expected a string ... but got: object`. + +`composeEnhancers` now resolves the named export to a real component before handing it to `ArgsProvider`: + +- If the named export is a function (CSF v2), use it directly. +- If it's an object with a `render` function (CSF v3 render-style), wrap that render function so `createElement` receives a function. +- Else if `meta.component` is present (CSF v3 args-only / empty / decorators-only), render `meta.component` with the merged args. +- Else fall back to a defensive renderer. + +CSF v2 stories are unchanged — they continue to follow the existing `typeof storyExport === "function"` path. Args/argTypes spread now reads from `storyExport` symmetrically for both shapes. New `e2e/csf3` Playwright fixture covers args-only, render-only, render+args, and empty CSF v3 shapes. diff --git a/e2e/csf3/package.json b/e2e/csf3/package.json new file mode 100644 index 00000000..650502b6 --- /dev/null +++ b/e2e/csf3/package.json @@ -0,0 +1,23 @@ +{ + "name": "test-csf3", + "version": "0.0.0", + "license": "MIT", + "private": true, + "scripts": { + "serve": "ladle serve -p 61113", + "serve-prod": "ladle preview -p 61113", + "build": "ladle build", + "lint": "echo 'no lint'", + "test-dev": "cross-env TYPE=dev pnpm exec playwright test", + "test-prod": "cross-env TYPE=prod pnpm exec playwright test", + "test": "pnpm test-dev && pnpm test-prod" + }, + "dependencies": { + "@ladle/playwright-config": "workspace:*", + "@ladle/react": "workspace:*", + "@playwright/test": "^1.49.1", + "cross-env": "^7.0.3", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/e2e/csf3/playwright.config.ts b/e2e/csf3/playwright.config.ts new file mode 100644 index 00000000..f5d3a0bb --- /dev/null +++ b/e2e/csf3/playwright.config.ts @@ -0,0 +1,5 @@ +import getPlaywrightConfig from "@ladle/playwright-config"; + +export default getPlaywrightConfig({ + port: 61113, +}); diff --git a/e2e/csf3/src/csf-v3.stories.tsx b/e2e/csf3/src/csf-v3.stories.tsx new file mode 100644 index 00000000..6b456e87 --- /dev/null +++ b/e2e/csf3/src/csf-v3.stories.tsx @@ -0,0 +1,69 @@ +import type { StoryDefault } from "@ladle/react"; + +// CSF v3 fixture coverage for compose-enhancers.tsx (#602). +// Storybook's Component Story Format v3 (the default since Storybook 7, 2023) +// describes a story as a plain object: { args?, render?, ... }. Ladle 5.1.1 +// historically only handled CSF v2 (named export = React.FC with .args / +// .argTypes / .decorators attached as function properties), so passing a +// plain object to ArgsProvider's React.createElement(component, props) threw +// "Element type is invalid: expected a string ... but got: object". +// +// The composeEnhancers fix resolves a real component before passing it to +// ArgsProvider. These stories exercise the four CSF v3 shapes the fix +// supports: +// 1. args-only with meta.component (meta.component fallback path) +// 2. render-only (renderFn wrapping path) +// 3. render+args (renderFn receives merged args) +// 4. empty (defensive fallback path; meta.component takes over here) +// +// Note on types: Ladle's `Story

` extends `React.FC

`, so the CSF v3 +// object shape can't be typed with Ladle's own `Story` export. These +// fixtures use plain object literals (matching what Storybook codebases +// produce when they import `StoryObj` from `@storybook/react`). + +type GreeterProps = { + name: string; + greeting?: string; +}; + +const Greeter = ({ name, greeting = "Hello" }: GreeterProps) => ( +

+ {greeting}, {name}! +

+); + +export default { + title: "csf-v3", + component: Greeter, + args: { + name: "world", + }, +} satisfies StoryDefault & { component: typeof Greeter }; + +// CSF v3 args-only — relies on `meta.component` fallback. +export const ArgsOnly = { + args: { + name: "args-only", + }, +}; + +// CSF v3 render-only — exercises the renderFn wrapping path. +export const RenderOnly = { + render: () =>

render-only output

, +}; + +// CSF v3 render + args — render receives merged args. +export const RenderWithArgs = { + args: { + name: "render-with-args", + greeting: "Hi", + }, + render: (args: GreeterProps) => ( +

+ {args.greeting}, {args.name}! +

+ ), +}; + +// CSF v3 empty — defensive fallback path; `meta.component` takes over. +export const Empty = {}; diff --git a/e2e/csf3/tests/csf-v3.spec.ts b/e2e/csf3/tests/csf-v3.spec.ts new file mode 100644 index 00000000..af0bb6f7 --- /dev/null +++ b/e2e/csf3/tests/csf-v3.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from "@playwright/test"; + +// CSF v3 stories should resolve to a real React component via composeEnhancers +// before ArgsProvider's createElement runs (#602). Without the fix, every page +// throws "Element type is invalid ... got: object" inside ArgsProvider and the +// story does not mount. + +test("CSF v3 args-only renders meta.component with merged args", async ({ + page, +}) => { + await page.goto("/?story=csf-v3--args-only"); + await expect(page.locator('[data-testid="greeter"]')).toHaveText( + "Hello, args-only!", + ); +}); + +test("CSF v3 render-only invokes the render function", async ({ page }) => { + await page.goto("/?story=csf-v3--render-only"); + await expect(page.locator('[data-testid="render-only"]')).toHaveText( + "render-only output", + ); +}); + +test("CSF v3 render+args passes merged args to the render function", async ({ + page, +}) => { + await page.goto("/?story=csf-v3--render-with-args"); + await expect(page.locator('[data-testid="render-with-args"]')).toHaveText( + "Hi, render-with-args!", + ); +}); + +test("CSF v3 empty story falls back to meta.component", async ({ page }) => { + await page.goto("/?story=csf-v3--empty"); + await expect(page.locator('[data-testid="greeter"]')).toHaveText( + "Hello, world!", + ); +}); + +test("CSF v3 stories raise no ArgsProvider createElement errors", async ({ + page, +}) => { + const consoleErrors: string[] = []; + page.on("pageerror", (err) => consoleErrors.push(err.message)); + page.on("console", (msg) => { + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + for (const storyId of [ + "csf-v3--args-only", + "csf-v3--render-only", + "csf-v3--render-with-args", + "csf-v3--empty", + ]) { + await page.goto(`/?story=${storyId}`); + await page.waitForSelector("[data-storyloaded]"); + } + expect( + consoleErrors.filter((e) => /Element type is invalid/i.test(e)), + ).toEqual([]); +}); diff --git a/e2e/csf3/vite.config.js b/e2e/csf3/vite.config.js new file mode 100644 index 00000000..bf705ebf --- /dev/null +++ b/e2e/csf3/vite.config.js @@ -0,0 +1,6 @@ +export default { + server: { + open: "none", + host: "127.0.0.1", + }, +}; diff --git a/package.json b/package.json index f5593c5f..ad4a1a52 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "e2e/commonjs", "e2e/config", "e2e/config-ts", + "e2e/csf3", "e2e/css", "e2e/decorators", "e2e/playwright", diff --git a/packages/ladle/lib/app/src/compose-enhancers.tsx b/packages/ladle/lib/app/src/compose-enhancers.tsx index 2ffc8fa5..9f23673e 100644 --- a/packages/ladle/lib/app/src/compose-enhancers.tsx +++ b/packages/ladle/lib/app/src/compose-enhancers.tsx @@ -16,20 +16,44 @@ export default function composeEnhancers(module: any, storyName: string) { if (module[storyName] && module[storyName].msw) { mswHandlers = module[storyName].msw; } + // CSF v3 support: the named export may be a plain object + // ({ args?, render?, … }) rather than a React.FC. ArgsProvider does + // React.createElement(component, props), which throws on an object. + // Resolve to a real component before passing it down. + const storyExport = module[storyName]; + let resolvedComponent: any; + if (typeof storyExport === "function") { + // CSF v2 — the named export IS the component. + resolvedComponent = storyExport; + } else if (storyExport && typeof storyExport === "object") { + if (typeof storyExport.render === "function") { + const renderFn = storyExport.render; + resolvedComponent = (renderProps: any) => renderFn(renderProps); + } else if (module.default && module.default.component) { + // Args-only / empty / decorators-only — render meta.component with args. + resolvedComponent = module.default.component; + } else { + // Defensive fallback. + resolvedComponent = (renderProps: any) => + renderProps && renderProps.children ? renderProps.children : null; + } + } else { + resolvedComponent = storyExport; + } const props = { args: { ...args, ...(module.default && module.default.args ? module.default.args : {}), - ...(module[storyName].args ? module[storyName].args : {}), + ...(storyExport && storyExport.args ? storyExport.args : {}), }, argTypes: { ...argTypes, ...(module.default && module.default.argTypes ? module.default.argTypes : {}), - ...(module[storyName].argTypes ? module[storyName].argTypes : {}), + ...(storyExport && storyExport.argTypes ? storyExport.argTypes : {}), }, - component: module[storyName], + component: resolvedComponent, }; if (module[storyName] && Array.isArray(module[storyName].decorators)) { decorators = [...decorators, ...module[storyName].decorators]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2f8bdd1..ad5728fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,27 @@ importers: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) + e2e/csf3: + dependencies: + '@ladle/playwright-config': + specifier: workspace:* + version: link:../playwright-config + '@ladle/react': + specifier: workspace:* + version: link:../../packages/ladle + '@playwright/test': + specifier: ^1.49.1 + version: 1.49.1 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + e2e/css: dependencies: '@ladle/playwright-config': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1b423edc..0ae8cd7e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ packages: - 'e2e/commonjs' - 'e2e/config' - 'e2e/config-ts' + - 'e2e/csf3' - 'e2e/css' - 'e2e/decorators' - 'e2e/playwright'