diff --git a/MIGRATION.md b/MIGRATION.md index 9db98fc45d94..c655a4a38ac8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -18,6 +18,7 @@ - [Docs modern inline rendering by default](#docs-modern-inline-rendering-by-default) - [Babel mode v7 by default](#babel-mode-v7-by-default) - [7.0 feature flags removed](#70-feature-flags-removed) + - [Vite builder uses vite config automatically](#vite-builder-uses-vite-config-automatically) - [Removed docs.getContainer and getPage parameters](#removed-docsgetcontainer-and-getpage-parameters) - [Icons API changed](#icons-api-changed) - [Docs Changes](#docs-changes) @@ -430,10 +431,13 @@ In 7.0, frameworks also specify the builder to be used. For example, The current - `@storybook/html-webpack5` - `@storybook/preact-webpack5` - `@storybook/react-webpack5` +- `@storybook/react-vite` - `@storybook/server-webpack5` - `@storybook/svelte-webpack5` +- `@storybook/svelte-vite` - `@storybook/vue-webpack5` - `@storybook/vue3-webpack5` +- `@storybook/vue3-vite` - `@storybook/web-components-webpack5` We will be expanding this list over the course of the 7.0 development cycle. More info on the rationale here: [Frameworks RFC](https://www.notion.so/chromatic-ui/Frameworks-RFC-89f8aafe3f0941ceb4c24683859ed65c). @@ -514,6 +518,12 @@ In 7.0 we've removed the following feature flags: | `emotionAlias` | This flag is no longer needed and should be deleted. | | `breakingChangesV7` | This flag is no longer needed and should be deleted. | +#### Vite builder uses vite config automatically + +When using a [Vite-based framework](#framework-field-mandatory), Storybook will automatically use your `vite.config.(ctm)js` config file starting in 7.0. +Some settings will be overridden by storybook so that it can function properly, and the merged settings can be modified using `viteFinal` in `.storybook/main.js` (see the [Storybook Vite configuration docs](https://storybook.js.org/docs/react/builders/vite#configuration)). +If you were using `viteFinal` in 6.5 to simply merge in your project's standard vite config, you can now remove it. + #### Removed docs.getContainer and getPage parameters It is no longer possible to set `parameters.docs.getContainer()` and `getPage()`. Instead use `parameters.docs.container` or `parameters.docs.page` directly. diff --git a/code/frameworks/svelte-vite/src/preset.ts b/code/frameworks/svelte-vite/src/preset.ts index f71998d1f3c5..8006514d2fde 100644 --- a/code/frameworks/svelte-vite/src/preset.ts +++ b/code/frameworks/svelte-vite/src/preset.ts @@ -1,6 +1,7 @@ import path from 'path'; import fs from 'fs'; import type { StorybookConfig } from '@storybook/builder-vite'; +import { svelteDocgen } from './plugins/svelte-docgen'; export const addons: StorybookConfig['addons'] = ['@storybook/svelte']; @@ -20,70 +21,7 @@ export function readPackageJson(): Record | false { export const viteFinal: StorybookConfig['viteFinal'] = async (config, { presets }) => { const { plugins = [] } = config; - const svelteOptions = await presets.apply>('frameworkOptions'); - try { - // eslint-disable-next-line global-require - const sveltePlugin = require('@sveltejs/vite-plugin-svelte').svelte; - // We need to create two separate svelte plugins, one for stories, and one for other svelte files - // because stories.svelte files cannot be hot-module-reloaded. - // Suggested in: https://github.com/sveltejs/vite-plugin-svelte/issues/321#issuecomment-1113205509 - - // First, create an array containing user exclude patterns, to combine with ours. - - let userExclude = []; - if (Array.isArray(svelteOptions?.exclude)) { - userExclude = svelteOptions?.exclude; - } else if (svelteOptions?.exclude) { - userExclude = [svelteOptions?.exclude]; - } - - // These are the svelte stories we need to exclude from HMR - const storyPatterns = ['**/*.story.svelte', '**/*.stories.svelte']; - // Non-story svelte files - // Starting in 1.0.0-next.42, svelte.config.js is included by default. - // We disable that, but allow it to be overridden in svelteOptions - plugins.push(sveltePlugin({ ...svelteOptions, exclude: [...userExclude, ...storyPatterns] })); - // Svelte stories without HMR - const storySveltePlugin = sveltePlugin({ - ...svelteOptions, - exclude: userExclude, - include: storyPatterns, - hot: false, - }); - plugins.push({ - // Starting in 1.0.0-next.43, the plugin function returns an array of plugins. We only want the first one here. - ...(Array.isArray(storySveltePlugin) ? storySveltePlugin[0] : storySveltePlugin), - name: 'vite-plugin-svelte-stories', - }); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND') { - throw new Error( - '@storybook/builder-vite requires @sveltejs/vite-plugin-svelte to be installed' + - ' when using @storybook/svelte.' + - ' Please install it and start storybook again.' - ); - } - throw err; - } - - // eslint-disable-next-line global-require - const { loadSvelteConfig } = require('@sveltejs/vite-plugin-svelte'); - const csfConfig = { ...loadSvelteConfig(), ...svelteOptions }; - - try { - // eslint-disable-next-line global-require - const csfPlugin = require('./plugins/csf-plugin').default; - plugins.push(csfPlugin(csfConfig)); - } catch (err) { - // Not all projects use `.stories.svelte` for stories, and by default 6.5+ does not auto-install @storybook/addon-svelte-csf. - // If it's any other kind of error, re-throw. - if ((err as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND') { - throw err; - } - } - - const { svelteDocgen } = await import('./plugins/svelte-docgen'); plugins.push(svelteDocgen(config)); return { diff --git a/code/frameworks/vue3-vite/src/preset.ts b/code/frameworks/vue3-vite/src/preset.ts index 61f7d277e642..3f35bed8b1ce 100644 --- a/code/frameworks/vue3-vite/src/preset.ts +++ b/code/frameworks/vue3-vite/src/preset.ts @@ -1,6 +1,7 @@ import path from 'path'; import fs from 'fs'; import type { StorybookConfig } from '@storybook/builder-vite'; +import { vueDocgen } from './plugins/vue-docgen'; export const addons: StorybookConfig['addons'] = ['@storybook/vue3']; @@ -21,22 +22,7 @@ export function readPackageJson(): Record | false { export const viteFinal: StorybookConfig['viteFinal'] = async (config, { presets }) => { const { plugins = [] } = config; - try { - // eslint-disable-next-line global-require - const vuePlugin = require('@vitejs/plugin-vue'); - plugins.push(vuePlugin()); - const { vueDocgen } = await import('./plugins/vue-docgen'); - plugins.push(vueDocgen()); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND') { - throw new Error( - '@storybook/builder-vite requires @vitejs/plugin-vue to be installed ' + - 'when using @storybook/vue or @storybook/vue3.' + - ' Please install it and start storybook again.' - ); - } - throw err; - } + plugins.push(vueDocgen()); const updated = { ...config, diff --git a/code/lib/api/src/lib/stories.ts b/code/lib/api/src/lib/stories.ts index 9be760fe3d8f..2019bc944ab1 100644 --- a/code/lib/api/src/lib/stories.ts +++ b/code/lib/api/src/lib/stories.ts @@ -519,6 +519,21 @@ export const transformStoryIndexToStoriesHash = ( .reduce(addItem, orphanHash); }; +export const addPreparedStories = (newHash: StoriesHash, oldHash?: StoriesHash) => { + if (!oldHash) return newHash; + + return Object.fromEntries( + Object.entries(newHash).map(([id, newEntry]) => { + const oldEntry = oldHash[id]; + if (newEntry.type === 'story' && oldEntry?.type === 'story' && oldEntry.prepared) { + return [id, { ...oldEntry, ...newEntry, prepared: true }]; + } + + return [id, newEntry]; + }) + ); +}; + export const getComponentLookupList = memoize(1)((hash: StoriesHash) => { return Object.entries(hash).reduce((acc, i) => { const value = i[1]; diff --git a/code/lib/api/src/modules/stories.ts b/code/lib/api/src/modules/stories.ts index ef1d5aa68641..3f6446e7a7e1 100644 --- a/code/lib/api/src/modules/stories.ts +++ b/code/lib/api/src/modules/stories.ts @@ -25,6 +25,7 @@ import { getStoriesLookupList, HashEntry, LeafEntry, + addPreparedStories, } from '../lib/stories'; import type { @@ -351,13 +352,16 @@ export const init: ModuleFn = ({ } }, setStoryList: async (storyIndex: StoryIndex) => { - const hash = transformStoryIndexToStoriesHash(storyIndex, { + const newHash = transformStoryIndexToStoriesHash(storyIndex, { provider, docsOptions, }); + // Now we need to patch in the existing prepared stories + const oldHash = store.getState().storiesHash; + await store.setState({ - storiesHash: hash, + storiesHash: addPreparedStories(newHash, oldHash), storiesConfigured: true, storiesFailed: null, }); diff --git a/code/lib/api/src/tests/stories.test.ts b/code/lib/api/src/tests/stories.test.ts index c075f9f384cc..a686680e2b3d 100644 --- a/code/lib/api/src/tests/stories.test.ts +++ b/code/lib/api/src/tests/stories.test.ts @@ -1,4 +1,6 @@ /// ; +// Need to import jest as mockJest for annoying jest reasons. Is there a better way? +import { jest, jest as mockJest, it, describe, expect, beforeEach } from '@jest/globals'; import { STORY_ARGS_UPDATED, @@ -21,17 +23,17 @@ import { StoryEntry, SetStoriesStoryData, SetStoriesStory, StoryIndex } from '.. import type Store from '../store'; import { ModuleArgs } from '..'; -const mockStories: jest.MockedFunction<() => StoryIndex['entries']> = jest.fn(); +const mockStories = jest.fn(); jest.mock('../lib/events'); jest.mock('global', () => ({ - ...(jest.requireActual('global') as Record), - fetch: jest.fn(() => ({ json: () => ({ v: 4, entries: mockStories() }) })), + ...(mockJest.requireActual('global') as Record), + fetch: mockJest.fn(() => ({ json: () => ({ v: 4, entries: mockStories() }) })), FEATURES: { storyStoreV7: true }, CONFIG_TYPE: 'DEVELOPMENT', })); -const getEventMetadataMock = getEventMetadata as jest.MockedFunction; +const getEventMetadataMock = getEventMetadata as ReturnType; const mockIndex = { 'component-a--story-1': { @@ -58,7 +60,7 @@ function createMockStore(initialState = {}) { let state = initialState; return { getState: jest.fn(() => state), - setState: jest.fn((s) => { + setState: jest.fn((s: typeof state) => { state = { ...state, ...s }; return Promise.resolve(state); }), @@ -1195,6 +1197,47 @@ describe('stories API', () => { expect(Object.keys(storedStoriesHash)).toEqual(['component-a', 'component-a--story-1']); }); + it('retains prepared-ness of stories', async () => { + const navigate = jest.fn(); + const store = createMockStore(); + const fullAPI = Object.assign(new EventEmitter(), { + setStories: jest.fn(), + setOptions: jest.fn(), + }); + + const { api, init } = initStories({ store, navigate, provider, fullAPI } as any); + Object.assign(fullAPI, api); + + global.fetch.mockClear(); + await init(); + expect(global.fetch).toHaveBeenCalledTimes(1); + + fullAPI.emit(STORY_PREPARED, { + id: 'component-a--story-1', + parameters: { a: 'b' }, + args: { c: 'd' }, + }); + // Let the promise/await chain resolve + await new Promise((r) => setTimeout(r, 0)); + expect(store.getState().storiesHash['component-a--story-1'] as StoryEntry).toMatchObject({ + prepared: true, + parameters: { a: 'b' }, + args: { c: 'd' }, + }); + + global.fetch.mockClear(); + provider.serverChannel.emit(STORY_INDEX_INVALIDATED); + expect(global.fetch).toHaveBeenCalledTimes(1); + + // Let the promise/await chain resolve + await new Promise((r) => setTimeout(r, 0)); + expect(store.getState().storiesHash['component-a--story-1'] as StoryEntry).toMatchObject({ + prepared: true, + parameters: { a: 'b' }, + args: { c: 'd' }, + }); + }); + it('handles docs entries', async () => { mockStories.mockReset().mockReturnValue({ 'component-a--page': { diff --git a/code/lib/builder-vite/src/build.ts b/code/lib/builder-vite/src/build.ts index d0a36401fc7f..5333c56f7b2c 100644 --- a/code/lib/builder-vite/src/build.ts +++ b/code/lib/builder-vite/src/build.ts @@ -1,32 +1,19 @@ import { build as viteBuild } from 'vite'; -import { stringifyProcessEnvs } from './envs'; import { commonConfig } from './vite-config'; -import type { EnvsRaw, ExtendedOptions } from './types'; +import type { ExtendedOptions } from './types'; export async function build(options: ExtendedOptions) { const { presets } = options; - const baseConfig = await commonConfig(options, 'build'); - const config = { - ...baseConfig, - build: { - outDir: options.outputDir, - emptyOutDir: false, // do not clean before running Vite build - Storybook has already added assets in there! - sourcemap: true, - }, + const config = await commonConfig(options, 'build'); + config.build = { + outDir: options.outputDir, + emptyOutDir: false, // do not clean before running Vite build - Storybook has already added assets in there! + sourcemap: true, }; const finalConfig = await presets.apply('viteFinal', config, options); - const envsRaw = await presets.apply>('env'); - // Stringify env variables after getting `envPrefix` from the final config - const envs = stringifyProcessEnvs(envsRaw, finalConfig.envPrefix); - // Update `define` - finalConfig.define = { - ...finalConfig.define, - ...envs, - }; - await viteBuild(finalConfig); } diff --git a/code/lib/builder-vite/src/envs.ts b/code/lib/builder-vite/src/envs.ts index 107391642627..7ebead6aab45 100644 --- a/code/lib/builder-vite/src/envs.ts +++ b/code/lib/builder-vite/src/envs.ts @@ -15,9 +15,6 @@ const allowedEnvVariables = [ 'SSR', ]; -// Env variables starts with env prefix will be exposed to your client source code via `import.meta.env` -export const allowedEnvPrefix = ['VITE_', 'STORYBOOK_']; - /** * Customized version of stringifyProcessEnvs from @storybook/core-common which * uses import.meta.env instead of process.env and checks for allowed variables. diff --git a/code/lib/builder-vite/src/optimizeDeps.ts b/code/lib/builder-vite/src/optimizeDeps.ts index fbe560720ed4..09df4ba81c8c 100644 --- a/code/lib/builder-vite/src/optimizeDeps.ts +++ b/code/lib/builder-vite/src/optimizeDeps.ts @@ -1,5 +1,6 @@ import * as path from 'path'; -import { normalizePath, resolveConfig, UserConfig } from 'vite'; +import { normalizePath, resolveConfig } from 'vite'; +import type { InlineConfig as ViteInlineConfig } from 'vite'; import { listStories } from './list-stories'; import type { ExtendedOptions } from './types'; @@ -101,13 +102,11 @@ const INCLUDE_CANDIDATES = [ const asyncFilter = async (arr: string[], predicate: (val: string) => Promise) => Promise.all(arr.map(predicate)).then((results) => arr.filter((_v, index) => results[index])); -export async function getOptimizeDeps( - config: UserConfig & { configFile: false; root: string }, - options: ExtendedOptions -) { - const { root } = config; +export async function getOptimizeDeps(config: ViteInlineConfig, options: ExtendedOptions) { + const { root = process.cwd() } = config; const absoluteStories = await listStories(options); const stories = absoluteStories.map((storyPath) => normalizePath(path.relative(root, storyPath))); + // TODO: check if resolveConfig takes a lot of time, possible optimizations here const resolvedConfig = await resolveConfig(config, 'serve', 'development'); // This function converts ids which might include ` > ` to a real path, if it exists on disk. diff --git a/code/lib/builder-vite/src/plugins/strip-story-hmr-boundaries.ts b/code/lib/builder-vite/src/plugins/strip-story-hmr-boundaries.ts new file mode 100644 index 000000000000..b0e53b484d34 --- /dev/null +++ b/code/lib/builder-vite/src/plugins/strip-story-hmr-boundaries.ts @@ -0,0 +1,27 @@ +import type { Plugin } from 'vite'; +import { createFilter } from 'vite'; +import MagicString from 'magic-string'; + +/** + * This plugin removes HMR `accept` calls in story files. Stories should not be treated + * as hmr boundaries, but vite has a bug which causes them to be treated as boundaries + * (https://github.com/vitejs/vite/issues/9869). + */ +export function stripStoryHMRBoundary(): Plugin { + const filter = createFilter(/\.stories\.([tj])sx?$/); + return { + name: 'storybook:strip-hmr-boundary', + enforce: 'post', + async transform(src: string, id: string) { + if (!filter(id)) return undefined; + + const s = new MagicString(src); + s.replace(/import\.meta\.hot\.accept\(\);/, ''); + + return { + code: s.toString(), + map: s.generateMap({ hires: true, source: id }), + }; + }, + }; +} diff --git a/code/lib/builder-vite/src/vite-config.ts b/code/lib/builder-vite/src/vite-config.ts index 66b5fc50343a..3887d7e04271 100644 --- a/code/lib/builder-vite/src/vite-config.ts +++ b/code/lib/builder-vite/src/vite-config.ts @@ -1,15 +1,21 @@ import * as path from 'path'; import fs from 'fs'; -import { Plugin } from 'vite'; +import { loadConfigFromFile, mergeConfig } from 'vite'; +import type { + ConfigEnv, + InlineConfig as ViteInlineConfig, + PluginOption, + UserConfig as ViteConfig, +} from 'vite'; import viteReact from '@vitejs/plugin-react'; -import type { UserConfig } from 'vite'; import { isPreservingSymlinks, getFrameworkName } from '@storybook/core-common'; -import { allowedEnvPrefix as envPrefix } from './envs'; import { codeGeneratorPlugin } from './code-generator-plugin'; +import { stringifyProcessEnvs } from './envs'; import { injectExportOrderPlugin } from './inject-export-order-plugin'; import { mdxPlugin } from './plugins/mdx-plugin'; import { noFouc } from './plugins/no-fouc'; -import type { ExtendedOptions } from './types'; +import { stripStoryHMRBoundary } from './plugins/strip-story-hmr-boundaries'; +import type { ExtendedOptions, EnvsRaw } from './types'; export type PluginConfigType = 'build' | 'development'; @@ -23,23 +29,56 @@ export function readPackageJson(): Record | false { return JSON.parse(jsonContent); } +const configEnvServe: ConfigEnv = { + mode: 'development', + command: 'serve', + ssrBuild: false, +}; + +const configEnvBuild: ConfigEnv = { + mode: 'production', + command: 'build', + ssrBuild: false, +}; + // Vite config that is common to development and production mode export async function commonConfig( options: ExtendedOptions, _type: PluginConfigType -): Promise { - return { +): Promise { + const { presets } = options; + const configEnv = _type === 'development' ? configEnvServe : configEnvBuild; + + const { config: userConfig = {} } = (await loadConfigFromFile(configEnv)) ?? {}; + + const sbConfig = { configFile: false, - root: path.resolve(options.configDir, '..'), cacheDir: 'node_modules/.vite-storybook', - envPrefix, - define: {}, + root: path.resolve(options.configDir, '..'), + plugins: await pluginConfig(options), resolve: { preserveSymlinks: isPreservingSymlinks() }, - plugins: await pluginConfig(options, _type), + // If an envPrefix is specified in the vite config, add STORYBOOK_ to it, + // otherwise, add VITE_ and STORYBOOK_ so that vite doesn't lose its default. + envPrefix: userConfig.envPrefix ? 'STORYBOOK_' : ['VITE_', 'STORYBOOK_'], }; + + const config: ViteConfig = mergeConfig(userConfig, sbConfig); + + // Sanitize environment variables if needed + const envsRaw = await presets.apply>('env'); + if (Object.keys(envsRaw).length) { + // Stringify env variables after getting `envPrefix` from the config + const envs = stringifyProcessEnvs(envsRaw, config.envPrefix); + config.define = { + ...config.define, + ...envs, + }; + } + + return config; } -export async function pluginConfig(options: ExtendedOptions, _type: PluginConfigType) { +export async function pluginConfig(options: ExtendedOptions) { const frameworkName = await getFrameworkName(options); const plugins = [ @@ -48,27 +87,13 @@ export async function pluginConfig(options: ExtendedOptions, _type: PluginConfig mdxPlugin(options), noFouc(), injectExportOrderPlugin, - // We need the react plugin here to support MDX. - viteReact({ - // Do not treat story files as HMR boundaries, storybook itself needs to handle them. - exclude: [/\.stories\.([tj])sx?$/, /node_modules/].concat( - frameworkName === '@storybook/react-vite' ? [] : [/\.([tj])sx?$/] - ), - }), - { - name: 'vite-plugin-storybook-allow', - enforce: 'post', - config(config) { - // if there is no allow list then Vite allows anything in the root directory - // if there is an allow list then Vite allows anything in the listed directories - // add the .storybook directory only if there's an allow list so that we don't end up - // disallowing the root directory unless it's already disallowed - if (config?.server?.fs?.allow) { - config.server.fs.allow.push('.storybook'); - } - }, - }, - ] as Plugin[]; + stripStoryHMRBoundary(), + ] as PluginOption[]; + + // We need the react plugin here to support MDX in non-react projects. + if (frameworkName !== '@storybook/react-vite') { + plugins.push(viteReact({ exclude: [/\.stories\.([tj])sx?$/, /node_modules/, /\.([tj])sx?$/] })); + } // TODO: framework doesn't exist, should move into framework when/if built if (frameworkName === '@storybook/preact-vite') { diff --git a/code/lib/builder-vite/src/vite-server.ts b/code/lib/builder-vite/src/vite-server.ts index 26ecbc4d51ad..a8a1b35b1d46 100644 --- a/code/lib/builder-vite/src/vite-server.ts +++ b/code/lib/builder-vite/src/vite-server.ts @@ -1,40 +1,30 @@ import type { Server } from 'http'; import { createServer } from 'vite'; -import { stringifyProcessEnvs } from './envs'; -import { getOptimizeDeps } from './optimizeDeps'; import { commonConfig } from './vite-config'; -import type { EnvsRaw, ExtendedOptions } from './types'; +import type { ExtendedOptions } from './types'; +import { getOptimizeDeps } from './optimizeDeps'; export async function createViteServer(options: ExtendedOptions, devServer: Server) { - const { port, presets } = options; + const { presets } = options; + + const config = await commonConfig(options, 'development'); - const baseConfig = await commonConfig(options, 'development'); - const defaultConfig = { - ...baseConfig, - server: { - middlewareMode: true, - hmr: { - port, - server: devServer, - }, - fs: { - strict: true, - }, + // Set up dev server + config.server = { + middlewareMode: true, + hmr: { + port: options.port, + server: devServer, + }, + fs: { + strict: true, }, - appType: 'custom' as const, - optimizeDeps: await getOptimizeDeps(baseConfig, options), }; + config.appType = 'custom'; - const finalConfig = await presets.apply('viteFinal', defaultConfig, options); - - const envsRaw = await presets.apply>('env'); - // Stringify env variables after getting `envPrefix` from the final config - const envs = stringifyProcessEnvs(envsRaw, finalConfig.envPrefix); - // Update `define` - finalConfig.define = { - ...finalConfig.define, - ...envs, - }; + // TODO: find a way to avoid having to do this in a separate step. + config.optimizeDeps = await getOptimizeDeps(config, options); + const finalConfig = await presets.apply('viteFinal', config, options); return createServer(finalConfig); }