From e062f382848afaee4bbd5a9434ebd030b62ccb9b Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Wed, 12 Nov 2025 12:23:56 +0100 Subject: [PATCH 1/7] CLI: In csf-factories codemod only remove types which are unused --- .../helpers/story-to-csf-factory.test.ts | 78 ++++++++++ .../codemod/helpers/story-to-csf-factory.ts | 137 +++++++++++++++--- 2 files changed, 196 insertions(+), 19 deletions(-) diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts index c6875ca8ad94..814d69522eb6 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts @@ -623,6 +623,84 @@ describe('stories codemod', () => { `); }); + it('should preserve user-defined generic types', async () => { + const result = await transform(dedent` + import { Meta, StoryObj } from '@storybook/react'; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type Data = Record; + interface UnusedButShouldNotBeRemoved { name: string }; + type UnusedAndShouldBeRemoved = Meta; + + export default { title: 'Table' }; + + export const A = { + render: () => { + const data: Data[] = []; + return ; + } + }; + `); + + expect(result).toContain('UnusedButShouldNotBeRemoved'); + expect(result).not.toContain('UnusedAndShouldBeRemoved'); + + expect(result).toMatchInlineSnapshot(` + import preview from '#.storybook/preview'; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type Data = Record; + interface UnusedButShouldNotBeRemoved { + name: string; + } + + const meta = preview.meta({ + title: 'Table', + }); + + export const A = meta.story({ + render: () => { + const data: Data[] = []; + return
; + }, + }); + `); + }); + + it('should remove Storybook-specific type aliases but leave the ones that are actually used', async () => { + await expect( + transform(dedent` + import { Meta, StoryObj, ComponentStory, ComponentMeta } from '@storybook/react'; + import { Button } from './Button'; + + type CustomMeta = Meta; + type CustomStory = StoryObj; + type LegacyStory = ComponentStory; + type LegacyMeta = ComponentMeta; + type ThisShouldNotBeRemoved = Meta; + const something: ThisShouldNotBeRemoved = {}; + + export default { title: 'Button' }; + export const A = {}; + `) + ).resolves.toMatchInlineSnapshot(` + import { Meta } from '@storybook/react'; + + import preview from '#.storybook/preview'; + + import { Button } from './Button'; + + type ThisShouldNotBeRemoved = Meta; + const something: ThisShouldNotBeRemoved = {}; + + const meta = preview.meta({ + title: 'Button', + }); + + export const A = meta.story(); + `); + }); + it.todo('should support non-conventional formats', async () => { const transformed = await transform(dedent` import { Meta, StoryObj as CSF3 } from '@storybook/react'; diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts index c6e784abc1b4..5fe3756eadf1 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts @@ -23,6 +23,120 @@ const typesDisallowList = [ type Options = { previewConfigPath: string; useSubPathImports: boolean }; +function findStorybookTypeAliases(ast: t.File): Map { + const storybookTypeAliases = new Map(); + + traverse(ast, { + TSTypeAliasDeclaration(path) { + if (!t.isIdentifier(path.node.id)) { + return; + } + + let referencesStorybookType = false; + path.traverse({ + TSTypeReference(innerPath) { + if (t.isIdentifier(innerPath.node.typeName)) { + // Check if the type annotation references any Storybook types + if (typesDisallowList.includes(innerPath.node.typeName.name)) { + referencesStorybookType = true; + innerPath.stop(); + } + } else if (t.isTSQualifiedName(innerPath.node.typeName)) { + // Handle qualified names like `Storybook.Meta` + let current = innerPath.node.typeName; + while (t.isTSQualifiedName(current)) { + if (t.isIdentifier(current.right) && typesDisallowList.includes(current.right.name)) { + referencesStorybookType = true; + innerPath.stop(); + break; + } + if (t.isIdentifier(current.left)) { + if (typesDisallowList.includes(current.left.name)) { + referencesStorybookType = true; + innerPath.stop(); + } + break; + } + current = current.left as t.TSQualifiedName; + } + } + }, + }); + + if (referencesStorybookType) { + storybookTypeAliases.set(path.node.id.name, path.node); + } + }, + }); + + return storybookTypeAliases; +} + +/** Find which Storybook-related type aliases are actually used in the code */ +function findUsedTypeNames( + ast: t.File, + storybookTypeAliases: Map +): Set { + const usedTypeNames = new Set(); + + traverse(ast, { + TSTypeReference(path) { + if (t.isIdentifier(path.node.typeName)) { + const typeName = path.node.typeName.name; + // Only track usage if it's not the type declaration itself + const isDeclaration = + path.parent.type === 'TSTypeAliasDeclaration' && + t.isTSTypeAliasDeclaration(path.parent) && + path.parent.typeAnnotation === path.node; + + if (!isDeclaration && storybookTypeAliases.has(typeName)) { + usedTypeNames.add(typeName); + } + } + }, + TSTypeAnnotation(path) { + // Check for type annotations in variable declarations, parameters, etc. + if ( + t.isTSTypeReference(path.node.typeAnnotation) && + t.isIdentifier(path.node.typeAnnotation.typeName) + ) { + const typeName = path.node.typeAnnotation.typeName.name; + if (storybookTypeAliases.has(typeName)) { + usedTypeNames.add(typeName); + } + } + }, + }); + + return usedTypeNames; +} + +/** + * Remove unused Storybook-specific type aliases from the program + * + * Only removes types that: + * + * 1. Reference Storybook types (Meta, Story, StoryObj, etc.) + * 2. Are not actually used anywhere in the code + */ +function removeUnusedStorybookTypes(programNode: t.Program, ast: t.File): void { + const storybookTypeAliases = findStorybookTypeAliases(ast); + const usedTypeNames = findUsedTypeNames(ast, storybookTypeAliases); + + // Collect types to remove + const typeAliasesToRemove = new Set(); + storybookTypeAliases.forEach((node, typeName) => { + if (!usedTypeNames.has(typeName)) { + typeAliasesToRemove.add(node); + } + }); + + // Filter out the unused types from the program body + programNode.body = programNode.body.filter( + (node) => !t.isTSTypeAliasDeclaration(node) || !typeAliasesToRemove.has(node) + ); +} + export async function storyToCsfFactory( info: FileInfo, { previewConfigPath, useSubPathImports }: Options @@ -327,26 +441,11 @@ export async function storyToCsfFactory( programNode.body.unshift(configImport); } - // Remove unused type aliases e.g. `type Story = StoryObj;` - programNode.body.forEach((node, index) => { - if (t.isTSTypeAliasDeclaration(node)) { - const isUsed = programNode.body.some((otherNode) => { - if (t.isVariableDeclaration(otherNode)) { - return otherNode.declarations.some( - (declaration) => - t.isIdentifier(declaration.init) && declaration.init.name === node.id.name - ); - } - return false; - }); - - if (!isUsed) { - programNode.body.splice(index, 1); - } - } - }); + // Remove Storybook-specific type aliases e.g. `type Story = StoryObj;` + // First: Removes types that reference Storybook types AND are not used in the code + removeUnusedStorybookTypes(programNode, csf._ast); - // Remove type imports – now inferred – from @storybook/* packages + // Second: Remove type imports – now inferred – from @storybook/* packages programNode.body = cleanupTypeImports(programNode, typesDisallowList); return printCsf(csf).code; From 82deb7a82c6874e96a3a2eab336951870ca77bb7 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Thu, 13 Nov 2025 12:50:42 +0100 Subject: [PATCH 2/7] extract and rework type removal logic --- .../helpers/remove-unused-types.test.ts | 92 +++++++++++++ .../codemod/helpers/remove-unused-types.ts | 105 ++++++++++++++ .../codemod/helpers/story-to-csf-factory.ts | 129 +----------------- 3 files changed, 202 insertions(+), 124 deletions(-) create mode 100644 code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts create mode 100644 code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.ts diff --git a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts new file mode 100644 index 000000000000..f5a848adcecb --- /dev/null +++ b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { loadCsf, printCsf } from 'storybook/internal/csf-tools'; + +import { dedent } from 'ts-dedent'; + +import { removeUnusedTypes } from './remove-unused-types'; + +expect.addSnapshotSerializer({ + serialize: (val: any) => (typeof val === 'string' ? val : val.toString()), + test: () => true, +}); + +describe('removeUnusedTypes', () => { + const getTransformed = (source: string) => { + const csf = loadCsf(source, { makeTitle: () => 'FIXME' }).parse(); + removeUnusedTypes(csf._ast.program, csf._ast); + return printCsf(csf).code; + }; + + it('should remove unused Storybook types', async () => { + const source = dedent` + import { StoryFn, StoryObj, ComponentStory, Meta, MetaObj, ComponentMeta } from '@storybook/react'; + import { Button } from './Button'; + + // unused types that should be removed + type UnusedAlias = Meta; + type UnusedAlias2 = StoryObj; + type UnusedAlias3 = ComponentStory; + type UnusedAlias4 = ComponentMeta; + type UnusedDeepType = { + foo: { + bar: { + story: StoryObj; + } + } + }; + interface UnusedInterface extends Meta {} + interface UnusedDeepInterface { + baz: { + qux: { + meta: Meta; + } + } + }; + + export default { component: Button }; + `; + const transformed = getTransformed(source); + expect(transformed).toMatchInlineSnapshot(` + import { Button } from './Button'; + + export default { component: Button }; + `); + }); + + it('should not remove used Storybook types', async () => { + const source = dedent` + // Nothing in this file should be removed or modified + import { StoryFn, StoryObj, ComponentStory, Meta, MetaObj, ComponentMeta } from '@storybook/react'; + import { Button } from './Button'; + + type Alias = StoryFn; + type Alias2 = Alias & { b: string }; + type Story = StoryObj & { a: string }; + type DeepType = { + foo: { + bar: { + story: ComponentStory; + } + } + }; + interface Interface extends Meta {} + interface DeepInterface { + baz: { + qux: { + meta: MetaObj; + } + } + }; + const X: ComponentMeta = {} + + function foo(a: Story, c: DeepType, d: Interface, e: DeepInterface){} + + export default {}; + `; + + const transformed = getTransformed(source); + + expect(transformed).toEqual(source); + }); +}); diff --git a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.ts b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.ts new file mode 100644 index 000000000000..3fcbbc03c6df --- /dev/null +++ b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.ts @@ -0,0 +1,105 @@ +import { types as t, traverse } from 'storybook/internal/babel'; + +import { cleanupTypeImports } from './csf-factories-utils'; + +// Name of types that should be removed from the import list +const typesDisallowList = [ + 'Story', + 'StoryFn', + 'StoryObj', + 'Meta', + 'MetaObj', + 'ComponentStory', + 'ComponentMeta', +]; + +// Helper to detect if a type node references (directly) any type in typesDisallowList +function isReferencingDisallowedType( + typeNode: t.TSTypeAliasDeclaration | t.TSInterfaceDeclaration +): boolean { + let found = false; + traverse(typeNode, { + noScope: true, + TSTypeReference(path) { + if ( + t.isIdentifier(path.node.typeName) && + typesDisallowList.includes(path.node.typeName.name) + ) { + found = true; + path.stop(); + } + }, + TSExpressionWithTypeArguments(path) { + if ( + t.isIdentifier(path.node.expression) && + typesDisallowList.includes(path.node.expression.name) + ) { + found = true; + path.stop(); + } + }, + }); + return found; +} + +/** + * Remove unused Storybook-specific type aliases from the program + * + * Only removes types that: + * + * 1. Reference Storybook types (Meta, Story, StoryObj, etc.) + * 2. Are not actually used anywhere in the code + */ +export function removeUnusedTypes(programNode: t.Program, ast: t.File): void { + // Find all type and interface declarations in the AST + const declaredTypes = new Map(); + + traverse(ast, { + TSTypeAliasDeclaration(path) { + const name = path.node.id.name; + declaredTypes.set(name, path.node); + }, + TSInterfaceDeclaration(path) { + const name = path.node.id.name; + declaredTypes.set(name, path.node); + }, + }); + + const declaredTypeNames = new Set(declaredTypes.keys()); + const referencedTypes = new Set(); + + traverse(ast, { + Identifier(path) { + const { node, parent } = path; + + // Only track as "used" if it's not the id of a declaration and refers to a declared type/interface + if ( + declaredTypeNames.has(node.name) && + !( + (t.isTSTypeAliasDeclaration(parent) && parent.id === node) || + (t.isTSInterfaceDeclaration(parent) && parent.id === node) + ) + ) { + referencedTypes.add(node.name); + } + }, + }); + + // First: Removes (only unused) types that reference Storybook types + programNode.body = programNode.body.filter((node) => { + if ( + (t.isTSTypeAliasDeclaration(node) || t.isTSInterfaceDeclaration(node)) && + declaredTypes.has(node.id.name) && + !referencedTypes.has(node.id.name) + ) { + // Remove ONLY IF this type/interface refers to any from typesDisallowList + if (isReferencingDisallowedType(node)) { + return false; + } + } + return true; + }); + + // Second: Remove (only unused) type imports – now inferred – from @storybook/* packages + programNode.body = cleanupTypeImports(programNode, typesDisallowList); +} diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts index 5fe3756eadf1..07d9f27ab195 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts @@ -6,11 +6,8 @@ import path from 'path'; import type { FileInfo } from '../../automigrate/codemod'; import { cleanupTypeImports } from './csf-factories-utils'; +import { removeUnusedTypes } from './remove-unused-types'; -// Name of properties that should not be renamed to `Story.input.xyz` -const reuseDisallowList = ['play', 'run', 'extends', 'story']; - -// Name of types that should be removed from the import list const typesDisallowList = [ 'Story', 'StoryFn', @@ -21,121 +18,10 @@ const typesDisallowList = [ 'ComponentMeta', ]; -type Options = { previewConfigPath: string; useSubPathImports: boolean }; - -function findStorybookTypeAliases(ast: t.File): Map { - const storybookTypeAliases = new Map(); - - traverse(ast, { - TSTypeAliasDeclaration(path) { - if (!t.isIdentifier(path.node.id)) { - return; - } - - let referencesStorybookType = false; - path.traverse({ - TSTypeReference(innerPath) { - if (t.isIdentifier(innerPath.node.typeName)) { - // Check if the type annotation references any Storybook types - if (typesDisallowList.includes(innerPath.node.typeName.name)) { - referencesStorybookType = true; - innerPath.stop(); - } - } else if (t.isTSQualifiedName(innerPath.node.typeName)) { - // Handle qualified names like `Storybook.Meta` - let current = innerPath.node.typeName; - while (t.isTSQualifiedName(current)) { - if (t.isIdentifier(current.right) && typesDisallowList.includes(current.right.name)) { - referencesStorybookType = true; - innerPath.stop(); - break; - } - if (t.isIdentifier(current.left)) { - if (typesDisallowList.includes(current.left.name)) { - referencesStorybookType = true; - innerPath.stop(); - } - break; - } - current = current.left as t.TSQualifiedName; - } - } - }, - }); - - if (referencesStorybookType) { - storybookTypeAliases.set(path.node.id.name, path.node); - } - }, - }); - - return storybookTypeAliases; -} - -/** Find which Storybook-related type aliases are actually used in the code */ -function findUsedTypeNames( - ast: t.File, - storybookTypeAliases: Map -): Set { - const usedTypeNames = new Set(); - - traverse(ast, { - TSTypeReference(path) { - if (t.isIdentifier(path.node.typeName)) { - const typeName = path.node.typeName.name; - // Only track usage if it's not the type declaration itself - const isDeclaration = - path.parent.type === 'TSTypeAliasDeclaration' && - t.isTSTypeAliasDeclaration(path.parent) && - path.parent.typeAnnotation === path.node; - - if (!isDeclaration && storybookTypeAliases.has(typeName)) { - usedTypeNames.add(typeName); - } - } - }, - TSTypeAnnotation(path) { - // Check for type annotations in variable declarations, parameters, etc. - if ( - t.isTSTypeReference(path.node.typeAnnotation) && - t.isIdentifier(path.node.typeAnnotation.typeName) - ) { - const typeName = path.node.typeAnnotation.typeName.name; - if (storybookTypeAliases.has(typeName)) { - usedTypeNames.add(typeName); - } - } - }, - }); - - return usedTypeNames; -} - -/** - * Remove unused Storybook-specific type aliases from the program - * - * Only removes types that: - * - * 1. Reference Storybook types (Meta, Story, StoryObj, etc.) - * 2. Are not actually used anywhere in the code - */ -function removeUnusedStorybookTypes(programNode: t.Program, ast: t.File): void { - const storybookTypeAliases = findStorybookTypeAliases(ast); - const usedTypeNames = findUsedTypeNames(ast, storybookTypeAliases); - - // Collect types to remove - const typeAliasesToRemove = new Set(); - storybookTypeAliases.forEach((node, typeName) => { - if (!usedTypeNames.has(typeName)) { - typeAliasesToRemove.add(node); - } - }); +// Name of properties that should not be renamed to `Story.input.xyz` +const reuseDisallowList = ['play', 'run', 'extends', 'story']; - // Filter out the unused types from the program body - programNode.body = programNode.body.filter( - (node) => !t.isTSTypeAliasDeclaration(node) || !typeAliasesToRemove.has(node) - ); -} +type Options = { previewConfigPath: string; useSubPathImports: boolean }; export async function storyToCsfFactory( info: FileInfo, @@ -441,12 +327,7 @@ export async function storyToCsfFactory( programNode.body.unshift(configImport); } - // Remove Storybook-specific type aliases e.g. `type Story = StoryObj;` - // First: Removes types that reference Storybook types AND are not used in the code - removeUnusedStorybookTypes(programNode, csf._ast); - - // Second: Remove type imports – now inferred – from @storybook/* packages - programNode.body = cleanupTypeImports(programNode, typesDisallowList); + removeUnusedTypes(programNode, csf._ast); return printCsf(csf).code; } From af2326bd000c0af28ce1231ec74a6ab27ed0af58 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Thu, 13 Nov 2025 13:02:31 +0100 Subject: [PATCH 3/7] use diff in test --- .../helpers/remove-unused-types.test.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts index f5a848adcecb..ad398e127d1d 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts @@ -4,6 +4,7 @@ import { loadCsf, printCsf } from 'storybook/internal/csf-tools'; import { dedent } from 'ts-dedent'; +import { getDiff } from '../../../../../core/src/core-server/utils/save-story/getDiff'; import { removeUnusedTypes } from './remove-unused-types'; expect.addSnapshotSerializer({ @@ -20,8 +21,8 @@ describe('removeUnusedTypes', () => { it('should remove unused Storybook types', async () => { const source = dedent` - import { StoryFn, StoryObj, ComponentStory, Meta, MetaObj, ComponentMeta } from '@storybook/react'; import { Button } from './Button'; + import { StoryFn, StoryObj, ComponentStory, Meta, MetaObj, ComponentMeta } from '@storybook/react'; // unused types that should be removed type UnusedAlias = Meta; @@ -47,10 +48,36 @@ describe('removeUnusedTypes', () => { export default { component: Button }; `; const transformed = getTransformed(source); - expect(transformed).toMatchInlineSnapshot(` + expect(getDiff(source, transformed)).toMatchInlineSnapshot(` import { Button } from './Button'; - - export default { component: Button }; + + - import { StoryFn, StoryObj, ComponentStory, Meta, MetaObj, ComponentMeta } from '@storybook/react'; + - + + + - // unused types that should be removed + - type UnusedAlias = Meta; + - type UnusedAlias2 = StoryObj; + - type UnusedAlias3 = ComponentStory; + - type UnusedAlias4 = ComponentMeta; + - type UnusedDeepType = { + - foo: { + - bar: { + - story: StoryObj; + - } + - } + - }; + - interface UnusedInterface extends Meta {} + - interface UnusedDeepInterface { + - baz: { + - qux: { + - meta: Meta; + - } + - } + - }; + - + - + export default { component: Button }; `); }); From 6935742708a6e18da64e664c67a8943f9ccf46f9 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Thu, 13 Nov 2025 13:49:49 +0100 Subject: [PATCH 4/7] try to fix flake --- .../src/codemod/helpers/remove-unused-types.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts index ad398e127d1d..f14f31b6de05 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts @@ -46,8 +46,9 @@ describe('removeUnusedTypes', () => { }; export default { component: Button }; - `; - const transformed = getTransformed(source); + `.replace(/\r\n/g, '\n'); + + const transformed = getTransformed(source).replace(/\r\n/g, '\n'); expect(getDiff(source, transformed)).toMatchInlineSnapshot(` import { Button } from './Button'; From ff0e4b69315b5fb78c296def0c68860bf3ea9f87 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Thu, 13 Nov 2025 06:56:01 -0800 Subject: [PATCH 5/7] fix windows failure --- .../src/codemod/helpers/remove-unused-types.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts index f14f31b6de05..2e698da4eca0 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts @@ -12,6 +12,8 @@ expect.addSnapshotSerializer({ test: () => true, }); +const unescape = (str: string) => str.replace(/\r\n/g, '\n'); + describe('removeUnusedTypes', () => { const getTransformed = (source: string) => { const csf = loadCsf(source, { makeTitle: () => 'FIXME' }).parse(); @@ -46,10 +48,10 @@ describe('removeUnusedTypes', () => { }; export default { component: Button }; - `.replace(/\r\n/g, '\n'); + `; - const transformed = getTransformed(source).replace(/\r\n/g, '\n'); - expect(getDiff(source, transformed)).toMatchInlineSnapshot(` + const transformed = getTransformed(source); + expect(getDiff(unescape(source), unescape(transformed))).toMatchInlineSnapshot(` import { Button } from './Button'; - import { StoryFn, StoryObj, ComponentStory, Meta, MetaObj, ComponentMeta } from '@storybook/react'; @@ -115,6 +117,6 @@ describe('removeUnusedTypes', () => { const transformed = getTransformed(source); - expect(transformed).toEqual(source); + expect(unescape(transformed)).toEqual(unescape(source)); }); }); From a7bedcd4f95ac3ce5017db4f72b0b9cc031d6a60 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Fri, 14 Nov 2025 10:16:31 +0100 Subject: [PATCH 6/7] Use Norbert's AI solution --- .../codemod/helpers/remove-unused-types.ts | 199 ++++++++++++------ 1 file changed, 131 insertions(+), 68 deletions(-) diff --git a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.ts b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.ts index 3fcbbc03c6df..497057dfc66c 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.ts @@ -13,93 +13,156 @@ const typesDisallowList = [ 'ComponentMeta', ]; -// Helper to detect if a type node references (directly) any type in typesDisallowList -function isReferencingDisallowedType( - typeNode: t.TSTypeAliasDeclaration | t.TSInterfaceDeclaration -): boolean { - let found = false; - traverse(typeNode, { - noScope: true, - TSTypeReference(path) { - if ( - t.isIdentifier(path.node.typeName) && - typesDisallowList.includes(path.node.typeName.name) - ) { - found = true; - path.stop(); - } - }, - TSExpressionWithTypeArguments(path) { - if ( - t.isIdentifier(path.node.expression) && - typesDisallowList.includes(path.node.expression.name) - ) { - found = true; - path.stop(); - } - }, - }); - return found; -} +const disallowedTypesSet = new Set(typesDisallowList); /** - * Remove unused Storybook-specific type aliases from the program + * Remove unused Storybook-specific type aliases from the program. * - * Only removes types that: + * Conditions to remove a declared type/interface: * - * 1. Reference Storybook types (Meta, Story, StoryObj, etc.) - * 2. Are not actually used anywhere in the code + * - It is declared in the file, + * - It is not referenced anywhere in the file, + * - AND it (the declaration) references at least one Storybook type from typesDisallowList. + * + * This implementation performs a single traversal of `ast`. During traversal we: + * + * - Collect declared type names, + * - Record references to declared types (including handling references that appear before + * declarations), + * - Detect per-declaration whether it references any disallowed Storybook type, and then perform a + * single filter pass on program.body. */ export function removeUnusedTypes(programNode: t.Program, ast: t.File): void { - // Find all type and interface declarations in the AST - const declaredTypes = new Map(); + // Declared type/interface names seen in this file + const declaredTypes = new Set(); - traverse(ast, { - TSTypeAliasDeclaration(path) { - const name = path.node.id.name; - declaredTypes.set(name, path.node); - }, - TSInterfaceDeclaration(path) { - const name = path.node.id.name; - declaredTypes.set(name, path.node); - }, - }); - - const declaredTypeNames = new Set(declaredTypes.keys()); + // Names of declared types that are referenced somewhere in the file const referencedTypes = new Set(); + // Temporary: identifier names seen before we encountered their declaration + // This lets us count forward references (identifier appears before type is declared). + const pendingIdentifierNames = new Set(); + + // Names of type declarations that (somewhere in their AST) reference a disallowed Storybook type + const typeDeclReferencesDisallowed = new Set(); + traverse(ast, { - Identifier(path) { - const { node, parent } = path; + enter(path) { + const node = path.node; - // Only track as "used" if it's not the id of a declaration and refers to a declared type/interface - if ( - declaredTypeNames.has(node.name) && - !( - (t.isTSTypeAliasDeclaration(parent) && parent.id === node) || - (t.isTSInterfaceDeclaration(parent) && parent.id === node) - ) - ) { - referencedTypes.add(node.name); + // 1) When we encounter a type/interface declaration, register it. + if (path.isTSTypeAliasDeclaration() || path.isTSInterfaceDeclaration()) { + // These always have an `id` property that's an Identifier + const idNode = (node as t.TSTypeAliasDeclaration | t.TSInterfaceDeclaration).id; + const name = idNode && t.isIdentifier(idNode) ? idNode.name : undefined; + if (name) { + declaredTypes.add(name); + + // If we previously saw identifiers with this name before the declaration, + // count them now as references (handles reference-before-declaration). + if (pendingIdentifierNames.has(name)) { + referencedTypes.add(name); + } + } + + // No need to traverse into the id itself here; we still want to traverse the + // declaration body so that disallowed-type references inside are detected + // by the TSTypeReference/TSExpressionWithTypeArguments handlers below. + return; + } + + // 2) Track identifier references to declared types. + if (path.isIdentifier()) { + const identifierNode = node as t.Identifier; + const name = identifierNode.name; + + // Skip the identifier that *is* the declaration id itself: + // parent is TSTypeAliasDeclaration or TSInterfaceDeclaration and its id is this node + const parentPath = path.parentPath; + if ( + parentPath && + (parentPath.isTSTypeAliasDeclaration() || parentPath.isTSInterfaceDeclaration()) + ) { + const parentIdNode = ( + parentPath.node as t.TSTypeAliasDeclaration | t.TSInterfaceDeclaration + ).id; + if (parentIdNode === node) { + return; + } + } + + // If we've already seen the declaration, mark as referenced. + if (declaredTypes.has(name)) { + referencedTypes.add(name); + } else { + // Otherwise record as pending — if the declaration appears later, we'll promote it. + pendingIdentifierNames.add(name); + } + + return; + } + + // 3) Detect references to disallowed Storybook types inside type declarations. + // If we find one, record which type declaration (owner) contains it. + if (path.isTSTypeReference()) { + const typeRefNode = node as t.TSTypeReference; + const typeNameNode = typeRefNode.typeName; + + if (t.isIdentifier(typeNameNode) && disallowedTypesSet.has(typeNameNode.name)) { + // Find the nearest enclosing type declaration (alias or interface) + const owner = path.findParent( + (p) => p.isTSTypeAliasDeclaration() || p.isTSInterfaceDeclaration() + ); + if (owner && (owner.isTSTypeAliasDeclaration() || owner.isTSInterfaceDeclaration())) { + const ownerId = (owner.node as t.TSTypeAliasDeclaration | t.TSInterfaceDeclaration).id; + const ownerName = t.isIdentifier(ownerId) ? ownerId.name : undefined; + if (ownerName) { + typeDeclReferencesDisallowed.add(ownerName); + } + } + } + + return; + } + + if (path.isTSExpressionWithTypeArguments()) { + const tsExprNode = node as t.TSExpressionWithTypeArguments; + const expr = tsExprNode.expression; + if (t.isIdentifier(expr) && disallowedTypesSet.has(expr.name)) { + const owner = path.findParent( + (p) => p.isTSTypeAliasDeclaration() || p.isTSInterfaceDeclaration() + ); + if (owner && (owner.isTSTypeAliasDeclaration() || owner.isTSInterfaceDeclaration())) { + const ownerId = (owner.node as t.TSTypeAliasDeclaration | t.TSInterfaceDeclaration).id; + const ownerName = t.isIdentifier(ownerId) ? ownerId.name : undefined; + if (ownerName) { + typeDeclReferencesDisallowed.add(ownerName); + } + } + } + return; } }, }); - // First: Removes (only unused) types that reference Storybook types + // Final pass: remove unused declared types that reference disallowed types programNode.body = programNode.body.filter((node) => { - if ( - (t.isTSTypeAliasDeclaration(node) || t.isTSInterfaceDeclaration(node)) && - declaredTypes.has(node.id.name) && - !referencedTypes.has(node.id.name) - ) { - // Remove ONLY IF this type/interface refers to any from typesDisallowList - if (isReferencingDisallowedType(node)) { - return false; + if (t.isTSTypeAliasDeclaration(node) || t.isTSInterfaceDeclaration(node)) { + const name = node.id.name; + + // If it's a declared type, unused, and references a disallowed Storybook type — remove it. + if ( + declaredTypes.has(name) && + !referencedTypes.has(name) && + typeDeclReferencesDisallowed.has(name) + ) { + return false; // filter out (remove) } } - return true; + + return true; // keep everything else }); - // Second: Remove (only unused) type imports – now inferred – from @storybook/* packages + // Cleanup any now-unused Storybook type imports (keeps original API: pass array) programNode.body = cleanupTypeImports(programNode, typesDisallowList); } From bc61ca4a7221d7e640ee341d33dcd588cfef072b Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Fri, 14 Nov 2025 10:30:05 +0100 Subject: [PATCH 7/7] update tests --- .../src/codemod/helpers/remove-unused-types.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts index 2e698da4eca0..cfafe0400e24 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.test.ts @@ -24,7 +24,9 @@ describe('removeUnusedTypes', () => { it('should remove unused Storybook types', async () => { const source = dedent` import { Button } from './Button'; - import { StoryFn, StoryObj, ComponentStory, Meta, MetaObj, ComponentMeta } from '@storybook/react'; + import { StoryFn, StoryObj } from '@storybook/react'; + import type { Meta, MetaObj } from '@storybook/react'; + import { type ComponentStory, type ComponentMeta } from '@storybook/react'; // unused types that should be removed type UnusedAlias = Meta; @@ -54,7 +56,9 @@ describe('removeUnusedTypes', () => { expect(getDiff(unescape(source), unescape(transformed))).toMatchInlineSnapshot(` import { Button } from './Button'; - - import { StoryFn, StoryObj, ComponentStory, Meta, MetaObj, ComponentMeta } from '@storybook/react'; + - import { StoryFn, StoryObj } from '@storybook/react'; + - import type { Meta, MetaObj } from '@storybook/react'; + - import { type ComponentStory, type ComponentMeta } from '@storybook/react'; -