Skip to content
Open
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
16 changes: 16 additions & 0 deletions .changeset/csf-v3-support.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions e2e/csf3/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
5 changes: 5 additions & 0 deletions e2e/csf3/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import getPlaywrightConfig from "@ladle/playwright-config";

export default getPlaywrightConfig({
port: 61113,
});
69 changes: 69 additions & 0 deletions e2e/csf3/src/csf-v3.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<P>` extends `React.FC<P>`, 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) => (
<p data-testid="greeter">
{greeting}, {name}!
</p>
);

export default {
title: "csf-v3",
component: Greeter,
args: {
name: "world",
},
} satisfies StoryDefault<GreeterProps> & { 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: () => <p data-testid="render-only">render-only output</p>,
};

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

// CSF v3 empty — defensive fallback path; `meta.component` takes over.
export const Empty = {};
60 changes: 60 additions & 0 deletions e2e/csf3/tests/csf-v3.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
6 changes: 6 additions & 0 deletions e2e/csf3/vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
server: {
open: "none",
host: "127.0.0.1",
},
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"e2e/commonjs",
"e2e/config",
"e2e/config-ts",
"e2e/csf3",
"e2e/css",
"e2e/decorators",
"e2e/playwright",
Expand Down
30 changes: 27 additions & 3 deletions packages/ladle/lib/app/src/compose-enhancers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
21 changes: 21 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ packages:
- 'e2e/commonjs'
- 'e2e/config'
- 'e2e/config-ts'
- 'e2e/csf3'
- 'e2e/css'
- 'e2e/decorators'
- 'e2e/playwright'
Expand Down
Loading