From 5c6c11921c0fafae42897f90b8b656ab92bdea70 Mon Sep 17 00:00:00 2001 From: Dario Date: Thu, 7 May 2026 16:31:00 +0100 Subject: [PATCH] fix(parse): unwrap TSSatisfiesExpression in named-identifier default-export init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ladle's getDefaultExport walker unwraps TSSatisfiesExpression / TSAsExpression when the *direct declaration* is wrapped, e.g. `export default { … } satisfies Meta<…>;`. It did not unwrap the same expression when it appears as the **init** of a named identifier that is then re-exported by name: const meta = { title: 'X', component: Y } satisfies Meta; export default meta; The named-identifier branch resolves objNode to the init expression but does not strip the `satisfies` wrapper, so objNode.properties is undefined and title/meta silently never reach the result. Story files using this pattern were dropped from the index. Mirror the existing direct-declaration unwrap. ~6 LOC. Add unit tests for the `satisfies` and `as` named-identifier patterns. --- .../parse-tssatisfies-named-default-export.md | 19 +++++++++ .../vite-plugin/parse/get-default-export.js | 13 ++++++ .../tests/parse/get-default-export.test.ts | 40 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 .changeset/parse-tssatisfies-named-default-export.md diff --git a/.changeset/parse-tssatisfies-named-default-export.md b/.changeset/parse-tssatisfies-named-default-export.md new file mode 100644 index 00000000..883bf1d3 --- /dev/null +++ b/.changeset/parse-tssatisfies-named-default-export.md @@ -0,0 +1,19 @@ +--- +"@ladle/react": patch +--- + +fix(parse): unwrap `TSSatisfiesExpression` / `TSAsExpression` in named-identifier default-export init + +Ladle's `getDefaultExport` walker handles `TSSatisfiesExpression` / `TSAsExpression` when the _direct declaration_ is wrapped, e.g. `export default { … } satisfies Meta<…>;`. It did not unwrap the same expression when it appears as the **init** of a named identifier that is then re-exported by name: + +```ts +const meta = { + title: "Components/Button", + component: Button, +} satisfies Meta; +export default meta; +``` + +`objNode` ended up pointing at the `TSSatisfiesExpression` rather than the underlying `ObjectExpression`. The subsequent `objNode.properties.forEach` iterated over an empty array (the satisfies node has no `.properties`), so the `title` and `meta` fields silently never reached the result, and story files using this pattern were dropped from the index entirely. + +Mirrors the existing direct-declaration unwrap pattern. Adds two new unit tests covering `satisfies` and `as` in this position. diff --git a/packages/ladle/lib/cli/vite-plugin/parse/get-default-export.js b/packages/ladle/lib/cli/vite-plugin/parse/get-default-export.js index 3537ee2f..06dcaaec 100644 --- a/packages/ladle/lib/cli/vite-plugin/parse/get-default-export.js +++ b/packages/ladle/lib/cli/vite-plugin/parse/get-default-export.js @@ -19,6 +19,19 @@ const getDefaultExport = (result, astPath) => { ) { objNode = astPath.node.declaration.expression; } + // Unwrap TSSatisfiesExpression / TSAsExpression that appears as the *init* + // of a named identifier default-export, e.g. + // const meta = { ... } satisfies Meta; + // export default meta; + // The named-identifier branch above resolves `objNode` to the init + // expression, but does not strip the `satisfies` wrapper. + if ( + objNode && + (objNode.type === "TSAsExpression" || + objNode.type === "TSSatisfiesExpression") + ) { + objNode = objNode.expression; + } objNode && objNode.properties.forEach((/** @type {any} */ prop) => { if (prop.type === "ObjectProperty" && prop.key.name === "title") { diff --git a/packages/ladle/tests/parse/get-default-export.test.ts b/packages/ladle/tests/parse/get-default-export.test.ts index a59f8112..2870615e 100644 --- a/packages/ladle/tests/parse/get-default-export.test.ts +++ b/packages/ladle/tests/parse/get-default-export.test.ts @@ -80,3 +80,43 @@ test("Throw an error if default export is not serializable", async () => { `Can't parse the default title and meta of file.js. Meta must be serializable and title a string literal.`, ); }); + +test("Get default export through named identifier with `satisfies`", async () => { + // CSF v3 / Storybook style: + // const meta = { ... } satisfies Meta; + // export default meta; + // Without the unwrap, `objNode` lands on the TSSatisfiesExpression + // (whose `.properties` is undefined), so title and meta silently never + // reach the result. + expect( + parseWithFn( + `const meta = { title: 'Title', meta: { some: 'foo' } } satisfies SomeType; + export default meta;`, + {}, + getDefaultExport, + "ExportDefaultDeclaration", + "foo.stories.tsx", + ), + ).toEqual( + getOutput({ + exportDefaultProps: { title: "Title", meta: { some: "foo" } }, + }), + ); +}); + +test("Get default export through named identifier with `as`", async () => { + expect( + parseWithFn( + `const meta = { title: 'Title', meta: { flag: true } } as SomeType; + export default meta;`, + {}, + getDefaultExport, + "ExportDefaultDeclaration", + "foo.stories.tsx", + ), + ).toEqual( + getOutput({ + exportDefaultProps: { title: "Title", meta: { flag: true } }, + }), + ); +});