Merge pull request #28798 from tobiasdiez/previewAnno

Vite: Improve handling of preview annotations
This commit is contained in:
Kasper Peulen 2025-02-28 16:27:16 +01:00 committed by GitHub
commit bc4d3146fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 323 additions and 121 deletions

View File

@ -130,9 +130,6 @@ const ThemedSetRoot = () => {
return null;
};
// eslint-disable-next-line no-underscore-dangle
const preview = (window as any).__STORYBOOK_PREVIEW__ as PreviewWeb<ReactRenderer> | undefined;
const channel = (window as any).__STORYBOOK_ADDONS_CHANNEL__ as Channel | undefined;
const loaders = [
/**
* This loader adds a DocsContext to the story, which is required for the most Blocks to work. A
@ -147,6 +144,9 @@ const loaders = [
* The DocsContext will then be added via the decorator below.
*/
async ({ parameters: { relativeCsfPaths, attached = true } }) => {
// eslint-disable-next-line no-underscore-dangle
const preview = (window as any).__STORYBOOK_PREVIEW__ as PreviewWeb<ReactRenderer> | undefined;
const channel = (window as any).__STORYBOOK_ADDONS_CHANNEL__ as Channel | undefined;
// __STORYBOOK_PREVIEW__ and __STORYBOOK_ADDONS_CHANNEL__ is set in the PreviewWeb constructor
// which isn't loaded in portable stories/vitest
if (!relativeCsfPaths || !preview || !channel) {

View File

@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest';
import { generateModernIframeScriptCodeFromPreviews } from './codegen-modern-iframe-script';
const projectRoot = 'projectRoot';
describe('generateModernIframeScriptCodeFromPreviews', () => {
it('handle one annotation', async () => {
const result = await generateModernIframeScriptCodeFromPreviews({
previewAnnotations: ['/user/.storybook/preview'],
projectRoot,
frameworkName: 'frameworkName',
isCsf4: false,
});
expect(result).toMatchInlineSnapshot(`
"import { setup } from 'storybook/internal/preview/runtime';
import 'virtual:/@storybook/builder-vite/setup-addons.js';
setup();
import { composeConfigs, PreviewWeb } from 'storybook/internal/preview-api';
import { isPreview } from 'storybook/internal/csf';
import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js';
import * as preview_2408 from "/user/.storybook/preview";
const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => {
const configs = [
hmrPreviewAnnotationModules[0] ?? preview_2408
]
return composeConfigs(configs);
}
window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations);
window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore;
if (import.meta.hot) {
import.meta.hot.accept('virtual:/@storybook/builder-vite/storybook-stories.js', (newModule) => {
// importFn has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn });
});
import.meta.hot.accept(["/user/.storybook/preview"], (previewAnnotationModules) => {
// getProjectAnnotations has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) });
});
};"
`);
});
it('handle one annotation CSF4', async () => {
const result = await generateModernIframeScriptCodeFromPreviews({
previewAnnotations: ['/user/.storybook/preview'],
projectRoot,
frameworkName: 'frameworkName',
isCsf4: true,
});
expect(result).toMatchInlineSnapshot(`
"import { setup } from 'storybook/internal/preview/runtime';
import 'virtual:/@storybook/builder-vite/setup-addons.js';
setup();
import { composeConfigs, PreviewWeb } from 'storybook/internal/preview-api';
import { isPreview } from 'storybook/internal/csf';
import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js';
import * as preview_2408 from "/user/.storybook/preview";
const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => {
const preview = hmrPreviewAnnotationModules[0] ?? preview_2408;
return preview.default.composed;
}
window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations);
window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore;
if (import.meta.hot) {
import.meta.hot.accept('virtual:/@storybook/builder-vite/storybook-stories.js', (newModule) => {
// importFn has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn });
});
import.meta.hot.accept(["/user/.storybook/preview"], (previewAnnotationModules) => {
// getProjectAnnotations has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) });
});
};"
`);
});
it('handle multiple annotations', async () => {
const result = await generateModernIframeScriptCodeFromPreviews({
previewAnnotations: ['/user/previewAnnotations1', '/user/.storybook/preview'],
projectRoot,
frameworkName: 'frameworkName',
isCsf4: false,
});
expect(result).toMatchInlineSnapshot(`
"import { setup } from 'storybook/internal/preview/runtime';
import 'virtual:/@storybook/builder-vite/setup-addons.js';
setup();
import { composeConfigs, PreviewWeb } from 'storybook/internal/preview-api';
import { isPreview } from 'storybook/internal/csf';
import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js';
import * as previewAnnotations1_2526 from "/user/previewAnnotations1";
import * as preview_2408 from "/user/.storybook/preview";
const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => {
const configs = [
hmrPreviewAnnotationModules[0] ?? previewAnnotations1_2526,
hmrPreviewAnnotationModules[1] ?? preview_2408
]
return composeConfigs(configs);
}
window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations);
window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore;
if (import.meta.hot) {
import.meta.hot.accept('virtual:/@storybook/builder-vite/storybook-stories.js', (newModule) => {
// importFn has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn });
});
import.meta.hot.accept(["/user/previewAnnotations1","/user/.storybook/preview"], (previewAnnotationModules) => {
// getProjectAnnotations has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) });
});
};"
`);
});
it('handle multiple annotations CSF4', async () => {
const result = await generateModernIframeScriptCodeFromPreviews({
previewAnnotations: ['/user/previewAnnotations1', '/user/.storybook/preview'],
projectRoot,
frameworkName: 'frameworkName',
isCsf4: true,
});
expect(result).toMatchInlineSnapshot(`
"import { setup } from 'storybook/internal/preview/runtime';
import 'virtual:/@storybook/builder-vite/setup-addons.js';
setup();
import { composeConfigs, PreviewWeb } from 'storybook/internal/preview-api';
import { isPreview } from 'storybook/internal/csf';
import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js';
import * as preview_2408 from "/user/.storybook/preview";
const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => {
const preview = hmrPreviewAnnotationModules[0] ?? preview_2408;
return preview.default.composed;
}
window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations);
window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore;
if (import.meta.hot) {
import.meta.hot.accept('virtual:/@storybook/builder-vite/storybook-stories.js', (newModule) => {
// importFn has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn });
});
import.meta.hot.accept(["/user/.storybook/preview"], (previewAnnotationModules) => {
// getProjectAnnotations has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) });
});
};"
`);
});
});

View File

@ -1,6 +1,9 @@
import { getFrameworkName, loadPreviewOrConfigFile } from 'storybook/internal/common';
import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools';
import type { Options, PreviewAnnotation } from 'storybook/internal/types';
import { genArrayFromRaw, genImport, genSafeVariableName } from 'knitwork';
import { filename } from 'pathe/utils';
import { dedent } from 'ts-dedent';
import { processPreviewAnnotation } from './utils/process-preview-annotation';
@ -11,38 +14,72 @@ export async function generateModernIframeScriptCode(options: Options, projectRo
const frameworkName = await getFrameworkName(options);
const previewOrConfigFile = loadPreviewOrConfigFile({ configDir });
const previewConfig = await readConfig(previewOrConfigFile!);
const isCsf4 = isCsfFactoryPreview(previewConfig);
const previewAnnotations = await presets.apply<PreviewAnnotation[]>(
'previewAnnotations',
[],
options
);
const [previewFileUrl, ...previewAnnotationURLs] = [previewOrConfigFile, ...previewAnnotations]
.filter(Boolean)
return generateModernIframeScriptCodeFromPreviews({
previewAnnotations: [...previewAnnotations, previewOrConfigFile],
projectRoot,
frameworkName,
isCsf4,
});
}
export async function generateModernIframeScriptCodeFromPreviews(options: {
previewAnnotations: (PreviewAnnotation | undefined)[];
projectRoot: string;
frameworkName: string;
isCsf4: boolean;
}) {
const { projectRoot, frameworkName } = options;
const previewAnnotationURLs = options.previewAnnotations
.filter((path) => path !== undefined)
.map((path) => processPreviewAnnotation(path, projectRoot));
const variables: string[] = [];
const imports: string[] = [];
for (const previewAnnotation of previewAnnotationURLs) {
const variable =
genSafeVariableName(filename(previewAnnotation)).replace(/_(45|46|47)/g, '_') +
'_' +
hash(previewAnnotation);
variables.push(variable);
imports.push(genImport(previewAnnotation, { name: '*', as: variable }));
}
const previewFileURL = previewAnnotationURLs[previewAnnotationURLs.length - 1];
const previewFileVariable = variables[variables.length - 1];
const previewFileImport = imports[imports.length - 1];
// This is pulled out to a variable because it is reused in both the initial page load
// and the HMR handler. We don't use the hot.accept callback params because only the changed
// modules are provided, the rest are null. We can just re-import everything again in that case.
const getPreviewAnnotationsFunction = dedent`
const getProjectAnnotations = async (hmrPreviewAnnotationModules = []) => {
const preview = await import('${previewFileUrl}');
if (isPreview(preview.default)) {
return preview.default.composed;
}
const configs = await Promise.all([${previewAnnotationURLs
.map(
// and the HMR handler.
// The `hmrPreviewAnnotationModules` parameter is used to pass the updated modules from HMR.
// However, only the changed modules are provided, the rest are null.
const getPreviewAnnotationsFunction = options.isCsf4
? dedent`
const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => {
const preview = hmrPreviewAnnotationModules[0] ?? ${previewFileVariable};
return preview.default.composed;
}`
: dedent`
const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => {
const configs = ${genArrayFromRaw(
variables.map(
(previewAnnotation, index) =>
// Prefer the updated module from an HMR update, otherwise import the original module
`hmrPreviewAnnotationModules[${index}] ?? import('${previewAnnotation}')`
)
.join(',\n')}])
return composeConfigs([...configs, preview]);
// Prefer the updated module from an HMR update, otherwise the original module
`hmrPreviewAnnotationModules[${index}] ?? ${previewAnnotation}`
),
' '
)}
return composeConfigs(configs);
}`;
// eslint-disable-next-line @typescript-eslint/no-shadow
const generateHMRHandler = (frameworkName: string): string => {
const generateHMRHandler = (): string => {
// Web components are not compatible with HMR, so disable HMR, reload page instead.
if (frameworkName === '@storybook/web-components-vite') {
return dedent`
@ -58,8 +95,7 @@ export async function generateModernIframeScriptCode(options: Options, projectRo
window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn });
});
import.meta.hot.accept(${JSON.stringify(previewAnnotationURLs)}, (previewAnnotationModules) => {
${getPreviewAnnotationsFunction}
import.meta.hot.accept(${JSON.stringify(options.isCsf4 ? [previewFileURL] : previewAnnotationURLs)}, (previewAnnotationModules) => {
// getProjectAnnotations has changed so we need to patch the new one in
window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) });
});
@ -76,6 +112,7 @@ export async function generateModernIframeScriptCode(options: Options, projectRo
*/
const code = dedent`
import { setup } from 'storybook/internal/preview/runtime';
import '${SB_VIRTUAL_FILES.VIRTUAL_ADDON_SETUP_FILE}';
setup();
@ -84,12 +121,17 @@ export async function generateModernIframeScriptCode(options: Options, projectRo
import { isPreview } from 'storybook/internal/csf';
import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}';
${options.isCsf4 ? previewFileImport : imports.join('\n')}
${getPreviewAnnotationsFunction}
window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations);
window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore;
${generateHMRHandler(frameworkName)};`.trim();
${generateHMRHandler()};
`.trim();
return code;
}
function hash(value: string) {
return value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
}

View File

@ -125,6 +125,7 @@ const INCLUDE_CANDIDATES = [
'qs',
'react-dom',
'react-dom/client',
'react-dom/test-utils',
'react-fast-compare',
'react-is',
'react-textarea-autosize',
@ -144,6 +145,7 @@ const INCLUDE_CANDIDATES = [
'refractor/lang/typescript.js',
'refractor/lang/yaml.js',
'regenerator-runtime/runtime.js',
'semver', // TODO: Remove once https://github.com/npm/node-semver/issues/712 is fixed
'sb-original/default-loader',
'sb-original/image-context',
'slash',

View File

@ -4,64 +4,71 @@ import { onlyWindows, skipWindows } from '../../../../vitest.helpers';
import { processPreviewAnnotation } from './process-preview-annotation';
describe('processPreviewAnnotation()', () => {
it('should pull the `bare` value from an object', () => {
it('should pull the `absolute` value from an object', () => {
const annotation = {
bare: '@storybook/addon-links/preview',
absolute: '/Users/foo/storybook/node_modules/@storybook/addon-links/dist/preview.mjs',
};
const url = processPreviewAnnotation(annotation, '/Users/foo/storybook/');
expect(url).toBe('@storybook/addon-links/preview');
expect(url).toBe('/Users/foo/storybook/node_modules/@storybook/addon-links/dist/preview.mjs');
});
it('should convert relative paths into urls', () => {
const annotation = './src/stories/components';
it('should convert relative paths into absolute paths', () => {
const annotation = './src/stories/preview';
const url = processPreviewAnnotation(annotation, '/Users/foo/storybook/');
expect(url).toBe('/src/stories/components');
expect(url).toBe('/Users/foo/storybook/src/stories/preview');
});
skipWindows(() => {
it('should convert absolute filesystem paths into urls relative to project root', () => {
const annotation = '/Users/foo/storybook/.storybook/preview.js';
const url = processPreviewAnnotation(annotation, '/Users/foo/storybook/');
expect(url).toBe('/.storybook/preview.js');
});
// TODO: figure out why this fails on windows. Could be related to storybook-metadata.test file altering path.sep
it('should convert node_modules into bare paths', () => {
const annotation = '/Users/foo/storybook/node_modules/storybook-addon/preview';
const url = processPreviewAnnotation(annotation, '/Users/foo/storybook/');
expect(url).toBe('storybook-addon/preview');
});
it('should convert relative paths outside the root into absolute', () => {
const annotation = '../parent.js';
const url = processPreviewAnnotation(annotation, '/Users/foo/storybook/');
expect(url).toBe('/Users/foo/parent.js');
});
it('should not change absolute paths outside of the project root', () => {
const annotation = '/Users/foo/parent.js';
const url = processPreviewAnnotation(annotation, '/Users/foo/storybook/');
expect(url).toBe(annotation);
});
it('should keep absolute filesystem paths', () => {
const annotation = '/Users/foo/storybook/.storybook/preview.js';
const url = processPreviewAnnotation(annotation, '/Users/foo/storybook/');
expect(url).toBe('/Users/foo/storybook/.storybook/preview.js');
});
onlyWindows(() => {
it('should convert absolute windows filesystem paths into urls relative to project root', () => {
const annotation = 'C:/foo/storybook/.storybook/preview.js';
const url = processPreviewAnnotation(annotation, 'C:/foo/storybook');
expect(url).toBe('/.storybook/preview.js');
});
it('should convert relative paths outside the root into absolute on Windows', () => {
const annotation = '../parent.js';
const url = processPreviewAnnotation(annotation, 'C:/Users/foo/storybook/');
expect(url).toBe('C:/Users/foo/parent.js');
});
it('should keep absolute node_modules paths', () => {
const annotation = '/Users/foo/storybook/node_modules/storybook-addon/preview';
const url = processPreviewAnnotation(annotation, '/Users/foo/storybook/');
expect(url).toBe('/Users/foo/storybook/node_modules/storybook-addon/preview');
});
it('should not change Windows absolute paths outside of the project root', () => {
const annotation = 'D:/Users/foo/parent.js';
const url = processPreviewAnnotation(annotation, 'D:/Users/foo/storybook/');
expect(url).toBe(annotation);
});
it('should convert relative paths outside the root into absolute', () => {
const annotation = '../parent.js';
const url = processPreviewAnnotation(annotation, '/Users/foo/storybook/');
expect(url).toBe('/Users/foo/parent.js');
});
it('should not change absolute paths outside of the project root', () => {
const annotation = '/Users/foo/parent.js';
const url = processPreviewAnnotation(annotation, '/Users/foo/storybook/');
expect(url).toBe(annotation);
});
it('should keep absolute windows filesystem paths as is', () => {
const annotation = 'C:/foo/storybook/.storybook/preview.js';
const url = processPreviewAnnotation(annotation, 'C:/foo/storybook');
expect(url).toBe('C:/foo/storybook/.storybook/preview.js');
});
it('should convert relative paths outside the root into absolute on Windows', () => {
const annotation = '../parent.js';
const url = processPreviewAnnotation(annotation, 'C:/Users/foo/storybook/');
expect(url).toBe('C:/Users/foo/parent.js');
});
it('should not change Windows absolute paths outside of the project root', () => {
const annotation = 'D:/Users/foo/parent.js';
const url = processPreviewAnnotation(annotation, 'D:/Users/foo/storybook/');
expect(url).toBe(annotation);
});
it('should normalize absolute Windows paths using \\', () => {
const annotation = 'C:\\foo\\storybook\\.storybook\\preview.js';
const url = processPreviewAnnotation(annotation, 'C:\\foo\\storybook');
expect(url).toBe('C:/foo/storybook/.storybook/preview.js');
});
it('should normalize relative Windows paths using \\', () => {
const annotation = '.\\src\\stories\\preview';
const url = processPreviewAnnotation(annotation, 'C:\\foo\\storybook');
expect(url).toBe('C:/foo/storybook/src/stories/preview');
});
});

View File

@ -1,54 +1,25 @@
import { isAbsolute, relative, resolve } from 'node:path';
import { stripAbsNodeModulesPath } from 'storybook/internal/common';
import type { PreviewAnnotation } from 'storybook/internal/types';
import slash from 'slash';
import { isAbsolute, normalize, resolve } from 'pathe';
/**
* Preview annotations can take several forms, and vite needs them to be a bit more restrained.
*
* For node_modules, we want bare imports (so vite can process them), and for files in the user's
* source, we want URLs absolute relative to project root.
*/
export function processPreviewAnnotation(path: PreviewAnnotation | undefined, projectRoot: string) {
// If entry is an object, take the first, which is the
// bare (non-absolute) specifier.
// This is so that webpack can use an absolute path, and
// continue supporting super-addons in pnp/pnpm without
// requiring them to re-export their sub-addons as we do
// in addon-essentials.
/** Preview annotations can take several forms, so we normalize them here to absolute file paths. */
export function processPreviewAnnotation(path: PreviewAnnotation, projectRoot: string) {
// If entry is an object, take the absolute specifier.
// This absolute specifier is automatically made for addons here:
// https://github.com/storybookjs/storybook/blob/ac6e73b9d8ce31dd9acc80999c8d7c22a111f3cc/code/core/src/common/presets.ts#L161-L171
if (typeof path === 'object') {
return path.bare;
// TODO: Remove this once the new version of Nuxt is released that removes this workaround:
// https://github.com/nuxt-modules/storybook/blob/a2eec6e898386f76c74826842e8e007b185c3d35/packages/storybook-addon/src/preset.ts#L279-L306
if (path.bare != null && path.absolute === '') {
return path.bare;
}
return path.absolute;
}
// This should not occur, since we use `.filter(Boolean)` prior to
// calling this function, but this makes typescript happy
if (!path) {
throw new Error('Could not determine path for previewAnnotation');
// If it's already an absolute path, return it.
if (isAbsolute(path)) {
return normalize(path);
}
// For addon dependencies that use require.resolve(), we need to convert to a bare path
// so that vite will process it as a dependency (cjs -> esm, etc).
// TODO: Evaluate if searching for node_modules in a yarn pnp environment is correct
if (path.includes('node_modules')) {
return stripAbsNodeModulesPath(path);
}
// resolve absolute paths relative to project root
const relativePath = isAbsolute(path) ? slash(relative(projectRoot, path)) : path;
// resolve relative paths into absolute urls
// note: this only works if vite's projectRoot === cwd.
if (relativePath.startsWith('./')) {
return slash(relativePath.replace(/^\.\//, '/'));
}
// If something is outside of root, convert to absolute. Uncommon?
if (relativePath.startsWith('../')) {
return slash(resolve(projectRoot, relativePath));
}
// At this point, it must be relative to the root but not start with a ./ or ../
return slash(`/${relativePath}`);
// resolve relative paths, relative to project root
return normalize(resolve(projectRoot, path));
}

View File

@ -33,7 +33,6 @@ export const getVirtualModules = async (options: Options) => {
// If entry is an object, use the absolute import specifier.
// This is to maintain back-compat with community addons that bundle other addons
// and package managers that "hide" sub dependencies (e.g. pnpm / yarn pnp)
// The vite builder uses the bare import specifier.
if (typeof entry === 'object') {
return entry.absolute;
}