Skip to content

Commit

Permalink
Merge pull request #19026 from storybookjs/vite-autoconfig
Browse files Browse the repository at this point in the history
Vite: Automatically use vite.config.js
  • Loading branch information
shilman authored Sep 1, 2022
2 parents ff452d0 + f9feca8 commit 3296bad
Show file tree
Hide file tree
Showing 12 changed files with 195 additions and 174 deletions.
10 changes: 10 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 1 addition & 63 deletions code/frameworks/svelte-vite/src/preset.ts
Original file line number Diff line number Diff line change
@@ -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'];

Expand All @@ -20,70 +21,7 @@ export function readPackageJson(): Record<string, any> | false {

export const viteFinal: StorybookConfig['viteFinal'] = async (config, { presets }) => {
const { plugins = [] } = config;
const svelteOptions = await presets.apply<Record<string, any>>('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 {
Expand Down
18 changes: 2 additions & 16 deletions code/frameworks/vue3-vite/src/preset.ts
Original file line number Diff line number Diff line change
@@ -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'];

Expand All @@ -21,22 +22,7 @@ export function readPackageJson(): Record<string, any> | 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,
Expand Down
15 changes: 15 additions & 0 deletions code/lib/api/src/lib/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
8 changes: 6 additions & 2 deletions code/lib/api/src/modules/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getStoriesLookupList,
HashEntry,
LeafEntry,
addPreparedStories,
} from '../lib/stories';

import type {
Expand Down Expand Up @@ -351,13 +352,16 @@ export const init: ModuleFn<SubAPI, SubState, true> = ({
}
},
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,
});
Expand Down
53 changes: 48 additions & 5 deletions code/lib/api/src/tests/stories.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/// <reference types="@types/jest" />;
// 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,
Expand All @@ -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<StoryIndex['entries'], []>();

jest.mock('../lib/events');
jest.mock('global', () => ({
...(jest.requireActual('global') as Record<string, any>),
fetch: jest.fn(() => ({ json: () => ({ v: 4, entries: mockStories() }) })),
...(mockJest.requireActual('global') as Record<string, any>),
fetch: mockJest.fn(() => ({ json: () => ({ v: 4, entries: mockStories() }) })),
FEATURES: { storyStoreV7: true },
CONFIG_TYPE: 'DEVELOPMENT',
}));

const getEventMetadataMock = getEventMetadata as jest.MockedFunction<typeof getEventMetadata>;
const getEventMetadataMock = getEventMetadata as ReturnType<typeof jest.fn>;

const mockIndex = {
'component-a--story-1': {
Expand All @@ -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);
}),
Expand Down Expand Up @@ -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': {
Expand Down
25 changes: 6 additions & 19 deletions code/lib/builder-vite/src/build.ts
Original file line number Diff line number Diff line change
@@ -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<Promise<EnvsRaw>>('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);
}
3 changes: 0 additions & 3 deletions code/lib/builder-vite/src/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 5 additions & 6 deletions code/lib/builder-vite/src/optimizeDeps.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -101,13 +102,11 @@ const INCLUDE_CANDIDATES = [
const asyncFilter = async (arr: string[], predicate: (val: string) => Promise<boolean>) =>
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.
Expand Down
27 changes: 27 additions & 0 deletions code/lib/builder-vite/src/plugins/strip-story-hmr-boundaries.ts
Original file line number Diff line number Diff line change
@@ -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 }),
};
},
};
}
Loading

0 comments on commit 3296bad

Please sign in to comment.