From be5ae52b962d9e4355a2b7337093c061689f0fda Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 28 Jan 2025 23:08:05 +0100 Subject: [PATCH 01/29] initial index-based importFn generation --- .../src/codegen-importfn-script.ts | 18 +++++-- code/builders/builder-vite/src/index.ts | 3 +- .../src/plugins/code-generator-plugin.ts | 48 ++++++++++++------- code/builders/builder-vite/src/vite-config.ts | 9 ++-- code/builders/builder-vite/src/vite-server.ts | 4 +- code/core/src/core-server/dev-server.ts | 3 ++ .../core-server/utils/StoryIndexGenerator.ts | 8 ++++ 7 files changed, 67 insertions(+), 26 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-importfn-script.ts b/code/builders/builder-vite/src/codegen-importfn-script.ts index dc485c88f74d..c370b405e61f 100644 --- a/code/builders/builder-vite/src/codegen-importfn-script.ts +++ b/code/builders/builder-vite/src/codegen-importfn-script.ts @@ -1,7 +1,8 @@ +import type { StoryIndexGenerator } from 'storybook/internal/core-server'; import type { Options } from 'storybook/internal/types'; import { genDynamicImport, genImport, genObjectFromRawEntries } from 'knitwork'; -import { normalize, relative } from 'pathe'; +import { join, normalize, relative } from 'pathe'; import { dedent } from 'ts-dedent'; import { listStories } from './list-stories'; @@ -45,9 +46,20 @@ export async function toImportFn(stories: string[]) { `; } -export async function generateImportFnScriptCode(options: Options): Promise { +export async function generateImportFnScriptCode( + options: Options, + storyIndexGenerator +): Promise { // First we need to get an array of stories and their absolute paths. - const stories = await listStories(options); + // const stories = await listStories(options); + const generator = (await storyIndexGenerator) as StoryIndexGenerator; + const index = await generator.getIndex(); + const stories = Object.values(index.entries).map((entry) => + join(process.cwd(), entry.importPath) + ); + + // console.log('original stories', stories[0]); + console.log('new stories', stories[0]); // We can then call toImportFn to create a function that can be used to load each story dynamically. // eslint-disable-next-line @typescript-eslint/return-await diff --git a/code/builders/builder-vite/src/index.ts b/code/builders/builder-vite/src/index.ts index 7051cc116363..3b5ffaaa3874 100644 --- a/code/builders/builder-vite/src/index.ts +++ b/code/builders/builder-vite/src/index.ts @@ -54,9 +54,10 @@ export const start: ViteBuilder['start'] = async ({ startTime, options, router, + storyIndexGenerator, server: devServer, }) => { - server = await createViteServer(options as Options, devServer); + server = await createViteServer(options as Options, devServer, storyIndexGenerator); router.use(iframeMiddleware(options as Options, server)); router.use(server.middlewares); diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index 79dba3844060..5a76bc881a68 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; +import type { StoryIndexGenerator } from 'storybook/internal/core-server'; import type { Options } from 'storybook/internal/types'; import type { Plugin } from 'vite'; @@ -10,7 +11,7 @@ import { generateAddonSetupCode } from '../codegen-set-addon-channel'; import { transformIframeHtml } from '../transform-iframe-html'; import { SB_VIRTUAL_FILES, getResolvedVirtualModuleId } from '../virtual-file-names'; -export function codeGeneratorPlugin(options: Options): Plugin { +export async function codeGeneratorPlugin(options: Options, storyIndexGenerator): Plugin { const iframePath = require.resolve('@storybook/builder-vite/input/iframe.html'); let iframeId: string; let projectRoot: string; @@ -19,16 +20,10 @@ export function codeGeneratorPlugin(options: Options): Plugin { return { name: 'storybook:code-generator-plugin', enforce: 'pre', - configureServer(server) { - // invalidate the whole vite-app.js script on every file change. - // (this might be a little too aggressive?) - server.watcher.on('change', () => { - const appModule = server.moduleGraph.getModuleById( - getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE) - ); - if (appModule) { - server.moduleGraph.invalidateModule(appModule); - } + async configureServer(server) { + const generator = (await storyIndexGenerator) as StoryIndexGenerator; + generator.onInvalidated(() => { + console.log('LOG: invalidated!'); const storiesModule = server.moduleGraph.getModuleById( getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE) ); @@ -36,16 +31,37 @@ export function codeGeneratorPlugin(options: Options): Plugin { server.moduleGraph.invalidateModule(storiesModule); } }); + // invalidate the whole vite-app.js script on every file change. + // (this might be a little too aggressive?) + server.watcher.on('change', (path) => { + console.log('LOG: server.watcher.on(change)', path); + // const appModule = server.moduleGraph.getModuleById( + // getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE) + // ); + // if (appModule) { + // server.moduleGraph.invalidateModule(appModule); + // } + // const storiesModule = server.moduleGraph.getModuleById( + // getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE) + // ); + // if (storiesModule) { + // server.moduleGraph.invalidateModule(storiesModule); + // } + }); // Adding new story files is not covered by the change event above. So we need to detect this and trigger // HMR to update the importFn. + // on generator.invalidate emit, + // run server.watcher.emit('change', SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE); + server.watcher.on('add', (path) => { + console.log('LOG: server.watcher.on(add)', path); // TODO maybe use the stories declaration in main - if (/\.stories\.([tj])sx?$/.test(path) || /\.mdx$/.test(path)) { - // We need to emit a change event to trigger HMR - server.watcher.emit('change', SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE); - } + // if (/\.stories\.([tj])sx?$/.test(path) || /\.mdx$/.test(path)) { + // // We need to emit a change event to trigger HMR + // server.watcher.emit('change', SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE); + // } }); }, config(config, { command }) { @@ -88,7 +104,7 @@ export function codeGeneratorPlugin(options: Options): Plugin { }, async load(id, config) { if (id === getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE)) { - return generateImportFnScriptCode(options); + return generateImportFnScriptCode(options, storyIndexGenerator); } if (id === getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_ADDON_SETUP_FILE)) { diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 8983dfc137b3..c8bb4cbe250b 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -44,7 +44,8 @@ const configEnvBuild: ConfigEnv = { // Vite config that is common to development and production mode export async function commonConfig( options: Options, - _type: PluginConfigType + _type: PluginConfigType, + storyIndexGenerator ): Promise { const configEnv = _type === 'development' ? configEnvServe : configEnvBuild; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -67,7 +68,7 @@ export async function commonConfig( root: projectRoot, // Allow storybook deployed as subfolder. See https://github.com/storybookjs/builder-vite/issues/238 base: './', - plugins: await pluginConfig(options), + plugins: await pluginConfig(options, storyIndexGenerator), resolve: { conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], preserveSymlinks: isPreservingSymlinks(), @@ -89,7 +90,7 @@ export async function commonConfig( return config; } -export async function pluginConfig(options: Options) { +export async function pluginConfig(options: Options, storyIndexGenerator) { const frameworkName = await getFrameworkName(options); const build = await options.presets.apply('build'); @@ -100,7 +101,7 @@ export async function pluginConfig(options: Options) { } const plugins = [ - codeGeneratorPlugin(options), + codeGeneratorPlugin(options, storyIndexGenerator), await csfPlugin(options), await injectExportOrderPlugin(), await stripStoryHMRBoundary(), diff --git a/code/builders/builder-vite/src/vite-server.ts b/code/builders/builder-vite/src/vite-server.ts index e61cd63636f0..b179b49aee81 100644 --- a/code/builders/builder-vite/src/vite-server.ts +++ b/code/builders/builder-vite/src/vite-server.ts @@ -7,10 +7,10 @@ import { sanitizeEnvVars } from './envs'; import { getOptimizeDeps } from './optimizeDeps'; import { commonConfig } from './vite-config'; -export async function createViteServer(options: Options, devServer: Server) { +export async function createViteServer(options: Options, devServer: Server, storyIndexGenerator) { const { presets } = options; - const commonCfg = await commonConfig(options, 'development'); + const commonCfg = await commonConfig(options, 'development', storyIndexGenerator); const config = { ...commonCfg, diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 325d33366514..db6eda9814a9 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -89,6 +89,9 @@ export async function storybookDevServer(options: Options) { startTime: process.hrtime(), options, router: app, + // TODO: add to options instead of passing it directly + // TODO: alternatively use presets + storyIndexGenerator: initializedStoryIndexGenerator, server, channel: serverChannel, }) diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts index 164613d7e4c2..cf9e2fb311c1 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts @@ -706,6 +706,7 @@ export class StoryIndexGenerator { }); this.lastIndex = null; this.lastError = null; + this.invalidateCallback?.(); } invalidate(specifier: NormalizedStoriesSpecifier, importPath: Path, removed: boolean) { @@ -750,6 +751,13 @@ export class StoryIndexGenerator { } this.lastIndex = null; this.lastError = null; + this.invalidateCallback?.(); + } + + onInvalidated(callback: () => void) { + // TODO add an instance field with a set of callbacks + // TODO: return a function to remove the callback + this.invalidateCallback = callback; } async getPreviewCode() { From 21926a5ae926c6b1ae3ebaab20780ae62ad2f2c0 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 19 Feb 2025 10:20:19 +0100 Subject: [PATCH 02/29] refactor vite hmr and importFn generation --- .../src/codegen-importfn-script.ts | 47 ++++------ code/builders/builder-vite/src/index.ts | 3 +- .../src/plugins/code-generator-plugin.ts | 89 ++++++------------- code/builders/builder-vite/src/vite-config.ts | 9 +- code/builders/builder-vite/src/vite-server.ts | 4 +- code/core/src/core-server/dev-server.ts | 5 +- .../core-server/utils/StoryIndexGenerator.ts | 15 ++-- code/core/src/types/modules/core-common.ts | 2 + 8 files changed, 62 insertions(+), 112 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-importfn-script.ts b/code/builders/builder-vite/src/codegen-importfn-script.ts index c370b405e61f..fb603b4effc7 100644 --- a/code/builders/builder-vite/src/codegen-importfn-script.ts +++ b/code/builders/builder-vite/src/codegen-importfn-script.ts @@ -1,7 +1,6 @@ -import type { StoryIndexGenerator } from 'storybook/internal/core-server'; -import type { Options } from 'storybook/internal/types'; +import type { StoryIndex } from 'storybook/internal/types'; -import { genDynamicImport, genImport, genObjectFromRawEntries } from 'knitwork'; +import { genDynamicImport, genObjectFromRawEntries } from 'knitwork'; import { join, normalize, relative } from 'pathe'; import { dedent } from 'ts-dedent'; @@ -27,14 +26,24 @@ function toImportPath(relativePath: string) { * function to delay loading and to allow Vite to split the code into smaller chunks. It then * creates a function, `importFn(path)`, which resolves a path to an import function and this is * called by Storybook to fetch a story dynamically when needed. - * - * @param stories An array of absolute story paths. */ -export async function toImportFn(stories: string[]) { - const objectEntries = stories.map((file) => { - const relativePath = relative(process.cwd(), file); +export async function generateImportFnScriptCode(index: StoryIndex): Promise { + const objectEntries: [string, string][] = Object.values(index.entries).map((entry) => { + if (entry.importPath.startsWith('virtual:')) { + console.log('LOG: virtual entry', entry.importPath); + return [entry.importPath, entry.importPath]; + } - return [toImportPath(relativePath), genDynamicImport(normalize(file))] as [string, string]; + const absolutePath = join(process.cwd(), entry.importPath); + const relativePath = relative(process.cwd(), absolutePath); + console.log('LOG: paths', { + importPath: entry.importPath, + absolutePath, + relativePath, + toImportPathed: toImportPath(relativePath), + cwd: process.cwd(), + }); + return [relativePath, genDynamicImport(normalize(absolutePath))]; }); return dedent` @@ -45,23 +54,3 @@ export async function toImportFn(stories: string[]) { } `; } - -export async function generateImportFnScriptCode( - options: Options, - storyIndexGenerator -): Promise { - // First we need to get an array of stories and their absolute paths. - // const stories = await listStories(options); - const generator = (await storyIndexGenerator) as StoryIndexGenerator; - const index = await generator.getIndex(); - const stories = Object.values(index.entries).map((entry) => - join(process.cwd(), entry.importPath) - ); - - // console.log('original stories', stories[0]); - console.log('new stories', stories[0]); - - // We can then call toImportFn to create a function that can be used to load each story dynamically. - // eslint-disable-next-line @typescript-eslint/return-await - return await toImportFn(stories); -} diff --git a/code/builders/builder-vite/src/index.ts b/code/builders/builder-vite/src/index.ts index 3b5ffaaa3874..7051cc116363 100644 --- a/code/builders/builder-vite/src/index.ts +++ b/code/builders/builder-vite/src/index.ts @@ -54,10 +54,9 @@ export const start: ViteBuilder['start'] = async ({ startTime, options, router, - storyIndexGenerator, server: devServer, }) => { - server = await createViteServer(options as Options, devServer, storyIndexGenerator); + server = await createViteServer(options as Options, devServer); router.use(iframeMiddleware(options as Options, server)); router.use(server.middlewares); diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index 5a76bc881a68..1c2f8ced6f45 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -11,18 +11,20 @@ import { generateAddonSetupCode } from '../codegen-set-addon-channel'; import { transformIframeHtml } from '../transform-iframe-html'; import { SB_VIRTUAL_FILES, getResolvedVirtualModuleId } from '../virtual-file-names'; -export async function codeGeneratorPlugin(options: Options, storyIndexGenerator): Plugin { +export function codeGeneratorPlugin(options: Options): Plugin { const iframePath = require.resolve('@storybook/builder-vite/input/iframe.html'); let iframeId: string; let projectRoot: string; + let storyIndexGenerator: StoryIndexGenerator | undefined; - // noinspection JSUnusedGlobalSymbols return { name: 'storybook:code-generator-plugin', enforce: 'pre', + async buildStart() { + storyIndexGenerator = await options?.getStoryIndexGenerator?.(); + }, async configureServer(server) { - const generator = (await storyIndexGenerator) as StoryIndexGenerator; - generator.onInvalidated(() => { + storyIndexGenerator?.onInvalidated(() => { console.log('LOG: invalidated!'); const storiesModule = server.moduleGraph.getModuleById( getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE) @@ -31,38 +33,6 @@ export async function codeGeneratorPlugin(options: Options, storyIndexGenerator) server.moduleGraph.invalidateModule(storiesModule); } }); - // invalidate the whole vite-app.js script on every file change. - // (this might be a little too aggressive?) - server.watcher.on('change', (path) => { - console.log('LOG: server.watcher.on(change)', path); - // const appModule = server.moduleGraph.getModuleById( - // getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE) - // ); - // if (appModule) { - // server.moduleGraph.invalidateModule(appModule); - // } - // const storiesModule = server.moduleGraph.getModuleById( - // getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE) - // ); - // if (storiesModule) { - // server.moduleGraph.invalidateModule(storiesModule); - // } - }); - - // Adding new story files is not covered by the change event above. So we need to detect this and trigger - // HMR to update the importFn. - - // on generator.invalidate emit, - // run server.watcher.emit('change', SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE); - - server.watcher.on('add', (path) => { - console.log('LOG: server.watcher.on(add)', path); - // TODO maybe use the stories declaration in main - // if (/\.stories\.([tj])sx?$/.test(path) || /\.mdx$/.test(path)) { - // // We need to emit a change event to trigger HMR - // server.watcher.emit('change', SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE); - // } - }); }, config(config, { command }) { // If we are building the static distribution, add iframe.html as an entry. @@ -84,42 +54,33 @@ export async function codeGeneratorPlugin(options: Options, storyIndexGenerator) iframeId = `${config.root}/iframe.html`; }, resolveId(source) { - if (source === SB_VIRTUAL_FILES.VIRTUAL_APP_FILE) { - return getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE); + if (Object.values(SB_VIRTUAL_FILES).includes(source)) { + return getResolvedVirtualModuleId(source); } if (source === iframePath) { return iframeId; } - if (source === SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE) { - return getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE); - } - if (source === SB_VIRTUAL_FILES.VIRTUAL_PREVIEW_FILE) { - return getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_PREVIEW_FILE); - } - if (source === SB_VIRTUAL_FILES.VIRTUAL_ADDON_SETUP_FILE) { - return getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_ADDON_SETUP_FILE); - } return undefined; }, - async load(id, config) { - if (id === getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE)) { - return generateImportFnScriptCode(options, storyIndexGenerator); + async load(id) { + switch (id) { + case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_PREVIEW_FILE): + return generateImportFnScriptCode( + (await storyIndexGenerator?.getIndex()) ?? { v: 5, entries: {} } + ); + case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_ADDON_SETUP_FILE): + return generateAddonSetupCode(); + case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE): + return generateModernIframeScriptCode(options, projectRoot); + case iframeId: + return readFileSync( + require.resolve('@storybook/builder-vite/input/iframe.html'), + 'utf-8' + ); + default: + return undefined; } - - if (id === getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_ADDON_SETUP_FILE)) { - return generateAddonSetupCode(); - } - - if (id === getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE)) { - return generateModernIframeScriptCode(options, projectRoot); - } - - if (id === iframeId) { - return readFileSync(require.resolve('@storybook/builder-vite/input/iframe.html'), 'utf-8'); - } - - return undefined; }, async transformIndexHtml(html, ctx) { if (ctx.path !== '/iframe.html') { diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index c8bb4cbe250b..8983dfc137b3 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -44,8 +44,7 @@ const configEnvBuild: ConfigEnv = { // Vite config that is common to development and production mode export async function commonConfig( options: Options, - _type: PluginConfigType, - storyIndexGenerator + _type: PluginConfigType ): Promise { const configEnv = _type === 'development' ? configEnvServe : configEnvBuild; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -68,7 +67,7 @@ export async function commonConfig( root: projectRoot, // Allow storybook deployed as subfolder. See https://github.com/storybookjs/builder-vite/issues/238 base: './', - plugins: await pluginConfig(options, storyIndexGenerator), + plugins: await pluginConfig(options), resolve: { conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], preserveSymlinks: isPreservingSymlinks(), @@ -90,7 +89,7 @@ export async function commonConfig( return config; } -export async function pluginConfig(options: Options, storyIndexGenerator) { +export async function pluginConfig(options: Options) { const frameworkName = await getFrameworkName(options); const build = await options.presets.apply('build'); @@ -101,7 +100,7 @@ export async function pluginConfig(options: Options, storyIndexGenerator) { } const plugins = [ - codeGeneratorPlugin(options, storyIndexGenerator), + codeGeneratorPlugin(options), await csfPlugin(options), await injectExportOrderPlugin(), await stripStoryHMRBoundary(), diff --git a/code/builders/builder-vite/src/vite-server.ts b/code/builders/builder-vite/src/vite-server.ts index b179b49aee81..e61cd63636f0 100644 --- a/code/builders/builder-vite/src/vite-server.ts +++ b/code/builders/builder-vite/src/vite-server.ts @@ -7,10 +7,10 @@ import { sanitizeEnvVars } from './envs'; import { getOptimizeDeps } from './optimizeDeps'; import { commonConfig } from './vite-config'; -export async function createViteServer(options: Options, devServer: Server, storyIndexGenerator) { +export async function createViteServer(options: Options, devServer: Server) { const { presets } = options; - const commonCfg = await commonConfig(options, 'development', storyIndexGenerator); + const commonCfg = await commonConfig(options, 'development'); const config = { ...commonCfg, diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index db6eda9814a9..bf683e712923 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -87,11 +87,8 @@ export async function storybookDevServer(options: Options) { previewStarted = previewBuilder .start({ startTime: process.hrtime(), - options, + options: { ...options, getStoryIndexGenerator: () => initializedStoryIndexGenerator }, router: app, - // TODO: add to options instead of passing it directly - // TODO: alternatively use presets - storyIndexGenerator: initializedStoryIndexGenerator, server, channel: serverChannel, }) diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts index cf9e2fb311c1..5a155aefd915 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts @@ -114,6 +114,8 @@ export class StoryIndexGenerator { // Same as the above but for the error case private lastError?: Error | null; + private invalidationListeners: Set<() => void> = new Set(); + constructor( public readonly specifiers: NormalizedStoriesSpecifier[], public readonly options: StoryIndexGeneratorOptions @@ -706,7 +708,7 @@ export class StoryIndexGenerator { }); this.lastIndex = null; this.lastError = null; - this.invalidateCallback?.(); + this.invalidationListeners.forEach((listener) => listener()); } invalidate(specifier: NormalizedStoriesSpecifier, importPath: Path, removed: boolean) { @@ -751,13 +753,14 @@ export class StoryIndexGenerator { } this.lastIndex = null; this.lastError = null; - this.invalidateCallback?.(); + this.invalidationListeners.forEach((listener) => listener()); } - onInvalidated(callback: () => void) { - // TODO add an instance field with a set of callbacks - // TODO: return a function to remove the callback - this.invalidateCallback = callback; + onInvalidated(listener: () => void) { + this.invalidationListeners.add(listener); + return () => { + this.invalidationListeners.delete(listener); + }; } async getPreviewCode() { diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 08d9be50e64f..396b43cb91a2 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -6,6 +6,7 @@ import type { Options as TelejsonOptions } from 'telejson'; import type { PackageJson as PackageJsonFromTypeFest } from 'type-fest'; import type { FileSystemCache } from '../../common/utils/file-cache'; +import type { StoryIndexGenerator } from '../../core-server'; import type { Indexer, StoriesEntry } from './indexer'; /** ⚠️ This file contains internal WIP types they MUST NOT be exported outside this package for now! */ @@ -200,6 +201,7 @@ export interface BuilderOptions { versionCheck?: VersionCheck; disableWebpackDefaults?: boolean; serverChannelUrl?: string; + getStoryIndexGenerator?: () => Promise; } export interface StorybookConfigOptions { From 9dd972a7e3bbbc32d180005e3540d954e75eb8b6 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 20 Feb 2025 09:29:15 +0100 Subject: [PATCH 03/29] fix hmr --- .../src/codegen-importfn-script.ts | 42 +++++-------------- .../src/codegen-modern-iframe-script.ts | 2 +- .../builders/builder-vite/src/list-stories.ts | 35 ---------------- .../builders/builder-vite/src/optimizeDeps.ts | 17 ++++---- .../src/plugins/code-generator-plugin.ts | 18 ++++---- .../builder-vite/src/virtual-file-names.ts | 1 - 6 files changed, 27 insertions(+), 88 deletions(-) delete mode 100644 code/builders/builder-vite/src/list-stories.ts diff --git a/code/builders/builder-vite/src/codegen-importfn-script.ts b/code/builders/builder-vite/src/codegen-importfn-script.ts index fb603b4effc7..4e3519d6adde 100644 --- a/code/builders/builder-vite/src/codegen-importfn-script.ts +++ b/code/builders/builder-vite/src/codegen-importfn-script.ts @@ -1,25 +1,9 @@ import type { StoryIndex } from 'storybook/internal/types'; import { genDynamicImport, genObjectFromRawEntries } from 'knitwork'; -import { join, normalize, relative } from 'pathe'; +import { join, normalize } from 'pathe'; import { dedent } from 'ts-dedent'; -import { listStories } from './list-stories'; - -/** - * This file is largely based on - * https://github.com/storybookjs/storybook/blob/d1195cbd0c61687f1720fefdb772e2f490a46584/lib/core-common/src/utils/to-importFn.ts - */ - -/** - * Paths get passed either with no leading './' - e.g. `src/Foo.stories.js`, or with a leading `../` - * (etc), e.g. `../src/Foo.stories.js`. We want to deal in importPaths relative to the working dir, - * so we normalize - */ -function toImportPath(relativePath: string) { - return relativePath.startsWith('../') ? relativePath : `./${relativePath}`; -} - /** * This function takes an array of stories and creates a mapping between the stories' relative paths * to the working directory and their dynamic imports. The import is done in an asynchronous @@ -28,22 +12,18 @@ function toImportPath(relativePath: string) { * called by Storybook to fetch a story dynamically when needed. */ export async function generateImportFnScriptCode(index: StoryIndex): Promise { - const objectEntries: [string, string][] = Object.values(index.entries).map((entry) => { - if (entry.importPath.startsWith('virtual:')) { - console.log('LOG: virtual entry', entry.importPath); - return [entry.importPath, entry.importPath]; + const uniqueImportPaths = [ + ...new Set(Object.values(index.entries).map((entry) => entry.importPath)), + ]; + + const objectEntries: [string, string][] = uniqueImportPaths.map((importPath) => { + if (importPath.startsWith('virtual:')) { + console.log('LOG: virtual entry', importPath); + return [importPath, importPath]; } - const absolutePath = join(process.cwd(), entry.importPath); - const relativePath = relative(process.cwd(), absolutePath); - console.log('LOG: paths', { - importPath: entry.importPath, - absolutePath, - relativePath, - toImportPathed: toImportPath(relativePath), - cwd: process.cwd(), - }); - return [relativePath, genDynamicImport(normalize(absolutePath))]; + const absolutePath = join(process.cwd(), importPath); + return [importPath, genDynamicImport(normalize(absolutePath))]; }); return dedent` 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 9699545d1c31..0cf6dd149516 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -4,7 +4,7 @@ import type { Options, PreviewAnnotation } from 'storybook/internal/types'; import { dedent } from 'ts-dedent'; import { processPreviewAnnotation } from './utils/process-preview-annotation'; -import { SB_VIRTUAL_FILES, getResolvedVirtualModuleId } from './virtual-file-names'; +import { SB_VIRTUAL_FILES } from './virtual-file-names'; export async function generateModernIframeScriptCode(options: Options, projectRoot: string) { const { presets, configDir } = options; diff --git a/code/builders/builder-vite/src/list-stories.ts b/code/builders/builder-vite/src/list-stories.ts deleted file mode 100644 index d5b417f2553c..000000000000 --- a/code/builders/builder-vite/src/list-stories.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { isAbsolute, join } from 'node:path'; - -import { commonGlobOptions, normalizeStories } from 'storybook/internal/common'; -import type { Options } from 'storybook/internal/types'; - -// eslint-disable-next-line depend/ban-dependencies -import { glob } from 'glob'; -import slash from 'slash'; - -export async function listStories(options: Options) { - const { normalizePath } = await import('vite'); - - return ( - ( - await Promise.all( - normalizeStories(await options.presets.apply('stories', [], options), { - configDir: options.configDir, - workingDir: options.configDir, - }).map(({ directory, files }) => { - const pattern = join(directory, files); - const absolutePattern = isAbsolute(pattern) ? pattern : join(options.configDir, pattern); - - return glob(slash(absolutePattern), { - ...commonGlobOptions(absolutePattern), - follow: true, - }); - }) - ) - ) - .reduce((carry, stories) => carry.concat(stories.map(normalizePath)), []) - // Sort stories to prevent a non-deterministic build. The result of Glob is not sorted an may differ - // for each invocation. This results in a different bundle file hashes from one build to the next. - .sort() - ); -} diff --git a/code/builders/builder-vite/src/optimizeDeps.ts b/code/builders/builder-vite/src/optimizeDeps.ts index 696d523086df..3de591edbaf8 100644 --- a/code/builders/builder-vite/src/optimizeDeps.ts +++ b/code/builders/builder-vite/src/optimizeDeps.ts @@ -1,11 +1,7 @@ -import { relative } from 'node:path'; - import type { Options } from 'storybook/internal/types'; import type { UserConfig, InlineConfig as ViteInlineConfig } from 'vite'; -import { listStories } from './list-stories'; - // It ensures that vite converts cjs deps into esm without vite having to find them during startup and then having to log a message about them and restart // TODO: Many of the deps might be prebundled now though, so probably worth trying to remove and see what happens const INCLUDE_CANDIDATES = [ @@ -169,10 +165,14 @@ const asyncFilter = async (arr: string[], predicate: (val: string) => Promise normalizePath(relative(root, storyPath))); + const index = (await (await options.getStoryIndexGenerator?.())?.getIndex()) ?? { + v: 5, + entries: {}, + }; + + const stories = Object.values(index.entries).map((entry) => entry.importPath); + + const { resolveConfig } = await import('vite'); // TODO: check if resolveConfig takes a lot of time, possible optimizations here const resolvedConfig = await resolveConfig(config, 'serve', 'development'); @@ -186,7 +186,6 @@ export async function getOptimizeDeps(config: ViteInlineConfig, options: Options const optimizeDeps: UserConfig['optimizeDeps'] = { ...config.optimizeDeps, - // We don't need to resolve the glob since vite supports globs for entries. entries: stories, // We need Vite to precompile these dependencies, because they contain non-ESM code that would break // if we served it directly to the browser. diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index 1c2f8ced6f45..513d49db1e35 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -20,18 +20,13 @@ export function codeGeneratorPlugin(options: Options): Plugin { return { name: 'storybook:code-generator-plugin', enforce: 'pre', - async buildStart() { - storyIndexGenerator = await options?.getStoryIndexGenerator?.(); - }, async configureServer(server) { + storyIndexGenerator = await options?.getStoryIndexGenerator?.(); storyIndexGenerator?.onInvalidated(() => { - console.log('LOG: invalidated!'); - const storiesModule = server.moduleGraph.getModuleById( + server.watcher.emit( + 'change', getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE) ); - if (storiesModule) { - server.moduleGraph.invalidateModule(storiesModule); - } }); }, config(config, { command }) { @@ -65,21 +60,22 @@ export function codeGeneratorPlugin(options: Options): Plugin { }, async load(id) { switch (id) { - case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_PREVIEW_FILE): + case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE): return generateImportFnScriptCode( (await storyIndexGenerator?.getIndex()) ?? { v: 5, entries: {} } ); + case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_ADDON_SETUP_FILE): return generateAddonSetupCode(); + case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE): return generateModernIframeScriptCode(options, projectRoot); + case iframeId: return readFileSync( require.resolve('@storybook/builder-vite/input/iframe.html'), 'utf-8' ); - default: - return undefined; } }, async transformIndexHtml(html, ctx) { diff --git a/code/builders/builder-vite/src/virtual-file-names.ts b/code/builders/builder-vite/src/virtual-file-names.ts index 221ac425bbc2..7ee932834f25 100644 --- a/code/builders/builder-vite/src/virtual-file-names.ts +++ b/code/builders/builder-vite/src/virtual-file-names.ts @@ -1,7 +1,6 @@ export const SB_VIRTUAL_FILES = { VIRTUAL_APP_FILE: 'virtual:/@storybook/builder-vite/vite-app.js', VIRTUAL_STORIES_FILE: 'virtual:/@storybook/builder-vite/storybook-stories.js', - VIRTUAL_PREVIEW_FILE: 'virtual:/@storybook/builder-vite/preview-entry.js', VIRTUAL_ADDON_SETUP_FILE: 'virtual:/@storybook/builder-vite/setup-addons.js', }; From 0ffeef0b8a499e58dcf28becbeb37bfccd92106d Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 20 Feb 2025 13:58:54 +0100 Subject: [PATCH 04/29] use StoryIndexGenerator when building too --- .../builder-vite/src/plugins/code-generator-plugin.ts | 5 +++++ code/builders/builder-vite/src/vite-config.ts | 1 - code/core/src/core-server/build-static.ts | 5 ++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index 513d49db1e35..a5698a4deb5b 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -29,6 +29,11 @@ export function codeGeneratorPlugin(options: Options): Plugin { ); }); }, + async buildStart() { + // configureServer is not called in build mode, so we need to initialize the storyIndexGenerator here + // in dev mode it will have been set in configureServer already + storyIndexGenerator ??= await options?.getStoryIndexGenerator?.(); + }, config(config, { command }) { // If we are building the static distribution, add iframe.html as an entry. // In development mode, it's not an entry - instead, we use a middleware diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 8983dfc137b3..957e2a5e2bc3 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -90,7 +90,6 @@ export async function commonConfig( } export async function pluginConfig(options: Options) { - const frameworkName = await getFrameworkName(options); const build = await options.presets.apply('build'); const externals: Record = globalsNameReferenceMap; diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index ab0d979acaf5..c11d507c6964 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -182,7 +182,10 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption previewBuilder .build({ startTime, - options: fullOptions, + options: { + ...fullOptions, + getStoryIndexGenerator: () => initializedStoryIndexGenerator, + }, }) .then(async (previewStats) => { logger.trace({ message: '=> Preview built', time: process.hrtime(startTime) }); From f6ba58accc527fa10eeaccb8d8df15fa03c348dc Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 21 Feb 2025 09:40:54 +0100 Subject: [PATCH 05/29] fix importPath for virtual modules --- .../src/codegen-importfn-script.ts | 2 +- .../core-server/utils/StoryIndexGenerator.ts | 20 ++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-importfn-script.ts b/code/builders/builder-vite/src/codegen-importfn-script.ts index 4e3519d6adde..b99d65f92820 100644 --- a/code/builders/builder-vite/src/codegen-importfn-script.ts +++ b/code/builders/builder-vite/src/codegen-importfn-script.ts @@ -19,7 +19,7 @@ export async function generateImportFnScriptCode(index: StoryIndex): Promise { if (importPath.startsWith('virtual:')) { console.log('LOG: virtual entry', importPath); - return [importPath, importPath]; + return [importPath, genDynamicImport(importPath)]; } const absolutePath = join(process.cwd(), importPath); diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts index db26d03b8174..eeb9f293fd0a 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts @@ -373,6 +373,16 @@ export class StoryIndexGenerator { ]); } + const toImportPath = (path: string | undefined) => { + if (!path) { + return importPath; + } + if (path.startsWith('virtual:')) { + return path; + } + return slash(normalizeStoryPath(relative(this.options.workingDir, path))); + }; + const entries: ((StoryIndexEntryWithExtra | DocsCacheEntry) & { tags: Tag[] })[] = indexInputs.map((input) => { const name = input.name ?? storyNameFromExport(input.exportName); @@ -393,7 +403,7 @@ export class StoryIndexGenerator { }, name, title, - importPath, + importPath: toImportPath(input.importPath), componentPath, tags, }; @@ -408,15 +418,15 @@ export class StoryIndexGenerator { if (createDocEntry && this.options.build?.test?.disableAutoDocs !== true) { const name = this.options.docs.defaultName ?? 'Docs'; const { metaId } = indexInputs[0]; - const { title } = entries[0]; - const id = toId(metaId ?? title, name); + const entry = entries[0]; + const id = toId(metaId ?? entry.title, name); const tags = combineTags(...projectTags, ...(indexInputs[0].tags ?? [])); entries.unshift({ id, - title, + title: entry.title, name, - importPath, + importPath: entry.importPath, type: 'docs', tags, storiesImports: [], From bd4370161aabbc18938c77e5acd079686ff6aab2 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 21 Feb 2025 11:05:18 +0100 Subject: [PATCH 06/29] cleanup --- code/builders/builder-vite/src/codegen-importfn-script.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/code/builders/builder-vite/src/codegen-importfn-script.ts b/code/builders/builder-vite/src/codegen-importfn-script.ts index b99d65f92820..54a264c6c5ac 100644 --- a/code/builders/builder-vite/src/codegen-importfn-script.ts +++ b/code/builders/builder-vite/src/codegen-importfn-script.ts @@ -18,7 +18,6 @@ export async function generateImportFnScriptCode(index: StoryIndex): Promise { if (importPath.startsWith('virtual:')) { - console.log('LOG: virtual entry', importPath); return [importPath, genDynamicImport(importPath)]; } From c0b65de1eecdbc2d2aefd3f0df3d5a953ac637f7 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Mon, 29 Sep 2025 14:42:07 +0200 Subject: [PATCH 07/29] make storyIndexGenerator a preset property --- .../builders/builder-vite/src/optimizeDeps.ts | 12 ++-- .../src/plugins/code-generator-plugin.ts | 14 ++-- code/core/src/core-server/build-static.ts | 37 ++--------- code/core/src/core-server/dev-server.ts | 40 ++++++++---- .../src/core-server/presets/common-preset.ts | 34 +++++++++- .../core/src/core-server/utils/doTelemetry.ts | 65 +++++++++---------- .../utils/getStoryIndexGenerator.ts | 45 ------------- .../src/core-server/utils/stories-json.ts | 13 ++-- code/core/src/types/modules/core-common.ts | 3 +- 9 files changed, 122 insertions(+), 141 deletions(-) delete mode 100644 code/core/src/core-server/utils/getStoryIndexGenerator.ts diff --git a/code/builders/builder-vite/src/optimizeDeps.ts b/code/builders/builder-vite/src/optimizeDeps.ts index 8bc59204eb46..734f30f8e1d3 100644 --- a/code/builders/builder-vite/src/optimizeDeps.ts +++ b/code/builders/builder-vite/src/optimizeDeps.ts @@ -1,3 +1,4 @@ +import type { StoryIndexGenerator } from 'storybook/internal/core-server'; import type { Options, StoryIndex } from 'storybook/internal/types'; import type { UserConfig, InlineConfig as ViteInlineConfig } from 'vite'; @@ -12,14 +13,17 @@ const asyncFilter = async (arr: string[], predicate: (val: string) => Promise arr.filter((_v, index) => results[index])); export async function getOptimizeDeps(config: ViteInlineConfig, options: Options) { - const extraOptimizeDeps = await options.presets.apply('optimizeViteDeps', []); + const [extraOptimizeDeps, storyIndexGenerator] = await Promise.all([ + options.presets.apply('optimizeViteDeps', []), + options.presets.apply('storyIndexGenerator'), + ]); - const index: StoryIndex = (await (await options.getStoryIndexGenerator?.())?.getIndex()) ?? { + const index: StoryIndex = (await storyIndexGenerator.getIndex()) ?? { v: 5, entries: {}, }; - const stories = Object.values(index.entries).map((entry) => entry.importPath); + const storyModulePaths = Object.values(index.entries).map((entry) => entry.importPath); const { resolveConfig } = await import('vite'); // TODO: check if resolveConfig takes a lot of time, possible optimizations here @@ -35,7 +39,7 @@ export async function getOptimizeDeps(config: ViteInlineConfig, options: Options const optimizeDeps: UserConfig['optimizeDeps'] = { ...config.optimizeDeps, - entries: stories, + entries: storyModulePaths, // We need Vite to precompile these dependencies, because they contain non-ESM code that would break // if we served it directly to the browser. include: [...include, ...(config.optimizeDeps?.include || [])], diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index e56f946f5ba5..a283e6883f98 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -17,14 +17,14 @@ export function codeGeneratorPlugin(options: Options): Plugin { const iframePath = fileURLToPath(importMetaResolve('@storybook/builder-vite/input/iframe.html')); let iframeId: string; let projectRoot: string; - let storyIndexGenerator: StoryIndexGenerator | undefined; + let storyIndexGenerator: StoryIndexGenerator; return { name: 'storybook:code-generator-plugin', enforce: 'pre', async configureServer(server) { - storyIndexGenerator = await options?.getStoryIndexGenerator?.(); - storyIndexGenerator?.onInvalidated(() => { + storyIndexGenerator = await options.presets.apply('storyIndexGenerator'); + storyIndexGenerator.onInvalidated(() => { server.watcher.emit( 'change', getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE) @@ -34,7 +34,8 @@ export function codeGeneratorPlugin(options: Options): Plugin { async buildStart() { // configureServer is not called in build mode, so we need to initialize the storyIndexGenerator here // in dev mode it will have been set in configureServer already - storyIndexGenerator ??= await options?.getStoryIndexGenerator?.(); + storyIndexGenerator ??= + await options.presets.apply('storyIndexGenerator'); }, config(config, { command }) { // If we are building the static distribution, add iframe.html as an entry. @@ -68,9 +69,8 @@ export function codeGeneratorPlugin(options: Options): Plugin { async load(id) { switch (id) { case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE): - return generateImportFnScriptCode( - (await storyIndexGenerator?.getIndex()) ?? { v: 5, entries: {} } - ); + const index = (await storyIndexGenerator?.getIndex()) ?? { v: 5, entries: {} }; + return generateImportFnScriptCode(index); case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_ADDON_SETUP_FILE): return generateAddonSetupCode(); diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 89bd52467c67..a26829325d7a 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -6,7 +6,6 @@ import { loadAllPresets, loadMainConfig, logConfig, - normalizeStories, resolveAddonName, } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; @@ -18,7 +17,7 @@ import { global } from '@storybook/global'; import picocolors from 'picocolors'; import { resolvePackageDir } from '../shared/utils/module'; -import { StoryIndexGenerator } from './utils/StoryIndexGenerator'; +import type { StoryIndexGenerator } from './utils/StoryIndexGenerator'; import { buildOrThrow } from './utils/build-or-throw'; import { copyAllStaticFilesRelativeToMain } from './utils/copy-all-static-files'; import { getBuilders } from './utils/get-builders'; @@ -97,13 +96,10 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption build, }); - const [features, core, staticDirs, indexers, stories, docsOptions] = await Promise.all([ + const [features, core, staticDirs] = await Promise.all([ presets.apply('features'), presets.apply('core'), presets.apply('staticDirs'), - presets.apply('experimental_indexers', []), - presets.apply('stories'), - presets.apply('docs'), ]); const invokedBy = process.env.STORYBOOK_INVOKED_BY; @@ -139,29 +135,13 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const coreServerPublicDir = join(resolvePackageDir('storybook'), 'assets/browser'); effects.push(cp(coreServerPublicDir, options.outputDir, { recursive: true })); - let initializedStoryIndexGenerator: Promise = + const initializedStoryIndexGenerator: Promise = Promise.resolve(undefined); if (!options.ignorePreview) { - const workingDir = process.cwd(); - const directories = { - configDir: options.configDir, - workingDir, - }; - const normalizedStories = normalizeStories(stories, directories); - - const generator = new StoryIndexGenerator(normalizedStories, { - ...directories, - indexers, - docs: docsOptions, - build, - }); - - initializedStoryIndexGenerator = generator.initialize().then(() => generator); + const storyIndexGeneratorPromise = presets.apply('storyIndexGenerator'); + effects.push( - extractStoriesJson( - join(options.outputDir, 'index.json'), - initializedStoryIndexGenerator as Promise - ) + extractStoriesJson(join(options.outputDir, 'index.json'), storyIndexGeneratorPromise) ); } @@ -189,10 +169,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption previewBuilder .build({ startTime, - options: { - ...fullOptions, - getStoryIndexGenerator: () => initializedStoryIndexGenerator, - }, + options: fullOptions, }) .then(async (previewStats) => { logger.trace({ message: '=> Preview built', time: process.hrtime(startTime) }); diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 7e46b9c74fdb..729ee81a4795 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -1,4 +1,4 @@ -import { logConfig } from 'storybook/internal/common'; +import { logConfig, normalizeStories } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import { MissingBuilderError } from 'storybook/internal/server-errors'; import type { Options } from 'storybook/internal/types'; @@ -14,12 +14,12 @@ import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders'; import { getCachingMiddleware } from './utils/get-caching-middleware'; import { getServerChannel } from './utils/get-server-channel'; import { getAccessControlMiddleware } from './utils/getAccessControlMiddleware'; -import { getStoryIndexGenerator } from './utils/getStoryIndexGenerator'; import { getMiddleware } from './utils/middleware'; import { openInBrowser } from './utils/open-browser/open-in-browser'; import { getServerAddresses } from './utils/server-address'; import { getServer } from './utils/server-init'; import { useStatics } from './utils/server-statics'; +import { useStoriesJson } from './utils/stories-json'; import { summarizeIndex } from './utils/summarizeIndex'; export async function storybookDevServer(options: Options) { @@ -32,12 +32,28 @@ export async function storybookDevServer(options: Options) { ); let indexError: Error | undefined; - // try get index generator, if failed, send telemetry without storyCount, then rethrow the error - const initializedStoryIndexGenerator: Promise = - getStoryIndexGenerator(app, options, serverChannel).catch((err) => { - indexError = err; - return undefined; - }); + + const storyIndexGeneratorPromise = + options.presets.apply('storyIndexGenerator'); + + const workingDir = process.cwd(); + const configDir = options.configDir; + + const stories = await options.presets.apply('stories'); + + const normalizedStories = normalizeStories(stories, { + configDir, + workingDir, + }); + + useStoriesJson({ + app, + storyIndexGeneratorPromise, + normalizedStories, + serverChannel, + workingDir, + configDir, + }); app.use(compression({ level: 1 })); @@ -95,7 +111,7 @@ export async function storybookDevServer(options: Options) { previewResult = await previewBuilder .start({ startTime: process.hrtime(), - options: { ...options, getStoryIndexGenerator: () => initializedStoryIndexGenerator }, + options, router: app, server, channel: serverChannel, @@ -121,7 +137,7 @@ export async function storybookDevServer(options: Options) { app.listen({ port, host }, resolve); }); - await Promise.all([initializedStoryIndexGenerator, listening]).then(async ([indexGenerator]) => { + await Promise.all([storyIndexGeneratorPromise, listening]).then(async ([indexGenerator]) => { if (indexGenerator && !options.ci && !options.smokeTest && options.open) { const url = host ? networkAddress : address; openInBrowser(options.previewOnly ? `${url}iframe.html?navigator=true` : url).catch(() => { @@ -136,12 +152,12 @@ export async function storybookDevServer(options: Options) { } // Now the preview has successfully started, we can count this as a 'dev' event. - doTelemetry(app, core, initializedStoryIndexGenerator, options); + doTelemetry(app, core, storyIndexGeneratorPromise, options); async function cancelTelemetry() { const payload = { eventType: 'dev' }; try { - const generator = await initializedStoryIndexGenerator; + const generator = await storyIndexGeneratorPromise; const indexAndStats = await generator?.getIndexAndStats(); // compute stats so we can get more accurate story counts if (indexAndStats) { diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index fe09e8647688..812ca5a9402e 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -4,7 +4,7 @@ import { isAbsolute, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Channel } from 'storybook/internal/channels'; -import { optionalEnvToBoolean } from 'storybook/internal/common'; +import { normalizeStories, optionalEnvToBoolean } from 'storybook/internal/common'; import { JsPackageManagerFactory, type RemoveAddonOptions, @@ -33,6 +33,7 @@ import { resolvePackageDir } from '../../shared/utils/module'; import { initCreateNewStoryChannel } from '../server-channel/create-new-story-channel'; import { initFileSearchChannel } from '../server-channel/file-search-channel'; import { initOpenInEditorChannel } from '../server-channel/open-in-editor-channel'; +import { StoryIndexGenerator } from '../utils/StoryIndexGenerator'; import { defaultFavicon, defaultStaticDirs } from '../utils/constants'; import { initializeSaveStory } from '../utils/save-story/save-story'; import { parseStaticDir } from '../utils/server-statics'; @@ -287,6 +288,37 @@ export const managerEntries = async (existing: any) => { ]; }; +let initializedStoryIndexGenerator: StoryIndexGenerator | undefined = undefined; +export const storyIndexGenerator: PresetPropertyFn<'storyIndexGenerator'> = async (_, options) => { + if (initializedStoryIndexGenerator) { + return initializedStoryIndexGenerator; + } + + const workingDir = process.cwd(); + const configDir = options.configDir; + + const [stories, indexers, docs] = await Promise.all([ + options.presets.apply('stories'), + options.presets.apply('experimental_indexers', []), + options.presets.apply('docs'), + ]); + + const normalizedStories = normalizeStories(stories, { + configDir, + workingDir, + }); + + const generator = new StoryIndexGenerator(normalizedStories, { + workingDir, + configDir, + indexers, + docs, + }); + await generator.initialize(); + initializedStoryIndexGenerator = generator; + return initializedStoryIndexGenerator; +}; + export const viteFinal = async ( existing: import('vite').UserConfig, options: Options diff --git a/code/core/src/core-server/utils/doTelemetry.ts b/code/core/src/core-server/utils/doTelemetry.ts index 607ed645c358..f2ac5b227d44 100644 --- a/code/core/src/core-server/utils/doTelemetry.ts +++ b/code/core/src/core-server/utils/doTelemetry.ts @@ -13,43 +13,42 @@ import { versionStatus } from './versionStatus'; export async function doTelemetry( app: Polka, core: CoreConfig, - initializedStoryIndexGenerator: Promise, + storyIndexGeneratorPromise: Promise, options: Options ) { if (!core?.disableTelemetry) { - initializedStoryIndexGenerator.then(async (generator) => { - let indexAndStats; - try { - indexAndStats = await generator?.getIndexAndStats(); - } catch (err) { - // If we fail to get the index, treat it as a recoverable error, but send it up to telemetry - // as if we crashed. In the future we will revisit this to send a distinct error - if (!(err instanceof Error)) { - throw new Error('encountered a non-recoverable error'); - } - sendTelemetryError(err, 'dev', { - cliOptions: options, - presetOptions: { ...options, corePresets: [], overridePresets: [] }, - }); - return; + const generator = await storyIndexGeneratorPromise; + let indexAndStats; + try { + indexAndStats = await generator?.getIndexAndStats(); + } catch (err) { + // If we fail to get the index, treat it as a recoverable error, but send it up to telemetry + // as if we crashed. In the future we will revisit this to send a distinct error + if (!(err instanceof Error)) { + throw new Error('encountered a non-recoverable error'); } - const { versionCheck, versionUpdates } = options; - invariant( - !versionUpdates || (versionUpdates && versionCheck), - 'versionCheck should be defined when versionUpdates is true' - ); - const payload = { - precedingUpgrade: await getPrecedingUpgrade(), - }; - if (indexAndStats) { - Object.assign(payload, { - versionStatus: versionUpdates && versionCheck ? versionStatus(versionCheck) : 'disabled', - storyIndex: summarizeIndex(indexAndStats.storyIndex), - storyStats: indexAndStats.stats, - }); - } - telemetry('dev', payload, { configDir: options.configDir }); - }); + sendTelemetryError(err, 'dev', { + cliOptions: options, + presetOptions: { ...options, corePresets: [], overridePresets: [] }, + }); + return; + } + const { versionCheck, versionUpdates } = options; + invariant( + !versionUpdates || (versionUpdates && versionCheck), + 'versionCheck should be defined when versionUpdates is true' + ); + const payload = { + precedingUpgrade: await getPrecedingUpgrade(), + }; + if (indexAndStats) { + Object.assign(payload, { + versionStatus: versionUpdates && versionCheck ? versionStatus(versionCheck) : 'disabled', + storyIndex: summarizeIndex(indexAndStats.storyIndex), + storyStats: indexAndStats.stats, + }); + } + telemetry('dev', payload, { configDir: options.configDir }); } if (!core?.disableProjectJson) { diff --git a/code/core/src/core-server/utils/getStoryIndexGenerator.ts b/code/core/src/core-server/utils/getStoryIndexGenerator.ts deleted file mode 100644 index c02cacb1ad6a..000000000000 --- a/code/core/src/core-server/utils/getStoryIndexGenerator.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { normalizeStories } from 'storybook/internal/common'; -import type { Options } from 'storybook/internal/types'; - -import type { Polka } from 'polka'; - -import { StoryIndexGenerator } from './StoryIndexGenerator'; -import type { ServerChannel } from './get-server-channel'; -import { useStoriesJson } from './stories-json'; - -export async function getStoryIndexGenerator( - app: Polka, - options: Options, - serverChannel: ServerChannel -): Promise { - const workingDir = process.cwd(); - const configDir = options.configDir; - const directories = { - configDir, - workingDir, - }; - const stories = options.presets.apply('stories'); - const indexers = options.presets.apply('experimental_indexers', []); - const docsOptions = options.presets.apply('docs'); - const normalizedStories = normalizeStories(await stories, directories); - - const generator = new StoryIndexGenerator(normalizedStories, { - ...directories, - indexers: await indexers, - docs: await docsOptions, - workingDir, - }); - - const initializedStoryIndexGenerator = generator.initialize().then(() => generator); - - useStoriesJson({ - app, - initializedStoryIndexGenerator, - normalizedStories, - serverChannel, - workingDir, - configDir, - }); - - return initializedStoryIndexGenerator; -} diff --git a/code/core/src/core-server/utils/stories-json.ts b/code/core/src/core-server/utils/stories-json.ts index 029e935f77f7..afaeb23e63c7 100644 --- a/code/core/src/core-server/utils/stories-json.ts +++ b/code/core/src/core-server/utils/stories-json.ts @@ -26,14 +26,14 @@ export async function extractStoriesJson( export function useStoriesJson({ app, - initializedStoryIndexGenerator, + storyIndexGeneratorPromise, workingDir = process.cwd(), configDir, serverChannel, normalizedStories, }: { app: Polka; - initializedStoryIndexGenerator: Promise; + storyIndexGeneratorPromise: Promise; serverChannel: ServerChannel; workingDir?: string; configDir?: string; @@ -43,15 +43,13 @@ export function useStoriesJson({ leading: true, }); watchStorySpecifiers(normalizedStories, { workingDir }, async (specifier, path, removed) => { - const generator = await initializedStoryIndexGenerator; - generator.invalidate(specifier, path, removed); + (await storyIndexGeneratorPromise).invalidate(specifier, path, removed); maybeInvalidate(); }); if (configDir) { watchConfig(configDir, async (filePath) => { if (basename(filePath).startsWith('preview')) { - const generator = await initializedStoryIndexGenerator; - generator.invalidateAll(); + (await storyIndexGeneratorPromise).invalidateAll(); maybeInvalidate(); } }); @@ -59,8 +57,7 @@ export function useStoriesJson({ app.use('/index.json', async (req, res) => { try { - const generator = await initializedStoryIndexGenerator; - const index = await generator.getIndex(); + const index = await (await storyIndexGeneratorPromise).getIndex(); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(index)); } catch (err) { diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index a816d01c0f64..1e9837a9f5ee 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -205,7 +205,6 @@ export interface BuilderOptions { versionCheck?: VersionCheck; disableWebpackDefaults?: boolean; serverChannelUrl?: string; - getStoryIndexGenerator?: () => Promise; networkAddress?: string; } @@ -481,6 +480,8 @@ export interface StorybookConfigRaw { experimental_indexers?: Indexer[]; + storyIndexGenerator?: StoryIndexGenerator; + docs?: DocsOptions; previewHead?: string; From 567fe818a6ef2d0f2a4721203dd23ff3c168ddb3 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Mon, 29 Sep 2025 14:49:54 +0200 Subject: [PATCH 08/29] make importPath optional --- code/core/src/csf-tools/CsfFile.ts | 1 - code/core/src/types/modules/indexer.ts | 4 ++-- docs/api/main-config/main-config-indexers.mdx | 6 +++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index 213bcf2fd53e..e274ccd382b0 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -956,7 +956,6 @@ export class CsfFile { // don't remove any duplicates or negations -- tags will be combined in the index const tags = [...(this._meta?.tags ?? []), ...(story.tags ?? [])]; const storyInput = { - importPath: fileName, rawComponentPath: this._rawComponentPath, exportName, title: this.meta?.title, diff --git a/code/core/src/types/modules/indexer.ts b/code/core/src/types/modules/indexer.ts index ff43c215d34e..908cee3d60a7 100644 --- a/code/core/src/types/modules/indexer.ts +++ b/code/core/src/types/modules/indexer.ts @@ -101,8 +101,8 @@ export interface IndexInputStats { /** The base input for indexing a story or docs entry. */ export type BaseIndexInput = { - /** The file to import from e.g. the story file. */ - importPath: Path; + /** The file to import from e.g. the story file. Defaults to the fileName arg passed to createIndex */ + importPath?: Path; /** The raw path/package of the file that provides meta.component, if one exists */ rawComponentPath?: Path; /** The name of the export to import. */ diff --git a/docs/api/main-config/main-config-indexers.mdx b/docs/api/main-config/main-config-indexers.mdx index 06b5fdef8de5..6b0320b63496 100644 --- a/docs/api/main-config/main-config-indexers.mdx +++ b/docs/api/main-config/main-config-indexers.mdx @@ -114,10 +114,10 @@ For each `IndexInput`, the indexer will add this export (from the file found at ##### `importPath` -(Required) - Type: `string` +Default: The original [`fileName`](#filename) passed to the `createIndex` function + The file to import from, e.g. the [CSF](../csf.mdx) file. It is likely that the [`fileName`](#filename) being indexed is not CSF, in which you will need to [transpile it to CSF](#transpiling-to-csf) so that Storybook can read it in the browser. @@ -487,4 +487,4 @@ Some example usages of custom indexers include: This example's code and live demo are available on [StackBlitz](https://stackblitz.com/~/github.com/Sidnioulz/storybook-sidebar-urls). - \ No newline at end of file + From beb26c15ef3e06473ff19a912aae32346d823073 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Mon, 29 Sep 2025 16:32:29 +0200 Subject: [PATCH 09/29] rename storiesJson -> indexJson --- code/core/src/core-server/build-static.ts | 6 ++-- code/core/src/core-server/dev-server.ts | 4 +-- ...tories-json.test.ts => index-json.test.ts} | 31 +++++++++---------- .../utils/{stories-json.ts => index-json.ts} | 11 +++---- 4 files changed, 24 insertions(+), 28 deletions(-) rename code/core/src/core-server/utils/{stories-json.test.ts => index-json.test.ts} (96%) rename code/core/src/core-server/utils/{stories-json.ts => index-json.ts} (83%) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index a26829325d7a..14f55c888c3e 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -21,9 +21,9 @@ import type { StoryIndexGenerator } from './utils/StoryIndexGenerator'; import { buildOrThrow } from './utils/build-or-throw'; import { copyAllStaticFilesRelativeToMain } from './utils/copy-all-static-files'; import { getBuilders } from './utils/get-builders'; +import { writeIndexJson } from './utils/index-json'; import { extractStorybookMetadata } from './utils/metadata'; import { outputStats } from './utils/output-stats'; -import { extractStoriesJson } from './utils/stories-json'; import { summarizeIndex } from './utils/summarizeIndex'; export type BuildStaticStandaloneOptions = CLIOptions & @@ -140,9 +140,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption if (!options.ignorePreview) { const storyIndexGeneratorPromise = presets.apply('storyIndexGenerator'); - effects.push( - extractStoriesJson(join(options.outputDir, 'index.json'), storyIndexGeneratorPromise) - ); + effects.push(writeIndexJson(join(options.outputDir, 'index.json'), storyIndexGeneratorPromise)); } if (!core?.disableProjectJson) { diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 729ee81a4795..5633243c8eb4 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -14,12 +14,12 @@ import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders'; import { getCachingMiddleware } from './utils/get-caching-middleware'; import { getServerChannel } from './utils/get-server-channel'; import { getAccessControlMiddleware } from './utils/getAccessControlMiddleware'; +import { registerIndexJsonRoute } from './utils/index-json'; import { getMiddleware } from './utils/middleware'; import { openInBrowser } from './utils/open-browser/open-in-browser'; import { getServerAddresses } from './utils/server-address'; import { getServer } from './utils/server-init'; import { useStatics } from './utils/server-statics'; -import { useStoriesJson } from './utils/stories-json'; import { summarizeIndex } from './utils/summarizeIndex'; export async function storybookDevServer(options: Options) { @@ -46,7 +46,7 @@ export async function storybookDevServer(options: Options) { workingDir, }); - useStoriesJson({ + registerIndexJsonRoute({ app, storyIndexGeneratorPromise, normalizedStories, diff --git a/code/core/src/core-server/utils/stories-json.test.ts b/code/core/src/core-server/utils/index-json.test.ts similarity index 96% rename from code/core/src/core-server/utils/stories-json.test.ts rename to code/core/src/core-server/utils/index-json.test.ts index 725f6de19aa1..2a4ea0d4a48d 100644 --- a/code/core/src/core-server/utils/stories-json.test.ts +++ b/code/core/src/core-server/utils/index-json.test.ts @@ -13,7 +13,7 @@ import { csfIndexer } from '../presets/common-preset'; import type { StoryIndexGeneratorOptions } from './StoryIndexGenerator'; import { StoryIndexGenerator } from './StoryIndexGenerator'; import type { ServerChannel } from './get-server-channel'; -import { DEBOUNCE, useStoriesJson } from './stories-json'; +import { DEBOUNCE, registerIndexJsonRoute } from './index-json'; vi.mock('watchpack'); vi.mock('es-toolkit/compat'); @@ -46,7 +46,7 @@ const normalizedStories = [ ), ]; -const getInitializedStoryIndexGenerator = async ( +const getStoryIndexGeneratorPromise = async ( overrides: any = {}, inputNormalizedStories = normalizedStories ) => { @@ -62,7 +62,7 @@ const getInitializedStoryIndexGenerator = async ( return generator; }; -describe('useStoriesJson', () => { +describe('registerIndexJsonRoute', () => { const use = vi.fn(); const app: Polka = { use } as any; const end = vi.fn(); @@ -94,15 +94,15 @@ describe('useStoriesJson', () => { describe('JSON endpoint', () => { it('scans and extracts index', async () => { const mockServerChannel = { emit: vi.fn() } as any as ServerChannel; - console.time('useStoriesJson'); - useStoriesJson({ + console.time('registerIndexJsonRoute'); + registerIndexJsonRoute({ app, serverChannel: mockServerChannel, workingDir, normalizedStories, - initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(), + storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), }); - console.timeEnd('useStoriesJson'); + console.timeEnd('registerIndexJsonRoute'); expect(use).toHaveBeenCalledTimes(1); const route = use.mock.calls[0][1]; @@ -471,12 +471,12 @@ describe('useStoriesJson', () => { it('can handle simultaneous access', async () => { const mockServerChannel = { emit: vi.fn() } as any as ServerChannel; - useStoriesJson({ + registerIndexJsonRoute({ app, serverChannel: mockServerChannel, workingDir, normalizedStories, - initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(), + storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), }); expect(use).toHaveBeenCalledTimes(1); @@ -487,7 +487,6 @@ describe('useStoriesJson', () => { const secondPromise = route(request, secondResponse); await Promise.all([firstPromise, secondPromise]); - expect(end).toHaveBeenCalledTimes(1); expect(response.statusCode).not.toEqual(500); expect(secondResponse.end).toHaveBeenCalledTimes(1); @@ -503,12 +502,12 @@ describe('useStoriesJson', () => { it('sends invalidate events', async () => { const mockServerChannel = { emit: vi.fn() } as any as ServerChannel; - useStoriesJson({ + registerIndexJsonRoute({ app, serverChannel: mockServerChannel, workingDir, normalizedStories, - initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(), + storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), }); expect(use).toHaveBeenCalledTimes(1); @@ -537,12 +536,12 @@ describe('useStoriesJson', () => { it('only sends one invalidation when multiple event listeners are listening', async () => { const mockServerChannel = { emit: vi.fn() } as any as ServerChannel; - useStoriesJson({ + registerIndexJsonRoute({ app, serverChannel: mockServerChannel, workingDir, normalizedStories, - initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(), + storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), }); expect(use).toHaveBeenCalledTimes(1); @@ -579,12 +578,12 @@ describe('useStoriesJson', () => { ); const mockServerChannel = { emit: vi.fn() } as any as ServerChannel; - useStoriesJson({ + registerIndexJsonRoute({ app, serverChannel: mockServerChannel, workingDir, normalizedStories, - initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(), + storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), }); expect(use).toHaveBeenCalledTimes(1); diff --git a/code/core/src/core-server/utils/stories-json.ts b/code/core/src/core-server/utils/index-json.ts similarity index 83% rename from code/core/src/core-server/utils/stories-json.ts rename to code/core/src/core-server/utils/index-json.ts index afaeb23e63c7..475599f6a0b8 100644 --- a/code/core/src/core-server/utils/stories-json.ts +++ b/code/core/src/core-server/utils/index-json.ts @@ -2,7 +2,7 @@ import { writeFile } from 'node:fs/promises'; import { basename } from 'node:path'; import { STORY_INDEX_INVALIDATED } from 'storybook/internal/core-events'; -import type { NormalizedStoriesSpecifier, StoryIndex } from 'storybook/internal/types'; +import type { NormalizedStoriesSpecifier } from 'storybook/internal/types'; import { debounce } from 'es-toolkit/compat'; import type { Polka } from 'polka'; @@ -14,17 +14,16 @@ import { watchConfig } from './watchConfig'; export const DEBOUNCE = 100; -export async function extractStoriesJson( +export async function writeIndexJson( outputFile: string, - initializedStoryIndexGenerator: Promise, - transform?: (index: StoryIndex) => any + initializedStoryIndexGenerator: Promise ) { const generator = await initializedStoryIndexGenerator; const storyIndex = await generator.getIndex(); - await writeFile(outputFile, JSON.stringify(transform ? transform(storyIndex) : storyIndex)); + await writeFile(outputFile, JSON.stringify(storyIndex)); } -export function useStoriesJson({ +export function registerIndexJsonRoute({ app, storyIndexGeneratorPromise, workingDir = process.cwd(), From 1e7bf42c74621db0c5b79608196e496617c7cbab Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Mon, 29 Sep 2025 21:59:31 +0200 Subject: [PATCH 10/29] improve importfn paths, tests --- code/.eslintrc.js | 12 --- .../src/codegen-importfn-script.test.ts | 74 ++++++++++++++----- .../src/codegen-importfn-script.ts | 4 +- .../builders/builder-vite/src/optimizeDeps.ts | 3 +- code/core/src/csf-tools/CsfFile.test.ts | 24 ++---- docs/api/main-config/main-config-indexers.mdx | 2 + 6 files changed, 70 insertions(+), 49 deletions(-) diff --git a/code/.eslintrc.js b/code/.eslintrc.js index 1c00177e9073..e1ff964d21b1 100644 --- a/code/.eslintrc.js +++ b/code/.eslintrc.js @@ -29,18 +29,6 @@ module.exports = { allowIndexSignaturePropertyAccess: true, }, ], - '@typescript-eslint/no-restricted-imports': [ - 'error', - { - paths: [ - { - name: 'vite', - message: 'Please dynamically import from vite instead, to force the use of ESM', - allowTypeImports: true, - }, - ], - }, - ], '@typescript-eslint/default-param-last': 'off', }, overrides: [ diff --git a/code/builders/builder-vite/src/codegen-importfn-script.test.ts b/code/builders/builder-vite/src/codegen-importfn-script.test.ts index da0e15c2154d..c839563f1aac 100644 --- a/code/builders/builder-vite/src/codegen-importfn-script.test.ts +++ b/code/builders/builder-vite/src/codegen-importfn-script.test.ts @@ -1,19 +1,41 @@ import { describe, expect, it, vi } from 'vitest'; -import { toImportFn } from './codegen-importfn-script'; +import type { StoryIndex } from 'storybook/internal/types'; -describe('toImportFn', () => { - it('should correctly map story paths to import functions for absolute paths on Linux', async () => { +import { generateImportFnScriptCode } from './codegen-importfn-script'; + +describe('generateImportFnScriptCode', () => { + it('should correctly map story paths to import functions for POSIX paths', async () => { vi.spyOn(process, 'cwd').mockReturnValue('/absolute/path'); - const stories = ['/absolute/path/to/story1.js', '/absolute/path/to/story2.js']; + const index: StoryIndex = { + v: 5, + entries: { + 'path-to-story': { + id: 'path-to-story', + title: 'Path to Story', + name: 'Default', + importPath: './to/abs-story.js', + type: 'story', + subtype: 'story', + }, + 'virtual-story': { + id: 'virtual-story', + title: 'Virtual Story', + name: 'Default', + importPath: 'virtual:story.js', + type: 'story', + subtype: 'story', + }, + }, + }; - const result = await toImportFn(stories); + const result = generateImportFnScriptCode(index); expect(result).toMatchInlineSnapshot(` "const importers = { - "./to/story1.js": () => import("/absolute/path/to/story1.js"), - "./to/story2.js": () => import("/absolute/path/to/story2.js") + "to/abs-story.js": () => import("/absolute/path/to/abs-story.js"), + "virtual:story.js": () => import("virtual:story.js") }; export async function importFn(path) { @@ -22,16 +44,37 @@ describe('toImportFn', () => { `); }); - it('should correctly map story paths to import functions for absolute paths on Windows', async () => { + it('should correctly map story paths to import functions for Windows paths', async () => { vi.spyOn(process, 'cwd').mockReturnValue('C:\\absolute\\path'); - const stories = ['C:\\absolute\\path\\to\\story1.js', 'C:\\absolute\\path\\to\\story2.js']; - const result = await toImportFn(stories); + const index: StoryIndex = { + v: 5, + entries: { + 'abs-path-to-story': { + id: 'abs-path-to-story', + title: 'Absolute Path to Story', + name: 'Default', + importPath: 'to\\abs-story.js', + type: 'story', + subtype: 'story', + }, + 'virtual-story': { + id: 'virtual-story', + title: 'Virtual Story', + name: 'Default', + importPath: 'virtual:story.js', + type: 'story', + subtype: 'story', + }, + }, + }; + + const result = generateImportFnScriptCode(index); expect(result).toMatchInlineSnapshot(` "const importers = { - "./to/story1.js": () => import("C:/absolute/path/to/story1.js"), - "./to/story2.js": () => import("C:/absolute/path/to/story2.js") + "to/abs-story.js": () => import("C:/absolute/path/to/abs-story.js"), + "virtual:story.js": () => import("virtual:story.js") }; export async function importFn(path) { @@ -40,11 +83,8 @@ describe('toImportFn', () => { `); }); - it('should handle an empty array of stories', async () => { - const root = '/absolute/path'; - const stories: string[] = []; - - const result = await toImportFn(stories); + it('should handle an empty index', async () => { + const result = generateImportFnScriptCode({ v: 5, entries: {} }); expect(result).toMatchInlineSnapshot(` "const importers = {}; diff --git a/code/builders/builder-vite/src/codegen-importfn-script.ts b/code/builders/builder-vite/src/codegen-importfn-script.ts index 54a264c6c5ac..8cfafd5fb0d6 100644 --- a/code/builders/builder-vite/src/codegen-importfn-script.ts +++ b/code/builders/builder-vite/src/codegen-importfn-script.ts @@ -11,7 +11,7 @@ import { dedent } from 'ts-dedent'; * creates a function, `importFn(path)`, which resolves a path to an import function and this is * called by Storybook to fetch a story dynamically when needed. */ -export async function generateImportFnScriptCode(index: StoryIndex): Promise { +export function generateImportFnScriptCode(index: StoryIndex): string { const uniqueImportPaths = [ ...new Set(Object.values(index.entries).map((entry) => entry.importPath)), ]; @@ -22,7 +22,7 @@ export async function generateImportFnScriptCode(index: StoryIndex): Promise entry.importPath); - const { resolveConfig } = await import('vite'); // TODO: check if resolveConfig takes a lot of time, possible optimizations here const resolvedConfig = await resolveConfig(config, 'serve', 'development'); diff --git a/code/core/src/csf-tools/CsfFile.test.ts b/code/core/src/csf-tools/CsfFile.test.ts index 17487fea5750..831b8945975f 100644 --- a/code/core/src/csf-tools/CsfFile.test.ts +++ b/code/core/src/csf-tools/CsfFile.test.ts @@ -1981,8 +1981,7 @@ describe('CsfFile', () => { ).parse(); expect(indexInputs).toMatchInlineSnapshot(` - - importPath: foo/bar.stories.js - exportName: A + - exportName: A title: custom foo title metaId: component-id tags: @@ -2004,8 +2003,7 @@ describe('CsfFile', () => { type: story subtype: story name: A - - importPath: foo/bar.stories.js - exportName: B + - exportName: B title: custom foo title metaId: component-id tags: @@ -2047,8 +2045,7 @@ describe('CsfFile', () => { ).parse(); expect(indexInputs).toMatchInlineSnapshot(` - - importPath: foo/bar.stories.js - exportName: A + - exportName: A title: custom foo title metaId: component-id tags: @@ -2087,8 +2084,7 @@ describe('CsfFile', () => { ).parse(); expect(indexInputs).toMatchInlineSnapshot(` - - importPath: foo/bar.stories.js - exportName: A + - exportName: A title: custom foo title tags: - component-tag @@ -2156,8 +2152,7 @@ describe('CsfFile', () => { ).parse(); expect(indexInputs).toMatchInlineSnapshot(` - - importPath: foo/bar.stories.js - exportName: A + - exportName: A title: custom foo title tags: [] __id: custom-foo-title--a @@ -2196,8 +2191,7 @@ describe('CsfFile', () => { ).parse(); expect(indexInputs).toMatchInlineSnapshot(` - - importPath: foo/bar.stories.js - exportName: A + - exportName: A title: custom foo title tags: [] __id: custom-foo-title--a @@ -2235,8 +2229,7 @@ describe('CsfFile', () => { ).parse(); expect(indexInputs).toMatchInlineSnapshot(` - - importPath: foo/bar.stories.js - rawComponentPath: ../src/Component.js + - rawComponentPath: ../src/Component.js exportName: A title: custom foo title tags: [] @@ -2275,8 +2268,7 @@ describe('CsfFile', () => { ).parse(); expect(indexInputs).toMatchInlineSnapshot(` - - importPath: foo/bar.stories.js - rawComponentPath: some-library + - rawComponentPath: some-library exportName: A title: custom foo title tags: [] diff --git a/docs/api/main-config/main-config-indexers.mdx b/docs/api/main-config/main-config-indexers.mdx index 6b0320b63496..fd5c1c85b86c 100644 --- a/docs/api/main-config/main-config-indexers.mdx +++ b/docs/api/main-config/main-config-indexers.mdx @@ -122,6 +122,8 @@ The file to import from, e.g. the [CSF](../csf.mdx) file. It is likely that the [`fileName`](#filename) being indexed is not CSF, in which you will need to [transpile it to CSF](#transpiling-to-csf) so that Storybook can read it in the browser. +⚠️ Custom `importPath`s are only supported in Vite-based projects. To use them in Webpack-based projects, you will need to [transpile the source file to CSF](#transpiling-to-csf) and leave `importPath` empty to use the original `fileName`. + ##### `type` (Required) From c2528fee8e1c6940c3f2fb7c2708e43147fba81e Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 30 Sep 2025 15:28:49 +0200 Subject: [PATCH 11/29] fix invalid import paths --- .../src/codegen-importfn-script.test.ts | 4 ++-- .../builder-vite/src/codegen-importfn-script.ts | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-importfn-script.test.ts b/code/builders/builder-vite/src/codegen-importfn-script.test.ts index c839563f1aac..35edde4662e6 100644 --- a/code/builders/builder-vite/src/codegen-importfn-script.test.ts +++ b/code/builders/builder-vite/src/codegen-importfn-script.test.ts @@ -34,7 +34,7 @@ describe('generateImportFnScriptCode', () => { expect(result).toMatchInlineSnapshot(` "const importers = { - "to/abs-story.js": () => import("/absolute/path/to/abs-story.js"), + "./to/abs-story.js": () => import("/absolute/path/to/abs-story.js"), "virtual:story.js": () => import("virtual:story.js") }; @@ -73,7 +73,7 @@ describe('generateImportFnScriptCode', () => { expect(result).toMatchInlineSnapshot(` "const importers = { - "to/abs-story.js": () => import("C:/absolute/path/to/abs-story.js"), + "./to/abs-story.js": () => import("C:/absolute/path/to/abs-story.js"), "virtual:story.js": () => import("virtual:story.js") }; diff --git a/code/builders/builder-vite/src/codegen-importfn-script.ts b/code/builders/builder-vite/src/codegen-importfn-script.ts index 8cfafd5fb0d6..e44d98036d3c 100644 --- a/code/builders/builder-vite/src/codegen-importfn-script.ts +++ b/code/builders/builder-vite/src/codegen-importfn-script.ts @@ -1,7 +1,7 @@ import type { StoryIndex } from 'storybook/internal/types'; import { genDynamicImport, genObjectFromRawEntries } from 'knitwork'; -import { join, normalize } from 'pathe'; +import { join, normalize, relative } from 'pathe'; import { dedent } from 'ts-dedent'; /** @@ -21,8 +21,19 @@ export function generateImportFnScriptCode(index: StoryIndex): string { return [importPath, genDynamicImport(importPath)]; } - const absolutePath = join(process.cwd(), importPath); - return [normalize(importPath), genDynamicImport(normalize(absolutePath))]; + /** + * Relative paths get passed either with no leading './' - e.g. `src/Foo.stories.js`, or with a + * leading `../` (etc), e.g. `../src/Foo.stories.js`. We want to deal in importPaths relative to + * the working dir, so we normalize + */ + const relativePath = normalize(relative(process.cwd(), importPath)); + const normalizedRelativePath = relativePath.startsWith('../') + ? relativePath + : `./${relativePath}`; + + const absolutePath = normalize(join(process.cwd(), importPath)); + + return [normalizedRelativePath, genDynamicImport(absolutePath)]; }); return dedent` From 13f9a62158b9627ae9f6ce05f316f530c6ecc3fe Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 1 Oct 2025 15:36:12 +0200 Subject: [PATCH 12/29] cleanup --- .../src/plugins/code-generator-plugin.ts | 13 ++++--------- code/core/src/core-server/build-static.ts | 11 ++++++++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index a283e6883f98..1580d6f1e839 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -17,26 +17,20 @@ export function codeGeneratorPlugin(options: Options): Plugin { const iframePath = fileURLToPath(importMetaResolve('@storybook/builder-vite/input/iframe.html')); let iframeId: string; let projectRoot: string; - let storyIndexGenerator: StoryIndexGenerator; + const storyIndexGeneratorPromise: Promise = + options.presets.apply('storyIndexGenerator'); return { name: 'storybook:code-generator-plugin', enforce: 'pre', async configureServer(server) { - storyIndexGenerator = await options.presets.apply('storyIndexGenerator'); - storyIndexGenerator.onInvalidated(() => { + (await storyIndexGeneratorPromise).onInvalidated(() => { server.watcher.emit( 'change', getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE) ); }); }, - async buildStart() { - // configureServer is not called in build mode, so we need to initialize the storyIndexGenerator here - // in dev mode it will have been set in configureServer already - storyIndexGenerator ??= - await options.presets.apply('storyIndexGenerator'); - }, config(config, { command }) { // If we are building the static distribution, add iframe.html as an entry. // In development mode, it's not an entry - instead, we use a middleware @@ -69,6 +63,7 @@ export function codeGeneratorPlugin(options: Options): Plugin { async load(id) { switch (id) { case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE): + const storyIndexGenerator = await storyIndexGeneratorPromise; const index = (await storyIndexGenerator?.getIndex()) ?? { v: 5, entries: {} }; return generateImportFnScriptCode(index); diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 14f55c888c3e..1008ee53df69 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -135,12 +135,17 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const coreServerPublicDir = join(resolvePackageDir('storybook'), 'assets/browser'); effects.push(cp(coreServerPublicDir, options.outputDir, { recursive: true })); - const initializedStoryIndexGenerator: Promise = + let initializedStoryIndexGenerator: Promise = Promise.resolve(undefined); if (!options.ignorePreview) { - const storyIndexGeneratorPromise = presets.apply('storyIndexGenerator'); + initializedStoryIndexGenerator = presets.apply('storyIndexGenerator'); - effects.push(writeIndexJson(join(options.outputDir, 'index.json'), storyIndexGeneratorPromise)); + effects.push( + writeIndexJson( + join(options.outputDir, 'index.json'), + initializedStoryIndexGenerator as Promise + ) + ); } if (!core?.disableProjectJson) { From 8b9df0a2549cd56a221ebb0812db3996301b0f79 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 1 Oct 2025 15:56:01 +0200 Subject: [PATCH 13/29] silent weird ts error --- code/core/src/csf/csf-factories.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/code/core/src/csf/csf-factories.ts b/code/core/src/csf/csf-factories.ts index 22af4a85f64c..99e0629d013f 100644 --- a/code/core/src/csf/csf-factories.ts +++ b/code/core/src/csf/csf-factories.ts @@ -203,6 +203,7 @@ function defineStory< const testFunction = typeof overridesOrTestFn !== 'function' ? testFn! : overridesOrTestFn; const play = + //@ts-expect-error don't know why the testFunction type is failing mountDestructured(this.play) || mountDestructured(testFunction) ? async ({ context }: StoryContext) => { await this.play?.(context); From 137980dae739e25e50ef782d402b8c7604daf72e Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 2 Oct 2025 09:26:13 +0200 Subject: [PATCH 14/29] fix checks --- code/builders/builder-vite/src/vite-config.test.ts | 1 - code/core/src/core-server/presets/common-preset.ts | 2 +- code/core/src/csf/csf-factories.ts | 1 - code/core/src/types/modules/core-common.ts | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/code/builders/builder-vite/src/vite-config.test.ts b/code/builders/builder-vite/src/vite-config.test.ts index 973746cfbbe6..3097c069431a 100644 --- a/code/builders/builder-vite/src/vite-config.test.ts +++ b/code/builders/builder-vite/src/vite-config.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { Options, Presets } from 'storybook/internal/types'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports import { loadConfigFromFile } from 'vite'; import { commonConfig } from './vite-config'; diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 812ca5a9402e..b7399350cd63 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -15,6 +15,7 @@ import { loadEnvs, removeAddon as removeAddonBase, } from 'storybook/internal/common'; +import { StoryIndexGenerator } from 'storybook/internal/core-server'; import { readCsf } from 'storybook/internal/csf-tools'; import { logger } from 'storybook/internal/node-logger'; import { telemetry } from 'storybook/internal/telemetry'; @@ -33,7 +34,6 @@ import { resolvePackageDir } from '../../shared/utils/module'; import { initCreateNewStoryChannel } from '../server-channel/create-new-story-channel'; import { initFileSearchChannel } from '../server-channel/file-search-channel'; import { initOpenInEditorChannel } from '../server-channel/open-in-editor-channel'; -import { StoryIndexGenerator } from '../utils/StoryIndexGenerator'; import { defaultFavicon, defaultStaticDirs } from '../utils/constants'; import { initializeSaveStory } from '../utils/save-story/save-story'; import { parseStaticDir } from '../utils/server-statics'; diff --git a/code/core/src/csf/csf-factories.ts b/code/core/src/csf/csf-factories.ts index 99e0629d013f..22af4a85f64c 100644 --- a/code/core/src/csf/csf-factories.ts +++ b/code/core/src/csf/csf-factories.ts @@ -203,7 +203,6 @@ function defineStory< const testFunction = typeof overridesOrTestFn !== 'function' ? testFn! : overridesOrTestFn; const play = - //@ts-expect-error don't know why the testFunction type is failing mountDestructured(this.play) || mountDestructured(testFunction) ? async ({ context }: StoryContext) => { await this.play?.(context); diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 1e9837a9f5ee..c0e589cddfe5 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -1,12 +1,12 @@ // should be node:http, but that caused the ui/manager to fail to build, might be able to switch this back once ui/manager is in the core import type { FileSystemCache } from 'storybook/internal/common'; +import type { StoryIndexGenerator } from 'storybook/internal/core-server'; import type { Server as HttpServer, IncomingMessage, ServerResponse } from 'http'; import type { Server as NetServer } from 'net'; import type { Options as TelejsonOptions } from 'telejson'; import type { PackageJson as PackageJsonFromTypeFest } from 'type-fest'; -import type { StoryIndexGenerator } from '../../core-server'; import type { Indexer, StoriesEntry } from './indexer'; /** ⚠️ This file contains internal WIP types they MUST NOT be exported outside this package for now! */ From 40e0d9d569de561fe485990d74c726fb9bb4f0c1 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 9 Dec 2025 21:57:19 +0100 Subject: [PATCH 15/29] make import entries unique in optimize deps --- .../src/codegen-importfn-script.ts | 24 +++++++++---------- .../builders/builder-vite/src/optimizeDeps.ts | 5 ++-- .../src/utils/unique-import-paths.ts | 6 +++++ 3 files changed, 19 insertions(+), 16 deletions(-) create mode 100644 code/builders/builder-vite/src/utils/unique-import-paths.ts diff --git a/code/builders/builder-vite/src/codegen-importfn-script.ts b/code/builders/builder-vite/src/codegen-importfn-script.ts index e44d98036d3c..b1a0b195e69a 100644 --- a/code/builders/builder-vite/src/codegen-importfn-script.ts +++ b/code/builders/builder-vite/src/codegen-importfn-script.ts @@ -4,27 +4,25 @@ import { genDynamicImport, genObjectFromRawEntries } from 'knitwork'; import { join, normalize, relative } from 'pathe'; import { dedent } from 'ts-dedent'; +import { getUniqueImportPaths } from './utils/unique-import-paths'; + /** - * This function takes an array of stories and creates a mapping between the stories' relative paths - * to the working directory and their dynamic imports. The import is done in an asynchronous - * function to delay loading and to allow Vite to split the code into smaller chunks. It then - * creates a function, `importFn(path)`, which resolves a path to an import function and this is - * called by Storybook to fetch a story dynamically when needed. + * This function takes the story index and creates a mapping between the stories' relative paths to + * the working directory and their dynamic imports. The import is done in an asynchronous function + * to delay loading and to allow Vite to split the code into smaller chunks. It then creates a + * function, `importFn(path)`, which resolves a path to an import function and this is called by + * Storybook to fetch a story dynamically when needed. */ export function generateImportFnScriptCode(index: StoryIndex): string { - const uniqueImportPaths = [ - ...new Set(Object.values(index.entries).map((entry) => entry.importPath)), - ]; - - const objectEntries: [string, string][] = uniqueImportPaths.map((importPath) => { + const objectEntries: [string, string][] = getUniqueImportPaths(index).map((importPath) => { if (importPath.startsWith('virtual:')) { return [importPath, genDynamicImport(importPath)]; } /** - * Relative paths get passed either with no leading './' - e.g. `src/Foo.stories.js`, or with a - * leading `../` (etc), e.g. `../src/Foo.stories.js`. We want to deal in importPaths relative to - * the working dir, so we normalize + * Relative paths get passed either with no leading './' - e.g. 'src/Foo.stories.js', or with a + * leading '../', e.g. '../src/Foo.stories.js'. We want to deal in importPaths relative to the + * working dir, so we normalize */ const relativePath = normalize(relative(process.cwd(), importPath)); const normalizedRelativePath = relativePath.startsWith('../') diff --git a/code/builders/builder-vite/src/optimizeDeps.ts b/code/builders/builder-vite/src/optimizeDeps.ts index 58470c54ed4b..9965ea47306f 100644 --- a/code/builders/builder-vite/src/optimizeDeps.ts +++ b/code/builders/builder-vite/src/optimizeDeps.ts @@ -4,6 +4,7 @@ import type { Options, StoryIndex } from 'storybook/internal/types'; import { type UserConfig, type InlineConfig as ViteInlineConfig, resolveConfig } from 'vite'; import { INCLUDE_CANDIDATES } from './constants'; +import { getUniqueImportPaths } from './utils/unique-import-paths'; /** * Helper function which allows us to `filter` with an async predicate. Uses Promise.all for @@ -25,8 +26,6 @@ export async function getOptimizeDeps(config: ViteInlineConfig, options: Options entries: {}, }; - const storyModulePaths = Object.values(index.entries).map((entry) => entry.importPath); - // TODO: check if resolveConfig takes a lot of time, possible optimizations here const resolvedConfig = await resolveConfig(config, 'serve', 'development'); @@ -37,7 +36,7 @@ export async function getOptimizeDeps(config: ViteInlineConfig, options: Options const optimizeDeps: UserConfig['optimizeDeps'] = { ...config.optimizeDeps, - entries: storyModulePaths, + entries: getUniqueImportPaths(index), // We need Vite to precompile these dependencies, because they contain non-ESM code that would break // if we served it directly to the browser. include: [...include, ...extraOptimizeDeps, ...(config.optimizeDeps?.include || [])], diff --git a/code/builders/builder-vite/src/utils/unique-import-paths.ts b/code/builders/builder-vite/src/utils/unique-import-paths.ts new file mode 100644 index 000000000000..cc6987b35aa3 --- /dev/null +++ b/code/builders/builder-vite/src/utils/unique-import-paths.ts @@ -0,0 +1,6 @@ +import type { StoryIndex } from 'storybook/internal/types'; + +/** Takes the story index and returns an array of unique import paths. */ +export function getUniqueImportPaths(index: StoryIndex): string[] { + return [...new Set(Object.values(index.entries).map((entry) => entry.importPath))]; +} From 09d8aed411b8b20e4dc10cff02666673940b7173 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 11 Dec 2025 14:43:53 +0100 Subject: [PATCH 16/29] add vscode workspace --- storybook.code-workspace | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 storybook.code-workspace diff --git a/storybook.code-workspace b/storybook.code-workspace new file mode 100644 index 000000000000..6f1972a0d504 --- /dev/null +++ b/storybook.code-workspace @@ -0,0 +1,21 @@ +{ + "folders": [ + { + "path": ".", + }, + { + "path": "../storybook-sandboxes", + }, + ], + "settings": { + "files.associations": { + "*.js": "javascriptreact", + }, + "js/ts.implicitProjectConfig.target": "ESNext", + "typescript.format.enable": false, + "typescript.preferGoToSourceDefinition": true, + "typescript.tsdk": "./node_modules/typescript/lib", + "vitest.workspaceConfig": "./code/vitest.workspace.ts", + "vitest.rootConfig": "./code/vitest.workspace.ts", + }, +} From 9a0b8e5fcfb511a1b43f99830191fdfbc2f98993 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 11 Dec 2025 14:55:29 +0100 Subject: [PATCH 17/29] Fix file change watching invalidating index --- .../src/plugins/code-generator-plugin.ts | 2 ++ code/core/src/core-server/dev-server.ts | 13 +++++--- .../src/core-server/presets/common-preset.ts | 20 +++++------ .../utils/watch-story-specifiers.ts | 33 ++++++++++++++++--- 4 files changed, 49 insertions(+), 19 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index 1580d6f1e839..f0ee04c3ab9c 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -25,6 +25,8 @@ export function codeGeneratorPlugin(options: Options): Plugin { enforce: 'pre', async configureServer(server) { (await storyIndexGeneratorPromise).onInvalidated(() => { + // TODO: this is only necessary when new files are added. + // Changes and removals are already watched and handled by Vite, so they actually trigger a double HMR event right now. server.watcher.emit( 'change', getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE) diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 509a04650ce7..650eeec7279e 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -36,19 +36,22 @@ export async function storybookDevServer(options: Options) { let indexError: Error | undefined; - const storyIndexGeneratorPromise = - options.presets.apply('storyIndexGenerator'); - const workingDir = process.cwd(); const configDir = options.configDir; - const stories = await options.presets.apply('stories'); - + // StoryIndexGenerator depends on these normalized stories to be referentially equal + // So it's important that we only normalize them once here and pass the same reference around const normalizedStories = normalizeStories(stories, { configDir, workingDir, }); + const storyIndexGeneratorPromise = options.presets.apply( + 'storyIndexGenerator', + undefined, + { normalizedStories } + ); + registerIndexJsonRoute({ app, storyIndexGeneratorPromise, diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 2437a3052617..f1254d903410 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import type { Channel } from 'storybook/internal/channels'; -import { normalizeStories, optionalEnvToBoolean } from 'storybook/internal/common'; +import { optionalEnvToBoolean } from 'storybook/internal/common'; import { JsPackageManagerFactory, type RemoveAddonOptions, @@ -19,9 +19,11 @@ import { telemetry } from 'storybook/internal/telemetry'; import type { CoreConfig, Indexer, + NormalizedStoriesSpecifier, Options, PresetProperty, PresetPropertyFn, + StorybookConfigRaw, } from 'storybook/internal/types'; import { isAbsolute, join } from 'pathe'; @@ -291,7 +293,11 @@ export const managerEntries = async (existing: any) => { }; let initializedStoryIndexGenerator: StoryIndexGenerator | undefined = undefined; -export const storyIndexGenerator: PresetPropertyFn<'storyIndexGenerator'> = async (_, options) => { +export const storyIndexGenerator: PresetPropertyFn< + 'storyIndexGenerator', + StorybookConfigRaw, + { normalizedStories: NormalizedStoriesSpecifier[] } +> = async (_, options) => { if (initializedStoryIndexGenerator) { return initializedStoryIndexGenerator; } @@ -299,18 +305,12 @@ export const storyIndexGenerator: PresetPropertyFn<'storyIndexGenerator'> = asyn const workingDir = process.cwd(); const configDir = options.configDir; - const [stories, indexers, docs] = await Promise.all([ - options.presets.apply('stories'), + const [indexers, docs] = await Promise.all([ options.presets.apply('experimental_indexers', []), options.presets.apply('docs'), ]); - const normalizedStories = normalizeStories(stories, { - configDir, - workingDir, - }); - - const generator = new StoryIndexGenerator(normalizedStories, { + const generator = new StoryIndexGenerator(options.normalizedStories, { workingDir, configDir, indexers, diff --git a/code/core/src/core-server/utils/watch-story-specifiers.ts b/code/core/src/core-server/utils/watch-story-specifiers.ts index 5355aa8f6227..76ca2c068b2e 100644 --- a/code/core/src/core-server/utils/watch-story-specifiers.ts +++ b/code/core/src/core-server/utils/watch-story-specifiers.ts @@ -116,7 +116,32 @@ export function watchStorySpecifiers( } } - wp.on('change', async (filePath: Path, mtime: Date, explanation: string) => { + // Batch rapid file events to avoid redundant processing. + // Watchpack fires multiple events for the same file in rapid succession. + // We collect events for 100ms, then process unique paths only. + const pendingEvents = new Map(); + let batchTimeout: ReturnType | undefined; + + function queueEvent(absolutePath: Path, removed: boolean) { + // Store/overwrite the event for this path (last event type wins) + pendingEvents.set(absolutePath, { removed }); + + // Reset the timer on each new event to batch them together + if (batchTimeout) { + clearTimeout(batchTimeout); + } + batchTimeout = setTimeout(async () => { + batchTimeout = undefined; + const events = new Map(pendingEvents); + pendingEvents.clear(); + + await Promise.all( + Array.from(events.entries()).map(([path, { removed }]) => onChangeOrRemove(path, removed)) + ); + }, 100); + } + + wp.on('change', (filePath: Path, mtime: Date, explanation: string) => { // When a file is renamed (including being moved out of the watched dir) // we see first an event with explanation=rename and no mtime for the old name. // then an event with explanation=rename with an mtime for the new name. @@ -124,10 +149,10 @@ export function watchStorySpecifiers( // but that seems dangerous (what if the contents changed?) and frankly not worth it // (at this stage at least) const removed = !mtime; - await onChangeOrRemove(filePath, removed); + queueEvent(filePath, removed); }); - wp.on('remove', async (filePath: Path, explanation: string) => { - await onChangeOrRemove(filePath, true); + wp.on('remove', (filePath: Path) => { + queueEvent(filePath, true); }); return () => wp.close(); From 4c43d3da043ca1f13c5d74b81596f596eaa50fae Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 11 Dec 2025 15:22:06 +0100 Subject: [PATCH 18/29] fix build not passing normalizedStories to storyIndexGenerator preset --- code/core/src/core-server/build-static.ts | 16 +++++++++++++++- .../src/core-server/presets/common-preset.ts | 6 +++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 215d1fc6cb9d..e66cfdbb842e 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -5,6 +5,7 @@ import { loadAllPresets, loadMainConfig, logConfig, + normalizeStories, resolveAddonName, } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; @@ -137,7 +138,20 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption let initializedStoryIndexGenerator: Promise = Promise.resolve(undefined); if (!options.ignorePreview) { - initializedStoryIndexGenerator = presets.apply('storyIndexGenerator'); + const workingDir = process.cwd(); + const configDir = options.configDir; + const stories = await presets.apply('stories'); + // StoryIndexGenerator depends on these normalized stories to be referentially equal + // So it's important that we only normalize them once here and pass the same reference around + const normalizedStories = normalizeStories(stories, { + configDir, + workingDir, + }); + initializedStoryIndexGenerator = presets.apply( + 'storyIndexGenerator', + undefined, + { normalizedStories } + ); effects.push( writeIndexJson( diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index f1254d903410..123be13af5bc 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -296,12 +296,16 @@ let initializedStoryIndexGenerator: StoryIndexGenerator | undefined = undefined; export const storyIndexGenerator: PresetPropertyFn< 'storyIndexGenerator', StorybookConfigRaw, - { normalizedStories: NormalizedStoriesSpecifier[] } + { normalizedStories?: NormalizedStoriesSpecifier[] } > = async (_, options) => { if (initializedStoryIndexGenerator) { return initializedStoryIndexGenerator; } + if (!options.normalizedStories) { + throw new Error(`uninitialized storyIndexGenerator preset requires normalizedStories option`); + } + const workingDir = process.cwd(); const configDir = options.configDir; From a075997738906f15720aacb428cb49daedbff4fd Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 12 Dec 2025 10:12:17 +0100 Subject: [PATCH 19/29] make StoryIndexGenerator do specifier lookup instead of watchStorySpecifier. This is because now, the specifiers/normalized stories are being created in multiple places, so they are not referential equal. Therefore the specifier being passed in can't be used as a key to lookup the map in this.specifierToCache. But by just moving the importPathMatcher check into the StoryIndexGenerator, it will use its own specifiers, which _can_ be used as keys for looking up. While this technically introduces a double pass on the specifiers, users don't have that many specifiers so it doesn't have any material impact on performance. --- code/core/src/core-server/build-static.ts | 15 +-------------- code/core/src/core-server/dev-server.ts | 7 ++----- .../src/core-server/presets/common-preset.ts | 19 ++++++++++--------- .../core-server/utils/StoryIndexGenerator.ts | 9 ++++++++- code/core/src/core-server/utils/index-json.ts | 4 ++-- .../utils/watch-story-specifiers.ts | 6 +++--- 6 files changed, 26 insertions(+), 34 deletions(-) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index e66cfdbb842e..b0e53c2964f6 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -138,20 +138,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption let initializedStoryIndexGenerator: Promise = Promise.resolve(undefined); if (!options.ignorePreview) { - const workingDir = process.cwd(); - const configDir = options.configDir; - const stories = await presets.apply('stories'); - // StoryIndexGenerator depends on these normalized stories to be referentially equal - // So it's important that we only normalize them once here and pass the same reference around - const normalizedStories = normalizeStories(stories, { - configDir, - workingDir, - }); - initializedStoryIndexGenerator = presets.apply( - 'storyIndexGenerator', - undefined, - { normalizedStories } - ); + initializedStoryIndexGenerator = presets.apply('storyIndexGenerator'); effects.push( writeIndexJson( diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 650eeec7279e..aa43fad5e75c 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -46,11 +46,8 @@ export async function storybookDevServer(options: Options) { workingDir, }); - const storyIndexGeneratorPromise = options.presets.apply( - 'storyIndexGenerator', - undefined, - { normalizedStories } - ); + const storyIndexGeneratorPromise = + options.presets.apply('storyIndexGenerator'); registerIndexJsonRoute({ app, diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 123be13af5bc..dd2552bb3889 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import type { Channel } from 'storybook/internal/channels'; -import { optionalEnvToBoolean } from 'storybook/internal/common'; +import { normalizeStories, optionalEnvToBoolean } from 'storybook/internal/common'; import { JsPackageManagerFactory, type RemoveAddonOptions, @@ -19,7 +19,6 @@ import { telemetry } from 'storybook/internal/telemetry'; import type { CoreConfig, Indexer, - NormalizedStoriesSpecifier, Options, PresetProperty, PresetPropertyFn, @@ -295,26 +294,28 @@ export const managerEntries = async (existing: any) => { let initializedStoryIndexGenerator: StoryIndexGenerator | undefined = undefined; export const storyIndexGenerator: PresetPropertyFn< 'storyIndexGenerator', - StorybookConfigRaw, - { normalizedStories?: NormalizedStoriesSpecifier[] } + StorybookConfigRaw > = async (_, options) => { if (initializedStoryIndexGenerator) { return initializedStoryIndexGenerator; } - if (!options.normalizedStories) { - throw new Error(`uninitialized storyIndexGenerator preset requires normalizedStories option`); - } - const workingDir = process.cwd(); const configDir = options.configDir; + const stories = await options.presets.apply('stories'); + // StoryIndexGenerator depends on these normalized stories to be referentially equal + // So it's important that we only normalize them once here and pass the same reference around + const normalizedStories = normalizeStories(stories, { + configDir, + workingDir, + }); const [indexers, docs] = await Promise.all([ options.presets.apply('experimental_indexers', []), options.presets.apply('docs'), ]); - const generator = new StoryIndexGenerator(options.normalizedStories, { + const generator = new StoryIndexGenerator(normalizedStories, { workingDir, configDir, indexers, diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts index 257b0212bad2..d629d7acc929 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts @@ -806,8 +806,15 @@ export class StoryIndexGenerator { this.invalidationListeners.forEach((listener) => listener()); } - invalidate(specifier: NormalizedStoriesSpecifier, importPath: Path, removed: boolean) { + invalidate(importPath: Path, removed: boolean) { const absolutePath = slash(resolve(this.options.workingDir, importPath)); + const specifier = this.specifierToCache + .keys() + .find((ns) => ns.importPathMatcher.exec(importPath)); + if (!specifier) { + // not a story file + return; + } const cache = this.specifierToCache.get(specifier); invariant( cache, diff --git a/code/core/src/core-server/utils/index-json.ts b/code/core/src/core-server/utils/index-json.ts index 168d11732b9d..08e1b5047957 100644 --- a/code/core/src/core-server/utils/index-json.ts +++ b/code/core/src/core-server/utils/index-json.ts @@ -41,8 +41,8 @@ export function registerIndexJsonRoute({ const maybeInvalidate = debounce(() => serverChannel.emit(STORY_INDEX_INVALIDATED), DEBOUNCE, { edges: ['leading', 'trailing'], }); - watchStorySpecifiers(normalizedStories, { workingDir }, async (specifier, path, removed) => { - (await storyIndexGeneratorPromise).invalidate(specifier, path, removed); + watchStorySpecifiers(normalizedStories, { workingDir }, async (path, removed) => { + (await storyIndexGeneratorPromise).invalidate(path, removed); maybeInvalidate(); }); if (configDir) { diff --git a/code/core/src/core-server/utils/watch-story-specifiers.ts b/code/core/src/core-server/utils/watch-story-specifiers.ts index 76ca2c068b2e..94879375b845 100644 --- a/code/core/src/core-server/utils/watch-story-specifiers.ts +++ b/code/core/src/core-server/utils/watch-story-specifiers.ts @@ -42,7 +42,7 @@ function getNestedFilesAndDirectories(directories: Path[]) { export function watchStorySpecifiers( specifiers: NormalizedStoriesSpecifier[], options: { workingDir: Path }, - onInvalidate: (specifier: NormalizedStoriesSpecifier, path: Path, removed: boolean) => void + onInvalidate: (path: Path, removed: boolean) => void ) { // Watch all nested files and directories up front to avoid this issue: // https://github.com/webpack/watchpack/issues/222 @@ -71,7 +71,7 @@ export function watchStorySpecifiers( const matchingSpecifier = specifiers.find((ns) => ns.importPathMatcher.exec(importPath)); if (matchingSpecifier) { - onInvalidate(matchingSpecifier, importPath, removed); + onInvalidate(importPath, removed); return; } @@ -108,7 +108,7 @@ export function watchStorySpecifiers( const fileImportPath = toImportPath(filePath); if (specifier.importPathMatcher.exec(fileImportPath)) { - onInvalidate(specifier, fileImportPath, removed); + onInvalidate(fileImportPath, removed); } }); }) From ac49974bf3c9a2472e9aa6d8ce3c18554bf80284 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 12 Dec 2025 11:02:54 +0100 Subject: [PATCH 20/29] fix potential race condition with story index generator --- .../src/core-server/presets/common-preset.ts | 60 ++++++++++--------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index dd2552bb3889..a46bb8162f5c 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -291,37 +291,43 @@ export const managerEntries = async (existing: any) => { ]; }; -let initializedStoryIndexGenerator: StoryIndexGenerator | undefined = undefined; +// Store the promise (not the result) to prevent race conditions. +// The promise is assigned synchronously, so concurrent calls will share the same initialization. +// This is essentially an async singleton pattern. +let storyIndexGeneratorPromise: Promise | undefined; export const storyIndexGenerator: PresetPropertyFn< 'storyIndexGenerator', StorybookConfigRaw > = async (_, options) => { - if (initializedStoryIndexGenerator) { - return initializedStoryIndexGenerator; + if (storyIndexGeneratorPromise) { + return storyIndexGeneratorPromise; } - const workingDir = process.cwd(); - const configDir = options.configDir; - const stories = await options.presets.apply('stories'); - // StoryIndexGenerator depends on these normalized stories to be referentially equal - // So it's important that we only normalize them once here and pass the same reference around - const normalizedStories = normalizeStories(stories, { - configDir, - workingDir, - }); - - const [indexers, docs] = await Promise.all([ - options.presets.apply('experimental_indexers', []), - options.presets.apply('docs'), - ]); - - const generator = new StoryIndexGenerator(normalizedStories, { - workingDir, - configDir, - indexers, - docs, - }); - await generator.initialize(); - initializedStoryIndexGenerator = generator; - return initializedStoryIndexGenerator; + storyIndexGeneratorPromise = (async () => { + const workingDir = process.cwd(); + const configDir = options.configDir; + const stories = await options.presets.apply('stories'); + // StoryIndexGenerator depends on these normalized stories to be referentially equal + // So it's important that we only normalize them once here and pass the same reference around + const normalizedStories = normalizeStories(stories, { + configDir, + workingDir, + }); + + const [indexers, docs] = await Promise.all([ + options.presets.apply('experimental_indexers', []), + options.presets.apply('docs'), + ]); + + const generator = new StoryIndexGenerator(normalizedStories, { + workingDir, + configDir, + indexers, + docs, + }); + await generator.initialize(); + return generator; + })(); + + return storyIndexGeneratorPromise; }; From f72345e0f4e46c757a61c79c9a623b1f736de8d5 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Mon, 15 Dec 2025 09:45:10 +0100 Subject: [PATCH 21/29] fix SIG tests --- .../utils/StoryIndexGenerator.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts index 13c781fb45f2..e1e3f75389ec 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts @@ -2118,7 +2118,7 @@ describe('StoryIndexGenerator', () => { await generator.getIndex(); expect(readCsfMock).toHaveBeenCalledTimes(12); - generator.invalidate(specifier, './src/B.stories.ts', false); + generator.invalidate('./src/B.stories.ts', false); readCsfMock.mockClear(); await generator.getIndex(); @@ -2140,7 +2140,7 @@ describe('StoryIndexGenerator', () => { await generator.getIndex(); expect(toId).toHaveBeenCalledTimes(7); - generator.invalidate(docsSpecifier, './src/docs2/Title.mdx', false); + generator.invalidate('./src/docs2/Title.mdx', false); toIdMock.mockClear(); await generator.getIndex(); @@ -2162,7 +2162,7 @@ describe('StoryIndexGenerator', () => { await generator.getIndex(); expect(toId).toHaveBeenCalledTimes(7); - generator.invalidate(storiesSpecifier, './src/A.stories.js', false); + generator.invalidate('./src/A.stories.js', false); toIdMock.mockClear(); await generator.getIndex(); @@ -2182,7 +2182,7 @@ describe('StoryIndexGenerator', () => { await generator.getIndex(); expect(sortFn).toHaveBeenCalled(); - generator.invalidate(specifier, './src/B.stories.ts', false); + generator.invalidate('./src/B.stories.ts', false); sortFn.mockClear(); await generator.getIndex(); @@ -2203,7 +2203,7 @@ describe('StoryIndexGenerator', () => { await generator.getIndex(); expect(readCsfMock).toHaveBeenCalledTimes(12); - generator.invalidate(specifier, './src/B.stories.ts', true); + generator.invalidate('./src/B.stories.ts', true); readCsfMock.mockClear(); await generator.getIndex(); @@ -2223,7 +2223,7 @@ describe('StoryIndexGenerator', () => { await generator.getIndex(); expect(sortFn).toHaveBeenCalled(); - generator.invalidate(specifier, './src/B.stories.ts', true); + generator.invalidate('./src/B.stories.ts', true); sortFn.mockClear(); await generator.getIndex(); @@ -2242,7 +2242,7 @@ describe('StoryIndexGenerator', () => { await generator.getIndex(); expect(readCsfMock).toHaveBeenCalledTimes(12); - generator.invalidate(specifier, './src/B.stories.ts', true); + generator.invalidate('./src/B.stories.ts', true); expect(Object.keys((await generator.getIndex()).entries)).not.toContain('b--story-one'); }); @@ -2264,7 +2264,7 @@ describe('StoryIndexGenerator', () => { expect(Object.keys((await generator.getIndex()).entries)).toContain('notitle--docs'); - generator.invalidate(docsSpecifier, './src/docs2/NoTitle.mdx', true); + generator.invalidate('./src/docs2/NoTitle.mdx', true); expect(Object.keys((await generator.getIndex()).entries)).not.toContain('notitle--docs'); }); @@ -2286,12 +2286,12 @@ describe('StoryIndexGenerator', () => { expect(Object.keys((await generator.getIndex()).entries)).toContain('a--metaof'); - generator.invalidate(docsSpecifier, './src/docs2/MetaOf.mdx', true); + generator.invalidate('./src/docs2/MetaOf.mdx', true); expect(Object.keys((await generator.getIndex()).entries)).not.toContain('a--metaof'); // this will throw if MetaOf is not removed from A's dependents - generator.invalidate(storiesSpecifier, './src/A.stories.js', false); + generator.invalidate('./src/A.stories.js', false); }); }); }); From 2063b5161bf87e1e08c59642ab83fd4d4d0e82dd Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 16 Dec 2025 14:45:50 +0100 Subject: [PATCH 22/29] fix unit tests to match new event batching behavior --- .../src/core-server/utils/index-json.test.ts | 43 +++++-- .../utils/watch-story-specifiers.test.ts | 117 +++++++++--------- 2 files changed, 91 insertions(+), 69 deletions(-) diff --git a/code/core/src/core-server/utils/index-json.test.ts b/code/core/src/core-server/utils/index-json.test.ts index feebaf7dcea1..816df2187ed8 100644 --- a/code/core/src/core-server/utils/index-json.test.ts +++ b/code/core/src/core-server/utils/index-json.test.ts @@ -529,8 +529,11 @@ describe('registerIndexJsonRoute', () => { expect(watcher.on).toHaveBeenCalledTimes(2); const onChange = watcher.on.mock.calls[0][1]; - await onChange(`${workingDir}/src/nested/Button.stories.ts`); - expect(mockServerChannel.emit).toHaveBeenCalledTimes(1); + onChange(`${workingDir}/src/nested/Button.stories.ts`); + // Wait for the batched events to be processed + await vi.waitFor(() => { + expect(mockServerChannel.emit).toHaveBeenCalledTimes(1); + }); expect(mockServerChannel.emit).toHaveBeenCalledWith(STORY_INDEX_INVALIDATED); }); @@ -567,8 +570,11 @@ describe('registerIndexJsonRoute', () => { expect(watcher.on).toHaveBeenCalledTimes(2); const onChange = watcher.on.mock.calls[0][1]; - await onChange(`${workingDir}/src/nested/Button.stories.ts`); - expect(mockServerChannel.emit).toHaveBeenCalledTimes(1); + onChange(`${workingDir}/src/nested/Button.stories.ts`); + // Wait for the batched events to be processed + await vi.waitFor(() => { + expect(mockServerChannel.emit).toHaveBeenCalledTimes(1); + }); expect(mockServerChannel.emit).toHaveBeenCalledWith(STORY_INDEX_INVALIDATED); }); @@ -606,18 +612,29 @@ describe('registerIndexJsonRoute', () => { expect(watcher.on).toHaveBeenCalledTimes(2); const onChange = watcher.on.mock.calls[0][1]; - await onChange(`${workingDir}/src/nested/Button.stories.ts`); - await onChange(`${workingDir}/src/nested/Button.stories.ts`); - await onChange(`${workingDir}/src/nested/Button.stories.ts`); - await onChange(`${workingDir}/src/nested/Button.stories.ts`); - await onChange(`${workingDir}/src/nested/Button.stories.ts`); - - expect(mockServerChannel.emit).toHaveBeenCalledTimes(1); + // Fire multiple change events in rapid succession + // These get batched by watchStorySpecifiers (100ms batching window) + // and then debounced by maybeInvalidate (100ms debounce) + onChange(`${workingDir}/src/nested/Button.stories.ts`); + onChange(`${workingDir}/src/nested/Button.stories.ts`); + onChange(`${workingDir}/src/nested/Button.stories.ts`); + onChange(`${workingDir}/src/nested/Button.stories.ts`); + onChange(`${workingDir}/src/nested/Button.stories.ts`); + + // Wait for first batch to be processed and emit (leading edge) + await vi.waitFor(() => { + expect(mockServerChannel.emit).toHaveBeenCalledTimes(1); + }); expect(mockServerChannel.emit).toHaveBeenCalledWith(STORY_INDEX_INVALIDATED); - await new Promise((r) => setTimeout(r, 2 * DEBOUNCE)); + // Fire another change event after the first batch is processed + // This will trigger the trailing edge of the debounce + onChange(`${workingDir}/src/nested/Button.stories.ts`); - expect(mockServerChannel.emit).toHaveBeenCalledTimes(2); + // Wait for trailing debounce to trigger second emit + await vi.waitFor(() => { + expect(mockServerChannel.emit).toHaveBeenCalledTimes(2); + }); }); }); }); diff --git a/code/core/src/core-server/utils/watch-story-specifiers.test.ts b/code/core/src/core-server/utils/watch-story-specifiers.test.ts index 476db312e4cd..fc19d8aa9e1a 100644 --- a/code/core/src/core-server/utils/watch-story-specifiers.test.ts +++ b/code/core/src/core-server/utils/watch-story-specifiers.test.ts @@ -1,6 +1,6 @@ import { join } from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { normalizeStoriesEntry } from 'storybook/internal/common'; @@ -19,7 +19,20 @@ describe('watchStorySpecifiers', () => { const abspath = (filename: string) => join(workingDir, filename); let close: () => void; - afterEach(() => close?.()); + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + close?.(); + vi.useRealTimers(); + }); + + // Helper to flush the batched events queue + const flushEvents = async () => { + await vi.runAllTimersAsync(); + }; it('watches basic globs', async () => { const specifier = normalizeStoriesEntry('../src/**/*.stories.@(ts|js)', options); @@ -29,12 +42,6 @@ describe('watchStorySpecifiers', () => { expect(Watchpack).toHaveBeenCalledTimes(1); const watcher = Watchpack.mock.instances[0]; - expect(watcher.watch).toHaveBeenCalledWith( - expect.objectContaining({ - directories: expect.any(Array), - files: expect.any(Array), - }) - ); expect(watcher.on).toHaveBeenCalledTimes(2); const baseOnChange = watcher.on.mock.calls[0][1]; @@ -44,38 +51,48 @@ describe('watchStorySpecifiers', () => { // File changed, matching onInvalidate.mockClear(); - await onChange('src/nested/Button.stories.ts', 1234); - expect(onInvalidate).toHaveBeenCalledWith(specifier, `./src/nested/Button.stories.ts`, false); + onChange('src/nested/Button.stories.ts', 1234); + await flushEvents(); + expect(onInvalidate).toHaveBeenCalledWith(`./src/nested/Button.stories.ts`, false); // File changed, NOT matching onInvalidate.mockClear(); - await onChange('src/nested/Button.ts', 1234); + onChange('src/nested/Button.ts', 1234); + await flushEvents(); expect(onInvalidate).not.toHaveBeenCalled(); // File removed, matching onInvalidate.mockClear(); - await onRemove('src/nested/Button.stories.ts'); - expect(onInvalidate).toHaveBeenCalledWith(specifier, `./src/nested/Button.stories.ts`, true); + onRemove('src/nested/Button.stories.ts'); + await flushEvents(); + expect(onInvalidate).toHaveBeenCalledWith(`./src/nested/Button.stories.ts`, true); // File removed, NOT matching onInvalidate.mockClear(); - await onRemove('src/nested/Button.ts'); + onRemove('src/nested/Button.ts'); + await flushEvents(); expect(onInvalidate).not.toHaveBeenCalled(); // File moved out, matching onInvalidate.mockClear(); - await onChange('src/nested/Button.stories.ts', null); - expect(onInvalidate).toHaveBeenCalledWith(specifier, `./src/nested/Button.stories.ts`, true); + onChange('src/nested/Button.stories.ts', null); + await flushEvents(); + expect(onInvalidate).toHaveBeenCalledWith(`./src/nested/Button.stories.ts`, true); // File renamed, matching onInvalidate.mockClear(); - await onChange('src/nested/Button.stories.ts', null); - await onChange('src/nested/Button-2.stories.ts', 1234); - expect(onInvalidate).toHaveBeenCalledWith(specifier, `./src/nested/Button.stories.ts`, true); - expect(onInvalidate).toHaveBeenCalledWith(specifier, `./src/nested/Button-2.stories.ts`, false); + onChange('src/nested/Button.stories.ts', null); + onChange('src/nested/Button-2.stories.ts', 1234); + await flushEvents(); + expect(onInvalidate).toHaveBeenCalledWith(`./src/nested/Button.stories.ts`, true); + expect(onInvalidate).toHaveBeenCalledWith(`./src/nested/Button-2.stories.ts`, false); }); it('scans directories when they are added', async () => { + // This test uses real timers because globby performs actual filesystem I/O + // that doesn't work well with fake timers + vi.useRealTimers(); + const specifier = normalizeStoriesEntry('../src/**/*.stories.@(ts|js)', options); const onInvalidate = vi.fn(); @@ -83,20 +100,17 @@ describe('watchStorySpecifiers', () => { expect(Watchpack).toHaveBeenCalledTimes(1); const watcher = Watchpack.mock.instances[0]; - expect(watcher.watch).toHaveBeenCalledWith( - expect.objectContaining({ - directories: expect.any(Array), - files: expect.any(Array), - }) - ); expect(watcher.on).toHaveBeenCalledTimes(2); const baseOnChange = watcher.on.mock.calls[0][1]; const onChange = (filename: string, ...args: any[]) => baseOnChange(abspath(filename), ...args); onInvalidate.mockClear(); - await onChange('src/nested', 1234); - expect(onInvalidate).toHaveBeenCalledWith(specifier, `./src/nested/Button.stories.ts`, false); + onChange('src/nested', 1234); + // Wait for the batching timeout and globby to complete + await vi.waitFor(() => { + expect(onInvalidate).toHaveBeenCalledWith(`./src/nested/Button.stories.ts`, false); + }); }); it('watches single file globs', async () => { @@ -107,12 +121,6 @@ describe('watchStorySpecifiers', () => { expect(Watchpack).toHaveBeenCalledTimes(1); const watcher = Watchpack.mock.instances[0]; - expect(watcher.watch).toHaveBeenCalledWith( - expect.objectContaining({ - directories: expect.any(Array), - files: expect.any(Array), - }) - ); expect(watcher.on).toHaveBeenCalledTimes(2); const baseOnChange = watcher.on.mock.calls[0][1]; @@ -122,28 +130,33 @@ describe('watchStorySpecifiers', () => { // File changed, matching onInvalidate.mockClear(); - await onChange('src/nested/Button.mdx', 1234); - expect(onInvalidate).toHaveBeenCalledWith(specifier, `./src/nested/Button.mdx`, false); + onChange('src/nested/Button.mdx', 1234); + await flushEvents(); + expect(onInvalidate).toHaveBeenCalledWith(`./src/nested/Button.mdx`, false); // File changed, NOT matching onInvalidate.mockClear(); - await onChange('src/nested/Button.tsx', 1234); + onChange('src/nested/Button.tsx', 1234); + await flushEvents(); expect(onInvalidate).not.toHaveBeenCalled(); // File removed, matching onInvalidate.mockClear(); - await onRemove('src/nested/Button.mdx'); - expect(onInvalidate).toHaveBeenCalledWith(specifier, `./src/nested/Button.mdx`, true); + onRemove('src/nested/Button.mdx'); + await flushEvents(); + expect(onInvalidate).toHaveBeenCalledWith(`./src/nested/Button.mdx`, true); // File removed, NOT matching onInvalidate.mockClear(); - await onRemove('src/nested/Button.tsx'); + onRemove('src/nested/Button.tsx'); + await flushEvents(); expect(onInvalidate).not.toHaveBeenCalled(); // File moved out, matching onInvalidate.mockClear(); - await onChange('src/nested/Button.mdx', null); - expect(onInvalidate).toHaveBeenCalledWith(specifier, `./src/nested/Button.mdx`, true); + onChange('src/nested/Button.mdx', null); + await flushEvents(); + expect(onInvalidate).toHaveBeenCalledWith(`./src/nested/Button.mdx`, true); }); it('multiplexes between two specifiers on the same directory', async () => { @@ -155,27 +168,19 @@ describe('watchStorySpecifiers', () => { expect(Watchpack).toHaveBeenCalledTimes(1); const watcher = Watchpack.mock.instances[0]; - expect(watcher.watch).toHaveBeenCalledWith( - expect.objectContaining({ - directories: expect.any(Array), - files: expect.any(Array), - }) - ); expect(watcher.on).toHaveBeenCalledTimes(2); const baseOnChange = watcher.on.mock.calls[0][1]; const onChange = (filename: string, ...args: any[]) => baseOnChange(abspath(filename), ...args); onInvalidate.mockClear(); - await onChange('src/nested/Button.stories.ts', 1234); - expect(onInvalidate).toHaveBeenCalledWith( - globSpecifier, - `./src/nested/Button.stories.ts`, - false - ); + onChange('src/nested/Button.stories.ts', 1234); + await flushEvents(); + expect(onInvalidate).toHaveBeenCalledWith(`./src/nested/Button.stories.ts`, false); onInvalidate.mockClear(); - await onChange('src/nested/Button.mdx', 1234); - expect(onInvalidate).toHaveBeenCalledWith(fileSpecifier, `./src/nested/Button.mdx`, false); + onChange('src/nested/Button.mdx', 1234); + await flushEvents(); + expect(onInvalidate).toHaveBeenCalledWith(`./src/nested/Button.mdx`, false); }); }); From 25e76c90aadeafe85841c4da0ebecd3598cb4039 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 16 Dec 2025 14:49:47 +0100 Subject: [PATCH 23/29] remove comment --- code/core/src/core-server/presets/common-preset.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index a46bb8162f5c..1036193ff689 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -307,8 +307,6 @@ export const storyIndexGenerator: PresetPropertyFn< const workingDir = process.cwd(); const configDir = options.configDir; const stories = await options.presets.apply('stories'); - // StoryIndexGenerator depends on these normalized stories to be referentially equal - // So it's important that we only normalize them once here and pass the same reference around const normalizedStories = normalizeStories(stories, { configDir, workingDir, From 920b47653c4dccc99501e82ff4e1ef35c5dc7f48 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 16 Dec 2025 15:51:14 +0100 Subject: [PATCH 24/29] cleanup --- .../src/codegen-importfn-script.ts | 38 ++++++++++--------- .../builders/builder-vite/src/optimizeDeps.ts | 5 +-- .../src/plugins/code-generator-plugin.ts | 10 +++-- .../builder-vite/src/virtual-file-names.ts | 2 + 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-importfn-script.ts b/code/builders/builder-vite/src/codegen-importfn-script.ts index b1a0b195e69a..b1f67fa72221 100644 --- a/code/builders/builder-vite/src/codegen-importfn-script.ts +++ b/code/builders/builder-vite/src/codegen-importfn-script.ts @@ -14,25 +14,27 @@ import { getUniqueImportPaths } from './utils/unique-import-paths'; * Storybook to fetch a story dynamically when needed. */ export function generateImportFnScriptCode(index: StoryIndex): string { - const objectEntries: [string, string][] = getUniqueImportPaths(index).map((importPath) => { - if (importPath.startsWith('virtual:')) { - return [importPath, genDynamicImport(importPath)]; + const objectEntries: [path: string, importStatement: string][] = getUniqueImportPaths(index).map( + (importPath) => { + if (importPath.startsWith('virtual:')) { + return [importPath, genDynamicImport(importPath)]; + } + + /** + * Relative paths get passed either with no leading './' - e.g. 'src/Foo.stories.js', or with + * a leading '../', e.g. '../src/Foo.stories.js'. We want to deal in importPaths relative to + * the working dir, so we normalize + */ + const relativePath = normalize(relative(process.cwd(), importPath)); + const normalizedRelativePath = relativePath.startsWith('../') + ? relativePath + : `./${relativePath}`; + + const absolutePath = normalize(join(process.cwd(), importPath)); + + return [normalizedRelativePath, genDynamicImport(absolutePath)]; } - - /** - * Relative paths get passed either with no leading './' - e.g. 'src/Foo.stories.js', or with a - * leading '../', e.g. '../src/Foo.stories.js'. We want to deal in importPaths relative to the - * working dir, so we normalize - */ - const relativePath = normalize(relative(process.cwd(), importPath)); - const normalizedRelativePath = relativePath.startsWith('../') - ? relativePath - : `./${relativePath}`; - - const absolutePath = normalize(join(process.cwd(), importPath)); - - return [normalizedRelativePath, genDynamicImport(absolutePath)]; - }); + ); return dedent` const importers = ${genObjectFromRawEntries(objectEntries)}; diff --git a/code/builders/builder-vite/src/optimizeDeps.ts b/code/builders/builder-vite/src/optimizeDeps.ts index 9965ea47306f..61e5c1ed5b56 100644 --- a/code/builders/builder-vite/src/optimizeDeps.ts +++ b/code/builders/builder-vite/src/optimizeDeps.ts @@ -21,10 +21,7 @@ export async function getOptimizeDeps(config: ViteInlineConfig, options: Options options.presets.apply('storyIndexGenerator'), ]); - const index: StoryIndex = (await storyIndexGenerator.getIndex()) ?? { - v: 5, - entries: {}, - }; + const index: StoryIndex = await storyIndexGenerator.getIndex(); // TODO: check if resolveConfig takes a lot of time, possible optimizations here const resolvedConfig = await resolveConfig(config, 'serve', 'development'); diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index f0ee04c3ab9c..4a38ee04e3b8 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -11,7 +11,11 @@ import { generateImportFnScriptCode } from '../codegen-importfn-script'; import { generateModernIframeScriptCode } from '../codegen-modern-iframe-script'; import { generateAddonSetupCode } from '../codegen-set-addon-channel'; import { transformIframeHtml } from '../transform-iframe-html'; -import { SB_VIRTUAL_FILES, getResolvedVirtualModuleId } from '../virtual-file-names'; +import { + SB_VIRTUAL_FILES, + SB_VIRTUAL_FILE_IDS, + getResolvedVirtualModuleId, +} from '../virtual-file-names'; export function codeGeneratorPlugin(options: Options): Plugin { const iframePath = fileURLToPath(importMetaResolve('@storybook/builder-vite/input/iframe.html')); @@ -53,7 +57,7 @@ export function codeGeneratorPlugin(options: Options): Plugin { iframeId = `${config.root}/iframe.html`; }, resolveId(source) { - if (Object.values(SB_VIRTUAL_FILES).includes(source)) { + if (SB_VIRTUAL_FILE_IDS.includes(source)) { return getResolvedVirtualModuleId(source); } if (source === iframePath) { @@ -66,7 +70,7 @@ export function codeGeneratorPlugin(options: Options): Plugin { switch (id) { case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE): const storyIndexGenerator = await storyIndexGeneratorPromise; - const index = (await storyIndexGenerator?.getIndex()) ?? { v: 5, entries: {} }; + const index = await storyIndexGenerator?.getIndex(); return generateImportFnScriptCode(index); case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_ADDON_SETUP_FILE): diff --git a/code/builders/builder-vite/src/virtual-file-names.ts b/code/builders/builder-vite/src/virtual-file-names.ts index 7ee932834f25..cf8f319480be 100644 --- a/code/builders/builder-vite/src/virtual-file-names.ts +++ b/code/builders/builder-vite/src/virtual-file-names.ts @@ -4,6 +4,8 @@ export const SB_VIRTUAL_FILES = { VIRTUAL_ADDON_SETUP_FILE: 'virtual:/@storybook/builder-vite/setup-addons.js', }; +export const SB_VIRTUAL_FILE_IDS = Object.values(SB_VIRTUAL_FILES); + export function getResolvedVirtualModuleId(virtualModuleId: string) { return `\0${virtualModuleId}`; } From d4cdc7522cd419062e8c080bb1751f639b19da35 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 16 Dec 2025 16:03:15 +0100 Subject: [PATCH 25/29] simplify index error handling --- code/core/src/core-server/dev-server.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index aa43fad5e75c..3b9b7cff3598 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -34,8 +34,6 @@ export async function storybookDevServer(options: Options) { getServerChannel(server) ); - let indexError: Error | undefined; - const workingDir = process.cwd(); const configDir = options.configDir; const stories = await options.presets.apply('stories'); @@ -147,18 +145,19 @@ export async function storybookDevServer(options: Options) { app.listen({ port, host }, resolve); }); - await Promise.all([storyIndexGeneratorPromise, listening]).then(async ([indexGenerator]) => { + try { + const [indexGenerator] = await Promise.all([storyIndexGeneratorPromise, listening]); + if (indexGenerator && !options.ci && !options.smokeTest && options.open) { const url = host ? networkAddress : address; openInBrowser(options.previewOnly ? `${url}iframe.html?navigator=true` : url).catch(() => { // the browser window could not be opened, this is non-critical, we just ignore the error }); } - }); - if (indexError) { + } catch (e) { await managerBuilder?.bail().catch(); await previewBuilder?.bail().catch(); - throw indexError; + throw e; } const features = await options.presets.apply('features'); From c648147346bbde454317a2c48d03da61e1f4fffa Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 16 Dec 2025 16:03:25 +0100 Subject: [PATCH 26/29] support node 20 in map keys --- code/core/src/core-server/utils/StoryIndexGenerator.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts index 05caf64a4e5d..783630a08543 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts @@ -808,9 +808,9 @@ export class StoryIndexGenerator { invalidate(importPath: Path, removed: boolean) { const absolutePath = slash(resolve(this.options.workingDir, importPath)); - const specifier = this.specifierToCache - .keys() - .find((ns) => ns.importPathMatcher.exec(importPath)); + const specifier = Array.from(this.specifierToCache.keys()).find((ns) => + ns.importPathMatcher.exec(importPath) + ); if (!specifier) { // not a story file return; From d4d565455bad33fbeb85cc458520d7d7243ad71c Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 16 Dec 2025 16:03:31 +0100 Subject: [PATCH 27/29] cleanup --- .../src/plugins/code-generator-plugin.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index 4a38ee04e3b8..2af433d0c90b 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -68,22 +68,24 @@ export function codeGeneratorPlugin(options: Options): Plugin { }, async load(id) { switch (id) { - case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE): + case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE): { const storyIndexGenerator = await storyIndexGeneratorPromise; const index = await storyIndexGenerator?.getIndex(); return generateImportFnScriptCode(index); + } - case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_ADDON_SETUP_FILE): + case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_ADDON_SETUP_FILE): { return generateAddonSetupCode(); - - case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE): + } + case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE): { return generateModernIframeScriptCode(options, projectRoot); - - case iframeId: + } + case iframeId: { return readFileSync( fileURLToPath(importMetaResolve('@storybook/builder-vite/input/iframe.html')), 'utf-8' ); + } } }, async transformIndexHtml(html, ctx) { From c3941b131e40f2ac78aeafbfc4fbed874547536b Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 16 Dec 2025 16:04:04 +0100 Subject: [PATCH 28/29] cleanup --- code/core/src/core-server/utils/index-json.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/code/core/src/core-server/utils/index-json.test.ts b/code/core/src/core-server/utils/index-json.test.ts index 816df2187ed8..e3d8b22e17e0 100644 --- a/code/core/src/core-server/utils/index-json.test.ts +++ b/code/core/src/core-server/utils/index-json.test.ts @@ -94,7 +94,6 @@ describe('registerIndexJsonRoute', () => { describe('JSON endpoint', () => { it('scans and extracts index', async () => { const mockServerChannel = { emit: vi.fn() } as any as ServerChannel; - console.time('registerIndexJsonRoute'); registerIndexJsonRoute({ app, serverChannel: mockServerChannel, @@ -102,7 +101,6 @@ describe('registerIndexJsonRoute', () => { normalizedStories, storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), }); - console.timeEnd('registerIndexJsonRoute'); expect(use).toHaveBeenCalledTimes(1); const route = use.mock.calls[0][1]; From 2f8ceda250663659901ff3669927936193ee80b5 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 17 Dec 2025 20:21:00 +0100 Subject: [PATCH 29/29] improve variable name --- code/core/src/core-server/build-static.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index b0e53c2964f6..0ef50cd6e77c 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -135,15 +135,15 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const coreServerPublicDir = join(resolvePackageDir('storybook'), 'assets/browser'); effects.push(cp(coreServerPublicDir, options.outputDir, { recursive: true })); - let initializedStoryIndexGenerator: Promise = + let storyIndexGeneratorPromise: Promise = Promise.resolve(undefined); if (!options.ignorePreview) { - initializedStoryIndexGenerator = presets.apply('storyIndexGenerator'); + storyIndexGeneratorPromise = presets.apply('storyIndexGenerator'); effects.push( writeIndexJson( join(options.outputDir, 'index.json'), - initializedStoryIndexGenerator as Promise + storyIndexGeneratorPromise as Promise ) ); @@ -151,7 +151,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const componentManifestGenerator = await presets.apply( 'experimental_componentManifestGenerator' ); - const indexGenerator = await initializedStoryIndexGenerator; + const indexGenerator = await storyIndexGeneratorPromise; if (componentManifestGenerator && indexGenerator) { try { const manifests = await componentManifestGenerator( @@ -222,7 +222,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption // NOTE: we don't send the 'build' event for test runs as we want to be as fast as possible. if (!core?.disableTelemetry && !options.test) { try { - const generator = await initializedStoryIndexGenerator; + const generator = await storyIndexGeneratorPromise; const storyIndex = await generator?.getIndex(); const payload: any = { precedingUpgrade: await getPrecedingUpgrade(),