diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index 9314446e0a74..49997225fef7 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -17,12 +17,12 @@ import { import { DocsContext } from '@storybook/blocks'; import { global } from '@storybook/global'; import type { Decorator, Loader, ReactRenderer } from '@storybook/react'; -import { definePreview } from '@storybook/react'; // TODO add empty preview // import * as storysource from '@storybook/addon-storysource'; // import * as designs from '@storybook/addon-designs/preview'; import addonTest from '@storybook/experimental-addon-test'; +import { definePreview } from '@storybook/react-vite'; import addonA11y from '@storybook/addon-a11y'; import addonEssentials from '@storybook/addon-essentials'; diff --git a/code/.storybook/storybook.setup.ts b/code/.storybook/storybook.setup.ts index 81f5202d719c..80160218a314 100644 --- a/code/.storybook/storybook.setup.ts +++ b/code/.storybook/storybook.setup.ts @@ -3,12 +3,12 @@ import { beforeAll, vi, expect as vitestExpect } from 'vitest'; import { setProjectAnnotations } from '@storybook/react'; import { userEvent as storybookEvent, expect as storybookExpect } from '@storybook/test'; -import previw from './preview'; +import preview from './preview'; vi.spyOn(console, 'warn').mockImplementation((...args) => console.log(...args)); const annotations = setProjectAnnotations([ - previw.input, + preview.composed, { // experiment with injecting Vitest's interactivity API over our userEvent while tests run in browser mode // https://vitest.dev/guide/browser/interactivity-api.html diff --git a/code/addons/test/src/vitest-plugin/test-utils.ts b/code/addons/test/src/vitest-plugin/test-utils.ts index 765ab38d5a1b..f8259d4445fb 100644 --- a/code/addons/test/src/vitest-plugin/test-utils.ts +++ b/code/addons/test/src/vitest-plugin/test-utils.ts @@ -33,7 +33,7 @@ export const testStory = ( const annotations = getCsfFactoryAnnotations(story, meta); const composedStory = composeStory( annotations.story, - annotations.meta, + annotations.meta!, { initialGlobals: (await getInitialGlobals?.()) ?? {}, tags: await getTags?.() }, annotations.preview, exportName diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts index 69b05dc5e744..7b8f82a3f330 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -24,10 +24,9 @@ export async function generateModernIframeScriptCode(options: Options, projectRo const getPreviewAnnotationsFunction = ` const getProjectAnnotations = async (hmrPreviewAnnotationModules = []) => { const preview = await import('${previewFileUrl}'); - const csfFactoryPreview = getCsfFactoryPreview(preview); - - if (csfFactoryPreview) { - return csfFactoryPreview.input; + + if (isPreview(preview.default)) { + return preview.default.composed; } const configs = await Promise.all([${previewAnnotationURLs @@ -79,7 +78,8 @@ export async function generateModernIframeScriptCode(options: Options, projectRo setup(); - import { composeConfigs, PreviewWeb, ClientApi, getCsfFactoryPreview } from 'storybook/internal/preview-api'; + import { composeConfigs, PreviewWeb, ClientApi } from 'storybook/internal/preview-api'; + import { isPreview } from 'storybook/internal/csf'; import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}'; diff --git a/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js b/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js index 423bee0c36d0..0ed2fe15fa85 100644 --- a/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js +++ b/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js @@ -1,4 +1,5 @@ import { createBrowserChannel } from 'storybook/internal/channels'; +import { isPreview } from 'storybook/internal/csf'; import { PreviewWeb, addons, composeConfigs } from 'storybook/internal/preview-api'; import { global } from '@storybook/global'; @@ -8,14 +9,10 @@ import { importFn } from '{{storiesFilename}}'; const getProjectAnnotations = () => { const previewAnnotations = ['{{previewAnnotations_requires}}']; // the last one in this array is the user preview - const preview = previewAnnotations[previewAnnotations.length - 1]; + const userPreview = previewAnnotations[previewAnnotations.length - 1]?.default; - const csfFactoryPreview = Object.values(preview).find((module) => { - return 'isCSFFactoryPreview' in module; - }); - - if (csfFactoryPreview) { - return csfFactoryPreview.annotations; + if (isPreview(userPreview)) { + return userPreview.composed; } return composeConfigs(previewAnnotations); diff --git a/code/core/package.json b/code/core/package.json index 65dacdcff300..9c1db3bec132 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -97,6 +97,11 @@ "import": "./dist/csf-tools/index.js", "require": "./dist/csf-tools/index.cjs" }, + "./csf": { + "types": "./dist/csf/index.d.ts", + "import": "./dist/csf/index.js", + "require": "./dist/csf/index.cjs" + }, "./common": { "types": "./dist/common/index.d.ts", "import": "./dist/common/index.js", @@ -219,6 +224,9 @@ "csf-tools": [ "./dist/csf-tools/index.d.ts" ], + "csf": [ + "./dist/csf/index.d.ts" + ], "common": [ "./dist/common/index.d.ts" ], diff --git a/code/core/scripts/entries.ts b/code/core/scripts/entries.ts index a8588f13fd86..2b90e2c4b8a4 100644 --- a/code/core/scripts/entries.ts +++ b/code/core/scripts/entries.ts @@ -25,6 +25,7 @@ export const getEntries = (cwd: string) => { define('src/channels/index.ts', ['browser', 'node'], true), define('src/types/index.ts', ['browser', 'node'], true, ['react']), define('src/csf-tools/index.ts', ['node'], true), + define('src/csf/index.ts', ['browser', 'node'], true), define('src/common/index.ts', ['node'], true), define('src/builder-manager/index.ts', ['node'], true), define('src/telemetry/index.ts', ['node'], true), diff --git a/code/core/src/csf-tools/ConfigFile.test.ts b/code/core/src/csf-tools/ConfigFile.test.ts index 6425a0ced108..bd9c6b97d482 100644 --- a/code/core/src/csf-tools/ConfigFile.test.ts +++ b/code/core/src/csf-tools/ConfigFile.test.ts @@ -247,7 +247,7 @@ describe('ConfigFile', () => { describe('factory config', () => { it('parses correctly', () => { const source = dedent` - import { definePreview } from '@storybook/react-vite/preview'; + import { definePreview } from '@storybook/react-vite'; const config = definePreview({ framework: 'foo', @@ -262,7 +262,7 @@ describe('ConfigFile', () => { getField( ['core', 'builder'], dedent` - import { definePreview } from '@storybook/react-vite/preview'; + import { definePreview } from '@storybook/react-vite'; export const foo = definePreview({ core: { builder: 'webpack5' } }); ` ) @@ -273,7 +273,7 @@ describe('ConfigFile', () => { getField( ['tags'], dedent` - import { definePreview } from '@storybook/react-vite/preview'; + import { definePreview } from '@storybook/react-vite'; const parameters = {}; export const config = definePreview({ parameters, @@ -528,14 +528,14 @@ describe('ConfigFile', () => { ['core', 'builder'], 'webpack5', dedent` - import { definePreview } from '@storybook/react-vite/preview'; + import { definePreview } from '@storybook/react-vite'; export const foo = definePreview({ addons: [], }); ` ) ).toMatchInlineSnapshot(` - import { definePreview } from '@storybook/react-vite/preview'; + import { definePreview } from '@storybook/react-vite'; export const foo = definePreview({ addons: [], @@ -551,14 +551,14 @@ describe('ConfigFile', () => { ['core', 'builder'], 'webpack5', dedent` - import { definePreview } from '@storybook/react-vite/preview'; + import { definePreview } from '@storybook/react-vite'; export const foo = definePreview({ core: { foo: 'bar' }, }); ` ) ).toMatchInlineSnapshot(` - import { definePreview } from '@storybook/react-vite/preview'; + import { definePreview } from '@storybook/react-vite'; export const foo = definePreview({ core: { foo: 'bar', @@ -573,14 +573,14 @@ describe('ConfigFile', () => { ['core', 'builder'], 'webpack5', dedent` - import { definePreview } from '@storybook/react-vite/preview'; + import { definePreview } from '@storybook/react-vite'; export const foo = definePreview({ core: { builder: 'webpack4' }, }); ` ) ).toMatchInlineSnapshot(` - import { definePreview } from '@storybook/react-vite/preview'; + import { definePreview } from '@storybook/react-vite'; export const foo = definePreview({ core: { builder: 'webpack5' }, }); diff --git a/code/core/src/csf/index.ts b/code/core/src/csf/index.ts new file mode 100644 index 000000000000..0c0ea4cbe21a --- /dev/null +++ b/code/core/src/csf/index.ts @@ -0,0 +1,96 @@ +/* eslint-disable no-underscore-dangle */ +import type { + Args, + ComponentAnnotations, + NormalizedComponentAnnotations, + NormalizedProjectAnnotations, + NormalizedStoryAnnotations, + ProjectAnnotations, + Renderer, + StoryAnnotations, +} from '@storybook/core/types'; + +import { composeConfigs, normalizeProjectAnnotations } from '@storybook/core/preview-api'; + +export interface Preview { + readonly _tag: 'Preview'; + input: ProjectAnnotations; + composed: NormalizedProjectAnnotations; + + meta(input: ComponentAnnotations): Meta; +} + +export function definePreview( + preview: Preview['input'] +): Preview { + return { + _tag: 'Preview', + input: preview, + get composed() { + const { addons, ...rest } = preview; + return normalizeProjectAnnotations(composeConfigs([...(addons ?? []), rest])); + }, + meta(meta: ComponentAnnotations) { + return defineMeta(meta, this); + }, + }; +} + +export function isPreview(input: unknown): input is Preview { + return input != null && typeof input === 'object' && '_tag' in input && input?._tag === 'Preview'; +} + +export interface Meta { + readonly _tag: 'Meta'; + input: ComponentAnnotations; + composed: NormalizedComponentAnnotations; + preview: Preview; + + story(input: ComponentAnnotations): Story; +} + +export function isMeta(input: unknown): input is Meta { + return input != null && typeof input === 'object' && '_tag' in input && input?._tag === 'Meta'; +} + +function defineMeta( + input: ComponentAnnotations, + preview: Preview +): Meta { + return { + _tag: 'Meta', + input, + preview, + get composed(): never { + throw new Error('Not implemented'); + }, + story(story: StoryAnnotations) { + return defineStory(story, this); + }, + }; +} + +export interface Story { + readonly _tag: 'Story'; + input: StoryAnnotations; + composed: NormalizedStoryAnnotations; + meta: Meta; +} + +function defineStory( + input: ComponentAnnotations, + meta: Meta +): Story { + return { + _tag: 'Story', + input, + meta, + get composed(): never { + throw new Error('Not implemented'); + }, + }; +} + +export function isStory(input: unknown): input is Story { + return input != null && typeof input === 'object' && '_tag' in input && input?._tag === 'Story'; +} diff --git a/code/core/src/preview-api/index.ts b/code/core/src/preview-api/index.ts index 907897730377..12ee8d71fba3 100644 --- a/code/core/src/preview-api/index.ts +++ b/code/core/src/preview-api/index.ts @@ -60,12 +60,7 @@ export { } from './store'; /** CSF API */ -export { - createPlaywrightTest, - getCsfFactoryPreview, - getCsfFactoryAnnotations, - isCsfFactory, -} from './modules/store/csf'; +export { createPlaywrightTest, getCsfFactoryAnnotations } from './modules/store/csf'; export type { PropDescriptor } from './store'; diff --git a/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.ts b/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.ts index c84bf2e16e12..d8d83bed4deb 100644 --- a/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.ts +++ b/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.ts @@ -1,4 +1,5 @@ import type { Channel } from '@storybook/core/channels'; +import { isStory } from '@storybook/core/csf'; import type { CSFFile, ModuleExport, @@ -13,7 +14,7 @@ import type { import { dedent } from 'ts-dedent'; -import { type StoryStore, isCsfFactory } from '../../store'; +import { type StoryStore } from '../../store'; import type { DocsContextProps } from './DocsContextProps'; export class DocsContext implements DocsContextProps { @@ -163,8 +164,7 @@ export class DocsContext implements DocsContextProps } const story = this.exportToStory.get( - // TODO: @kasperpeulen will fix this once csf factory types are defined - isCsfFactory(moduleExportOrType) ? (moduleExportOrType as any).input : moduleExportOrType + isStory(moduleExportOrType) ? moduleExportOrType.input : moduleExportOrType ); if (story) { diff --git a/code/core/src/preview-api/modules/store/csf/csf-factory-utils.ts b/code/core/src/preview-api/modules/store/csf/csf-factory-utils.ts index def96170fca1..741a1842d4a5 100644 --- a/code/core/src/preview-api/modules/store/csf/csf-factory-utils.ts +++ b/code/core/src/preview-api/modules/store/csf/csf-factory-utils.ts @@ -1,27 +1,11 @@ -/* eslint-disable @typescript-eslint/naming-convention */ - -/* eslint-disable no-underscore-dangle */ +import { isStory } from '@storybook/core/csf'; import type { Args, ComponentAnnotations, LegacyStoryAnnotationsOrFn, - ModuleExports, ProjectAnnotations, Renderer, - StoryAnnotations, -} from '@storybook/types'; - -export function getCsfFactoryPreview(preview: ModuleExports): ProjectAnnotations | null { - return Object.values(preview).find(isCsfFactory) ?? null; -} - -export function isCsfFactory(target: StoryAnnotations | ProjectAnnotations) { - return ( - target != null && - typeof target === 'object' && - ('isCSFFactory' in target || 'isCSFFactoryPreview' in target) - ); -} +} from '@storybook/core/types'; export function getCsfFactoryAnnotations< TRenderer extends Renderer = Renderer, @@ -31,12 +15,11 @@ export function getCsfFactoryAnnotations< meta?: ComponentAnnotations, projectAnnotations?: ProjectAnnotations ) { - const _isCsfFactory = isCsfFactory(story); - - return { - // TODO: @kasperpeulen will fix this once csf factory types are defined - story: _isCsfFactory ? (story as any)?.input : story, - meta: _isCsfFactory ? (story as any)?.meta?.input : meta, - preview: _isCsfFactory ? (story as any)?.config?.input : projectAnnotations, - }; + return isStory(story) + ? { + story: story.input, + meta: story.meta.input, + preview: story.meta.preview.composed, + } + : { story, meta, preview: projectAnnotations }; } diff --git a/code/core/src/preview-api/modules/store/csf/processCSFFile.ts b/code/core/src/preview-api/modules/store/csf/processCSFFile.ts index 9242d7cf8d18..9ebb75d6e7d5 100644 --- a/code/core/src/preview-api/modules/store/csf/processCSFFile.ts +++ b/code/core/src/preview-api/modules/store/csf/processCSFFile.ts @@ -1,3 +1,4 @@ +import { isStory } from '@storybook/core/csf'; import type { ComponentTitle, Parameters, Path, Renderer } from '@storybook/core/types'; import type { CSFFile, ModuleExports, NormalizedComponentAnnotations } from '@storybook/core/types'; import { isExportStory } from '@storybook/csf'; @@ -46,10 +47,8 @@ export function processCSFFile( // eslint-disable-next-line @typescript-eslint/naming-convention const { default: defaultExport, __namedExportsOrder, ...namedExports } = moduleExports; - const firstStory: any = Object.values(namedExports)[0]; - // CSF4 - // TODO: @kasperpeulen will fix this once csf factory types are defined - if (!defaultExport && 'isCSFFactory' in firstStory) { + const firstStory = Object.values(namedExports)[0]; + if (isStory(firstStory)) { const meta: NormalizedComponentAnnotations = normalizeComponentAnnotations(firstStory.meta.input, title, importPath); checkDisallowedParameters(meta.parameters); @@ -65,7 +64,7 @@ export function processCSFFile( } }); - csfFile.projectAnnotations = firstStory.config.input; + csfFile.projectAnnotations = firstStory.meta.preview.composed; return csfFile; } diff --git a/code/core/src/types/modules/story.ts b/code/core/src/types/modules/story.ts index a823863defaf..6cb1ef2e874e 100644 --- a/code/core/src/types/modules/story.ts +++ b/code/core/src/types/modules/story.ts @@ -46,6 +46,7 @@ export type RenderToCanvas = ( export interface ProjectAnnotations extends CsfProjectAnnotations { + addons?: ProjectAnnotations[]; testingLibraryRender?: (...args: never[]) => { unmount: () => void }; renderToCanvas?: RenderToCanvas; /* @deprecated use renderToCanvas */ diff --git a/code/frameworks/experimental-nextjs-vite/src/index.ts b/code/frameworks/experimental-nextjs-vite/src/index.ts index 32476387c88c..f620bc6df0a0 100644 --- a/code/frameworks/experimental-nextjs-vite/src/index.ts +++ b/code/frameworks/experimental-nextjs-vite/src/index.ts @@ -1,5 +1,10 @@ +import type { ReactPreview } from '@storybook/react'; +import { definePreview as definePreviewBase } from '@storybook/react'; + import type vitePluginStorybookNextJs from 'vite-plugin-storybook-nextjs'; +import * as nextPreview from './preview'; + export * from './types'; export * from './portable-stories'; @@ -8,3 +13,12 @@ export * from './portable-stories'; declare module '@storybook/experimental-nextjs-vite/vite-plugin' { export const storybookNextJsPlugin: typeof vitePluginStorybookNextJs; } + +export function definePreview(preview: NextPreview['input']) { + return definePreviewBase({ + ...preview, + addons: [nextPreview, ...(preview.addons ?? [])], + }) as NextPreview; +} + +interface NextPreview extends ReactPreview {} diff --git a/code/frameworks/nextjs/package.json b/code/frameworks/nextjs/package.json index ae268eb69bdc..86896ebae743 100644 --- a/code/frameworks/nextjs/package.json +++ b/code/frameworks/nextjs/package.json @@ -87,7 +87,7 @@ "import": "./dist/export-mocks/router/index.mjs", "require": "./dist/export-mocks/router/index.js" }, - "./main": { + "./node": { "types": "./dist/node/index.d.ts", "node": "./dist/node/index.js", "import": "./dist/node/index.mjs", diff --git a/code/frameworks/nextjs/src/index.ts b/code/frameworks/nextjs/src/index.ts index a904f93ec89d..41f13da3975f 100644 --- a/code/frameworks/nextjs/src/index.ts +++ b/code/frameworks/nextjs/src/index.ts @@ -1,2 +1,16 @@ +import type { ReactPreview } from '@storybook/react'; +import { definePreview as definePreviewBase } from '@storybook/react'; + +import * as nextPreview from './preview'; + export * from './types'; export * from './portable-stories'; + +export function definePreview(preview: NextPreview['input']) { + return definePreviewBase({ + ...preview, + addons: [nextPreview, ...(preview.addons ?? [])], + }) as NextPreview; +} + +interface NextPreview extends ReactPreview {} diff --git a/code/frameworks/react-vite/src/index.ts b/code/frameworks/react-vite/src/index.ts index fcb073fefcd6..54688d096160 100644 --- a/code/frameworks/react-vite/src/index.ts +++ b/code/frameworks/react-vite/src/index.ts @@ -1 +1,3 @@ +export { definePreview } from '@storybook/react'; + export * from './types'; diff --git a/code/frameworks/react-webpack5/src/index.ts b/code/frameworks/react-webpack5/src/index.ts index fcb073fefcd6..84081fa8f85d 100644 --- a/code/frameworks/react-webpack5/src/index.ts +++ b/code/frameworks/react-webpack5/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export { definePreview } from '@storybook/react'; diff --git a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts index 85174836901b..6be5462c51ee 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts @@ -156,7 +156,7 @@ describe('preview specific functionality', () => { }; `) ).resolves.toMatchInlineSnapshot(` - import { definePreview } from '@storybook/react'; + import { definePreview } from '@storybook/react-vite'; export default definePreview({ tags: ['test'], @@ -167,7 +167,7 @@ describe('preview specific functionality', () => { it('should remove legacy preview type imports', async () => { await expect( transform(dedent` - import type { Preview } from '@storybook/react' + import type { Preview } from '@storybook/react-vite' const preview: Preview = { tags: [] @@ -175,7 +175,7 @@ describe('preview specific functionality', () => { export default preview; `) ).resolves.toMatchInlineSnapshot(` - import { definePreview } from '@storybook/react'; + import { definePreview } from '@storybook/react-vite'; export default definePreview({ tags: [], diff --git a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts index 4231c3a6fd2a..34d62b3c7f2a 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts @@ -27,13 +27,6 @@ export async function configToCsfFactory( } const methodName = configType === 'main' ? 'defineMain' : 'definePreview'; - // TODO: remove this later, it's just a quick workaround for preview imports - // while it is part of @storybook/react and not @storybook/react-vite - frameworkPackage = - configType === 'preview' && frameworkPackage === '@storybook/react-vite' - ? '@storybook/react' - : frameworkPackage; - const programNode = config._ast.program; const hasNamedExports = Object.keys(config._exportDecls).length > 0; diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index 0324635b1ffe..1b5ff4b4279e 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -75,6 +75,7 @@ export type Template = { disableDocs?: boolean; extraDependencies?: string[]; editAddons?: (addons: string[]) => string[]; + useCsfFactory?: boolean; }; /** * Flag to indicate that this template is a secondary template, which is used mainly to test @@ -106,6 +107,7 @@ const baseTemplates = { skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], modifications: { + useCsfFactory: true, extraDependencies: ['prop-types'], mainConfig: (config) => { const stories = config.getFieldValue>(['stories']); @@ -136,6 +138,7 @@ const baseTemplates = { builder: '@storybook/builder-webpack5', }, modifications: { + useCsfFactory: true, extraDependencies: ['prop-types'], }, }, @@ -149,6 +152,7 @@ const baseTemplates = { builder: '@storybook/builder-webpack5', }, modifications: { + useCsfFactory: true, mainConfig: { features: { experimentalRSC: true, @@ -169,6 +173,7 @@ const baseTemplates = { builder: '@storybook/builder-webpack5', }, modifications: { + useCsfFactory: true, mainConfig: { features: { experimentalRSC: true, @@ -189,6 +194,7 @@ const baseTemplates = { builder: '@storybook/builder-webpack5', }, modifications: { + useCsfFactory: true, mainConfig: { features: { experimentalRSC: true, @@ -209,6 +215,7 @@ const baseTemplates = { builder: '@storybook/builder-webpack5', }, modifications: { + useCsfFactory: true, mainConfig: { features: { experimentalRSC: true, @@ -229,6 +236,7 @@ const baseTemplates = { builder: '@storybook/builder-vite', }, modifications: { + useCsfFactory: true, mainConfig: { framework: '@storybook/experimental-nextjs-vite', features: { @@ -255,6 +263,7 @@ const baseTemplates = { builder: '@storybook/builder-vite', }, modifications: { + useCsfFactory: true, mainConfig: { framework: '@storybook/experimental-nextjs-vite', features: { @@ -280,6 +289,7 @@ const baseTemplates = { builder: '@storybook/builder-vite', }, modifications: { + useCsfFactory: true, extraDependencies: ['prop-types'], mainConfig: { features: { @@ -298,6 +308,7 @@ const baseTemplates = { builder: '@storybook/builder-vite', }, modifications: { + useCsfFactory: true, extraDependencies: ['prop-types'], mainConfig: { features: { @@ -329,6 +340,7 @@ const baseTemplates = { builder: '@storybook/builder-vite', }, modifications: { + useCsfFactory: true, extraDependencies: ['prop-types'], mainConfig: { features: { @@ -347,6 +359,7 @@ const baseTemplates = { builder: '@storybook/builder-webpack5', }, modifications: { + useCsfFactory: true, extraDependencies: ['prop-types'], }, skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], @@ -361,6 +374,7 @@ const baseTemplates = { builder: '@storybook/builder-webpack5', }, modifications: { + useCsfFactory: true, extraDependencies: ['prop-types'], }, skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], @@ -385,6 +399,7 @@ const baseTemplates = { builder: '@storybook/builder-webpack5', }, modifications: { + useCsfFactory: true, extraDependencies: ['prop-types'], }, skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], diff --git a/code/lib/cli/core/csf/index.cjs b/code/lib/cli/core/csf/index.cjs new file mode 100644 index 000000000000..19b144387019 --- /dev/null +++ b/code/lib/cli/core/csf/index.cjs @@ -0,0 +1 @@ +module.exports = require('@storybook/core/csf'); diff --git a/code/lib/cli/core/csf/index.d.ts b/code/lib/cli/core/csf/index.d.ts new file mode 100644 index 000000000000..2cc38939bb85 --- /dev/null +++ b/code/lib/cli/core/csf/index.d.ts @@ -0,0 +1,2 @@ +export * from '@storybook/core/csf'; +export type * from '@storybook/core/csf'; diff --git a/code/lib/cli/core/csf/index.js b/code/lib/cli/core/csf/index.js new file mode 100644 index 000000000000..ed1380489e93 --- /dev/null +++ b/code/lib/cli/core/csf/index.js @@ -0,0 +1 @@ +export * from '@storybook/core/csf'; diff --git a/code/lib/cli/package.json b/code/lib/cli/package.json index 570728be56c7..c868a549ba8f 100644 --- a/code/lib/cli/package.json +++ b/code/lib/cli/package.json @@ -103,6 +103,11 @@ "import": "./core/types/index.js", "require": "./core/types/index.cjs" }, + "./internal/csf": { + "types": "./core/csf/index.d.ts", + "import": "./core/csf/index.js", + "require": "./core/csf/index.cjs" + }, "./internal/csf-tools": { "types": "./core/csf-tools/index.d.ts", "import": "./core/csf-tools/index.js", @@ -233,6 +238,9 @@ "internal/core-server": [ "./core/core-server/index.d.ts" ], + "internal/csf": [ + "./core/csf/index.d.ts" + ], "internal/csf-tools": [ "./core/csf-tools/index.d.ts" ], diff --git a/code/renderers/react/src/__test__/Button.csf4.stories.tsx b/code/renderers/react/src/__test__/Button.csf4.stories.tsx index 3ea470023cfe..6a92532ab691 100644 --- a/code/renderers/react/src/__test__/Button.csf4.stories.tsx +++ b/code/renderers/react/src/__test__/Button.csf4.stories.tsx @@ -88,7 +88,7 @@ export const CSF3Button = meta.story({ }); export const CSF3ButtonWithRender = meta.story({ - ...CSF3Button, + ...CSF3Button.input, render: (args) => (

I am a custom render function

diff --git a/code/renderers/react/src/__test__/__snapshots__/portable-stories-factory.test.tsx.snap b/code/renderers/react/src/__test__/__snapshots__/portable-stories-factory.test.tsx.snap index 4e8d3a043beb..3f00ff746281 100644 --- a/code/renderers/react/src/__test__/__snapshots__/portable-stories-factory.test.tsx.snap +++ b/code/renderers/react/src/__test__/__snapshots__/portable-stories-factory.test.tsx.snap @@ -52,7 +52,7 @@ exports[`Renders CSF3ButtonWithRender story 1`] = ` class="storybook-button storybook-button--medium storybook-button--secondary" type="button" > - Children coming from meta args + foo
diff --git a/code/renderers/react/src/csf-factories.test.tsx b/code/renderers/react/src/csf-factories.test.tsx index 3b1928051eb4..ccaf6d405421 100644 --- a/code/renderers/react/src/csf-factories.test.tsx +++ b/code/renderers/react/src/csf-factories.test.tsx @@ -1,7 +1,26 @@ +// @vitest-environment happy-dom +// this file tests Typescript types that's why there are no assertions +import { describe, it } from 'vitest'; import { expect, test } from 'vitest'; -import { Button } from './__test__/Button'; +import type { KeyboardEventHandler, ReactElement, ReactNode } from 'react'; +import React from 'react'; + +import type { Args, StrictArgs } from 'storybook/internal/types'; + +import type { Canvas } from '@storybook/csf'; +import type { Mock } from '@storybook/test'; +import { fn } from '@storybook/test'; + +import { expectTypeOf } from 'expect-type'; + import { definePreview } from './preview'; +import type { Decorator } from './public-types'; + +type ButtonProps = { label: string; disabled: boolean }; +const Button: (props: ButtonProps) => ReactElement = () => <>; + +const preview = definePreview({}); test('csf factories', () => { const config = definePreview({ @@ -12,13 +31,224 @@ test('csf factories', () => { ], }); - const meta = config.meta({ component: Button, args: { primary: true } }); + const meta = config.meta({ component: Button, args: { disabled: true } }); const MyStory = meta.story({ args: { - children: 'Hello world', + label: 'Hello world', + }, + }); + + expect(MyStory.input.args?.label).toBe('Hello world'); +}); + +describe('Args can be provided in multiple ways', () => { + it('✅ All required args may be provided in meta', () => { + const meta = preview.meta({ + component: Button, + args: { label: 'good', disabled: false }, + }); + + const Basic = meta.story({}); + }); + + it('✅ Required args may be provided partial in meta and the story', () => { + const meta = preview.meta({ + component: Button, + args: { label: 'good' }, + }); + const Basic = meta.story({ + args: { disabled: false }, + }); + }); + + it('❌ The combined shape of meta args and story args must match the required args.', () => { + { + const meta = preview.meta({ component: Button }); + const Basic = meta.story({ + // @ts-expect-error disabled not provided ❌ + args: { label: 'good' }, + }); + } + { + const meta = preview.meta({ + component: Button, + args: { label: 'good' }, + }); + // @ts-expect-error disabled not provided ❌ + const Basic = meta.story({}); + } + { + const meta = preview.meta({ component: Button }); + const Basic = meta.story({ + // @ts-expect-error disabled not provided ❌ + args: { label: 'good' }, + }); + } + }); +}); + +it('✅ Void functions are not changed', () => { + interface CmpProps { + label: string; + disabled: boolean; + onClick(): void; + onKeyDown: KeyboardEventHandler; + onLoading: (s: string) => ReactElement; + submitAction(): void; + } + + const Cmp: (props: CmpProps) => ReactElement = () => <>; + + const meta = preview.meta({ + component: Cmp, + args: { label: 'good' }, + }); + + const Basic = meta.story({ + args: { + disabled: false, + onLoading: () =>
Loading...
, + onKeyDown: fn(), + onClick: fn(), + submitAction: fn(), }, }); +}); + +type ThemeData = 'light' | 'dark'; +declare const Theme: (props: { theme: ThemeData; children?: ReactNode }) => ReactElement; + +describe('Story args can be inferred', () => { + it('Correct args are inferred when type is widened for render function', () => { + const meta = preview.meta({ + component: Button, + args: { disabled: false }, + render: (args: ButtonProps & { theme: ThemeData }, { component }) => { + // component is not null as it is provided in meta - expect(MyStory.input.args?.children).toBe('Hello world'); + const Component = component!; + return ( + + + + ); + }, + }); + + const Basic = meta.story({ args: { theme: 'light', label: 'good' } }); + }); + + const withDecorator: Decorator<{ decoratorArg: number }> = (Story, { args }) => ( + <> + Decorator: {args.decoratorArg} + + + ); + + it('Correct args are inferred when type is widened for decorators', () => { + const meta = preview.meta({ + component: Button, + args: { disabled: false }, + decorators: [withDecorator], + }); + + const Basic = meta.story({ args: { decoratorArg: 0, label: 'good' } }); + }); + + it('Correct args are inferred when type is widened for multiple decorators', () => { + type Props = ButtonProps & { decoratorArg: number; decoratorArg2: string }; + + const secondDecorator: Decorator<{ decoratorArg2: string }> = (Story, { args }) => ( + <> + Decorator: {args.decoratorArg2} + + + ); + + // decorator is not using args + const thirdDecorator: Decorator = (Story) => ( + <> + + + ); + + // decorator is not using args + const fourthDecorator: Decorator = (Story) => ( + <> + + + ); + + const meta = preview.meta({ + component: Button, + args: { disabled: false }, + decorators: [withDecorator, secondDecorator, thirdDecorator, fourthDecorator], + }); + + const Basic = meta.story({ + args: { decoratorArg: 0, decoratorArg2: '', label: 'good' }, + }); + }); +}); + +it('Components without Props can be used, issue #21768', () => { + const Component = () => <>Foo; + const withDecorator: Decorator = (Story) => ( + <> + + + ); + + const meta = preview.meta({ + component: Component, + decorators: [withDecorator], + }); + + const Basic = meta.story({}); +}); + +it('Meta is broken when using discriminating types, issue #23629', () => { + type TestButtonProps = { + text: string; + } & ( + | { + id?: string; + onClick?: (e: unknown, id: string | undefined) => void; + } + | { + id: string; + onClick: (e: unknown, id: string) => void; + } + ); + const TestButton: React.FC = ({ text }) => { + return

{text}

; + }; + + preview.meta({ + title: 'Components/Button', + component: TestButton, + args: { + text: 'Button', + }, + }); +}); + +it('Infer mock function given to args in meta.', () => { + type Props = { label: string; onClick: () => void; onRender: () => JSX.Element }; + const TestButton = (props: Props) => <>; + + const meta = preview.meta({ + component: TestButton, + args: { label: 'label', onClick: fn(), onRender: () => <>some jsx }, + }); + + const Basic = meta.story({ + play: async ({ args, mount }) => { + const canvas = await mount(); + expectTypeOf(canvas).toEqualTypeOf(); + expectTypeOf(args.onClick).toEqualTypeOf(); + expectTypeOf(args.onRender).toEqualTypeOf<() => JSX.Element>(); + }, + }); }); diff --git a/code/renderers/react/src/index.ts b/code/renderers/react/src/index.ts index 6f3f803c6d1b..0734155b57e2 100644 --- a/code/renderers/react/src/index.ts +++ b/code/renderers/react/src/index.ts @@ -5,7 +5,7 @@ export * from './public-types'; export * from './portable-stories'; -export { definePreview } from './preview'; +export * from './preview'; export type { ReactParameters } from './types'; diff --git a/code/renderers/react/src/preview.tsx b/code/renderers/react/src/preview.tsx index 40fdb3307316..498521c241f0 100644 --- a/code/renderers/react/src/preview.tsx +++ b/code/renderers/react/src/preview.tsx @@ -1,74 +1,72 @@ -import type { ComponentProps, ComponentType } from 'react'; +import type { ComponentType } from 'react'; -import { composeConfigs } from 'storybook/internal/preview-api'; -import { normalizeProjectAnnotations } from 'storybook/internal/preview-api'; +import { definePreview as definePreviewBase } from 'storybook/internal/csf'; +import type { Meta, Preview, Story } from 'storybook/internal/csf'; import type { Args, + ArgsStoryFn, ComponentAnnotations, - NormalizedProjectAnnotations, - ProjectAnnotations, + DecoratorFunction, Renderer, StoryAnnotations, } from 'storybook/internal/types'; -import type { SetOptional } from 'type-fest'; +import type { AddMocks } from 'src/public-types'; +import type { RemoveIndexSignature, SetOptional, Simplify, UnionToIntersection } from 'type-fest'; import * as reactAnnotations from './entry-preview'; import * as reactDocsAnnotations from './entry-preview-docs'; import type { ReactRenderer } from './types'; -export function definePreview(config: PreviewConfigData) { - return new PreviewConfig({ - ...config, - addons: [reactAnnotations, reactDocsAnnotations, ...(config.addons ?? [])], - }); +export function definePreview(preview: ReactPreview['input']) { + return definePreviewBase({ + ...preview, + addons: [reactAnnotations, reactDocsAnnotations, ...(preview.addons ?? [])], + }) as ReactPreview; } -interface PreviewConfigData extends ProjectAnnotations { - addons?: ProjectAnnotations[]; -} - -class PreviewConfig { - readonly input: NormalizedProjectAnnotations; - - constructor(data: PreviewConfigData) { - const { addons, ...rest } = data; - this.input = normalizeProjectAnnotations(composeConfigs([...(addons ?? []), rest])); - } - - readonly meta = < - TComponent extends ComponentType, - TMetaArgs extends Partial>, +export interface ReactPreview extends Preview { + meta< + TArgs extends Args, + Decorators extends DecoratorFunction, + // Try to make Exact, TMetaArgs> work + TMetaArgs extends Partial, >( - meta: ComponentAnnotations & { component: TComponent; args: TMetaArgs } - ) => { - return new Meta, TMetaArgs>(meta, this); - }; - - readonly isCSFFactoryPreview = true; + meta: { + render?: ArgsStoryFn; + component?: ComponentType; + decorators?: Decorators | Decorators[]; + args?: TMetaArgs; + } & Omit, 'decorators'> + ): ReactMeta< + { + args: Simplify< + TArgs & Simplify>> + >; + }, + { args: Partial extends TMetaArgs ? {} : TMetaArgs } + >; } -class Meta { - readonly input: ComponentAnnotations; - - readonly config: PreviewConfig; - - constructor(annotations: ComponentAnnotations, config: PreviewConfig) { - this.input = annotations; - this.config = config; - } - - readonly story = ( - story: StoryAnnotations> - ) => new Story(story as any, this, this.config); +type DecoratorsArgs = UnionToIntersection< + Decorators extends DecoratorFunction ? TArgs : unknown +>; +interface ReactMeta< + Context extends { args: Args }, + MetaInput extends ComponentAnnotations, +> extends Meta { + story< + const TInput extends Simplify< + StoryAnnotations< + ReactRenderer, + // TODO: infer mocks from story itself as well + AddMocks, + SetOptional + > + >, + >( + story: TInput + ): ReactStory; } -class Story { - constructor( - public input: StoryAnnotations, - public meta: Meta, - public config: PreviewConfig - ) {} - - readonly isCSFFactory = true; -} +interface ReactStory extends Story {} diff --git a/code/renderers/react/src/public-types.ts b/code/renderers/react/src/public-types.ts index 8a2f7003ec94..2f2b2d1de816 100644 --- a/code/renderers/react/src/public-types.ts +++ b/code/renderers/react/src/public-types.ts @@ -66,7 +66,7 @@ export type StoryObj = [TMetaOrCmpOrArgs] extends [ : StoryAnnotations; // This performs a downcast to function types that are mocks, when a mock fn is given to meta args. -type AddMocks = Simplify<{ +export type AddMocks = Simplify<{ [T in keyof TArgs]: T extends keyof DefaultArgs ? // eslint-disable-next-line @typescript-eslint/ban-types DefaultArgs[T] extends (...args: any) => any & { mock: {} } // allow any function with a mock object diff --git a/code/frameworks/react-vite/template/stories/csf4.mdx b/code/renderers/react/template/stories/csf4.mdx similarity index 100% rename from code/frameworks/react-vite/template/stories/csf4.mdx rename to code/renderers/react/template/stories/csf4.mdx diff --git a/code/frameworks/react-vite/template/stories/csf4.stories.tsx b/code/renderers/react/template/stories/csf4.stories.tsx similarity index 100% rename from code/frameworks/react-vite/template/stories/csf4.stories.tsx rename to code/renderers/react/template/stories/csf4.stories.tsx diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index 563ca02ee478..07db3deff32c 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -429,7 +429,7 @@ export async function setupVitest(details: TemplateDetails, options: PassedOptio import projectAnnotations from './preview' // setProjectAnnotations still kept to support non-CSF4 story tests - const annotations = setProjectAnnotations(projectAnnotations.input) + const annotations = setProjectAnnotations(projectAnnotations.composed) beforeAll(annotations.beforeAll) ` ); @@ -815,10 +815,7 @@ export const extendPreview: Task['run'] = async ({ template, sandboxDir }) => { logger.log('📝 Extending preview.js'); const previewConfig = await readConfig({ cwd: sandboxDir, fileName: 'preview' }); - if ( - template.expected.framework === '@storybook/react-vite' && - !template.skipTasks.includes('vitest-integration') - ) { + if (template.modifications?.useCsfFactory) { previewConfig.setImport(null, '../src/stories/components'); previewConfig.setImport({ namespace: 'coreAnnotations' }, '../template-stories/core/preview'); previewConfig.setImport( @@ -837,10 +834,7 @@ export const extendPreview: Task['run'] = async ({ template, sandboxDir }) => { }; export const runMigrations: Task['run'] = async ({ sandboxDir, template }, { dryRun, debug }) => { - if ( - template.expected.framework === '@storybook/react-vite' && - !template.skipTasks.includes('vitest-integration') - ) { + if (template.modifications?.useCsfFactory) { await executeCLIStep(steps.automigrate, { cwd: sandboxDir, argument: 'csf-factories',