diff --git a/README.md b/README.md
index 190ee0898cac..fdb6bbb2855d 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@
-
+
@@ -215,7 +215,32 @@ Storybook is organized as a monorepo. Useful scripts include:
Become a sponsor to have your logo and website URL on our README on Github. \[[Become a sponsor](https://opencollective.com/storybook#sponsor)]
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
### Backers
diff --git a/code/addons/interactions/src/components/MethodCall.tsx b/code/addons/interactions/src/components/MethodCall.tsx
index 6830a3d539b6..4b6896eb4bf5 100644
--- a/code/addons/interactions/src/components/MethodCall.tsx
+++ b/code/addons/interactions/src/components/MethodCall.tsx
@@ -214,7 +214,9 @@ export const ArrayNode = ({
}
const nodes = value
.slice(0, 3)
- .map((v) => );
+ .map((v, index) => (
+
+ ));
const nodelist = interleave(nodes, , );
if (value.length <= 3) {
return [{nodelist}];
diff --git a/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts b/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts
index ccc4de4fad3f..f8978d512973 100644
--- a/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts
+++ b/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts
@@ -1,10 +1,28 @@
import * as webpackReal from 'webpack';
import { logger } from 'storybook/internal/node-logger';
-import type { Options } from 'storybook/internal/types';
+import type { Options, PresetProperty } from 'storybook/internal/types';
import type { Configuration } from 'webpack';
import { loadCustomWebpackConfig } from '@storybook/core-webpack';
import { createDefaultWebpackConfig } from '../preview/base-webpack.config';
+export const swc: PresetProperty<'swc'> = (config: Record): Record => {
+ return {
+ ...config,
+ env: {
+ ...(config?.env ?? {}),
+ targets: config?.env?.targets ?? {
+ chrome: 100,
+ safari: 15,
+ firefox: 91,
+ },
+ // Transpiles the broken syntax to the closest non-broken modern syntax.
+ // E.g. it won't transpile parameter destructuring in Safari
+ // which would break how we detect if the mount context property is used in the play function.
+ bugfixes: config?.env?.bugfixes ?? true,
+ },
+ };
+};
+
export async function webpack(config: Configuration, options: Options) {
const { configDir, configType, presets } = options;
diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts
index da1b21a5b00b..217a081e66f0 100644
--- a/code/core/src/core-server/utils/StoryIndexGenerator.ts
+++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts
@@ -120,14 +120,16 @@ export class StoryIndexGenerator {
this.specifiers.map(async (specifier) => {
const pathToSubIndex = {} as SpecifierStoriesCache;
- const fullGlob = slash(
- path.join(this.options.workingDir, specifier.directory, specifier.files)
- );
+ const fullGlob = slash(path.join(specifier.directory, specifier.files));
// Dynamically import globby because it is a pure ESM module
const { globby } = await import('globby');
- const files = await globby(fullGlob, commonGlobOptions(fullGlob));
+ const files = await globby(fullGlob, {
+ absolute: true,
+ cwd: this.options.workingDir,
+ ...commonGlobOptions(fullGlob),
+ });
if (files.length === 0) {
once.warn(
diff --git a/code/core/src/core-server/utils/__search-files-tests__/src/ignored.js b/code/core/src/core-server/utils/__search-files-tests__/src/ignored.js
deleted file mode 100644
index e69de29bb2d1..000000000000
diff --git a/code/core/src/core-server/withTelemetry.ts b/code/core/src/core-server/withTelemetry.ts
index 68ac7668ae9b..7be18cbe15fd 100644
--- a/code/core/src/core-server/withTelemetry.ts
+++ b/code/core/src/core-server/withTelemetry.ts
@@ -13,7 +13,7 @@ type TelemetryOptions = {
};
const promptCrashReports = async () => {
- if (process.env.CI && process.env.NODE_ENV !== 'test') {
+ if (process.env.CI) {
return undefined;
}
diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts
index e531693e689a..b13e42175a45 100644
--- a/code/core/src/csf-tools/ConfigFile.ts
+++ b/code/core/src/csf-tools/ConfigFile.ts
@@ -319,7 +319,7 @@ export class ConfigFile {
return _getPathProperties(rest, exported);
}
- getFieldValue(path: string[]) {
+ getFieldValue(path: string[]): T | undefined {
const node = this.getFieldNode(path);
if (node) {
const { code } = generate(node, {});
diff --git a/code/core/src/preview-api/modules/preview-web/Preview.tsx b/code/core/src/preview-api/modules/preview-web/Preview.tsx
index 9c9778f98801..3df5f84cdf05 100644
--- a/code/core/src/preview-api/modules/preview-web/Preview.tsx
+++ b/code/core/src/preview-api/modules/preview-web/Preview.tsx
@@ -20,7 +20,22 @@ import {
UPDATE_GLOBALS,
UPDATE_STORY_ARGS,
} from '@storybook/core/core-events';
+import type { CleanupCallback } from '@storybook/csf';
import type { Channel } from '@storybook/core/channels';
+import type {
+ Renderer,
+ Args,
+ Globals,
+ ModuleImportFn,
+ RenderContextCallbacks,
+ RenderToCanvas,
+ PreparedStory,
+ StoryIndex,
+ ProjectAnnotations,
+ StoryId,
+ StoryRenderOptions,
+ SetGlobalsPayload,
+} from '@storybook/core/types';
import {
CalledPreviewMethodBeforeInitializationError,
MissingRenderToCanvasError,
@@ -34,16 +49,6 @@ import { StoryRender } from './render/StoryRender';
import type { CsfDocsRender } from './render/CsfDocsRender';
import type { MdxDocsRender } from './render/MdxDocsRender';
import { mountDestructured } from './render/mount-utils';
-import type { Args, Globals, Renderer, StoryId } from '@storybook/core/types';
-import type {
- ModuleImportFn,
- PreparedStory,
- ProjectAnnotations,
- RenderToCanvas,
-} from '@storybook/core/types';
-import type { RenderContextCallbacks, StoryRenderOptions } from '@storybook/core/types';
-import type { StoryIndex } from '@storybook/core/types';
-import type { SetGlobalsPayload } from '@storybook/core/types';
const { fetch } = global;
@@ -69,6 +74,8 @@ export class Preview {
// project annotations. Once the index loads, it is stored on the store and this will get unset.
private projectAnnotationsBeforeInitialization?: ProjectAnnotations;
+ private beforeAllCleanup?: CleanupCallback | void;
+
protected storeInitializationPromise: Promise;
protected resolveStoreInitializationPromise!: () => void;
@@ -120,6 +127,7 @@ export class Preview {
try {
const projectAnnotations = await this.getProjectAnnotationsOrRenderError();
+ await this.runBeforeAllHook(projectAnnotations);
await this.initializeWithProjectAnnotations(projectAnnotations);
} catch (err) {
this.rejectStoreInitializationPromise(err as Error);
@@ -168,6 +176,16 @@ export class Preview {
}
}
+ async runBeforeAllHook(projectAnnotations: ProjectAnnotations) {
+ try {
+ await this.beforeAllCleanup?.();
+ this.beforeAllCleanup = await projectAnnotations.beforeAll?.();
+ } catch (err) {
+ this.renderPreviewEntryError('Error in beforeAll hook:', err as Error);
+ throw err;
+ }
+ }
+
async getStoryIndexFromServer() {
const result = await fetch(STORY_INDEX_PATH);
if (result.status === 200) {
@@ -223,6 +241,8 @@ export class Preview {
this.getProjectAnnotations = getProjectAnnotations;
const projectAnnotations = await this.getProjectAnnotationsOrRenderError();
+ await this.runBeforeAllHook(projectAnnotations);
+
if (!this.storyStoreValue) {
await this.initializeWithProjectAnnotations(projectAnnotations);
return;
diff --git a/code/core/src/preview-api/modules/store/StoryStore.ts b/code/core/src/preview-api/modules/store/StoryStore.ts
index b68553018815..0e9935762248 100644
--- a/code/core/src/preview-api/modules/store/StoryStore.ts
+++ b/code/core/src/preview-api/modules/store/StoryStore.ts
@@ -27,7 +27,7 @@ import {
normalizeProjectAnnotations,
prepareContext,
} from './csf';
-import type { CleanupCallback } from '@storybook/csf';
+import type { Canvas, CleanupCallback } from '@storybook/csf';
import type {
BoundStory,
CSFFile,
@@ -375,7 +375,7 @@ export class StoryStore {
step: (label, play) => story.runStep(label, play, context),
context: null!,
mount: null!,
- canvas: {},
+ canvas: {} as Canvas,
viewMode: 'story',
} as StoryContext;
diff --git a/code/core/src/preview-api/modules/store/csf/beforeAll.test.ts b/code/core/src/preview-api/modules/store/csf/beforeAll.test.ts
new file mode 100644
index 000000000000..e3f0200dd19a
--- /dev/null
+++ b/code/core/src/preview-api/modules/store/csf/beforeAll.test.ts
@@ -0,0 +1,71 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { composeBeforeAllHooks } from './beforeAll';
+
+const calls: string[] = [];
+
+beforeEach(() => {
+ calls.length = 0;
+});
+
+const basicHook = (label: string) => () => {
+ calls.push(label);
+};
+const asyncHook = (label: string, delay: number) => async () => {
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ calls.push(label);
+};
+const cleanupHook = (label: string) => () => {
+ calls.push(label);
+ return () => {
+ calls.push(label + ' cleanup');
+ };
+};
+const asyncCleanupHook = (label: string, delay: number) => () => {
+ calls.push(label);
+ return async () => {
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ calls.push(label + ' cleanup');
+ };
+};
+
+describe('composeBeforeAllHooks', () => {
+ it('should return a composed hook function', async () => {
+ await composeBeforeAllHooks([basicHook('one'), basicHook('two'), basicHook('three')])();
+ expect(calls).toEqual(['one', 'two', 'three']);
+ });
+
+ it('should execute cleanups in reverse order', async () => {
+ const cleanup = await composeBeforeAllHooks([
+ cleanupHook('one'),
+ cleanupHook('two'),
+ cleanupHook('three'),
+ ])();
+ expect(calls).toEqual(['one', 'two', 'three']);
+
+ await cleanup?.();
+ expect(calls).toEqual(['one', 'two', 'three', 'three cleanup', 'two cleanup', 'one cleanup']);
+ });
+
+ it('should execute async hooks in sequence', async () => {
+ await composeBeforeAllHooks([
+ asyncHook('one', 10),
+ asyncHook('two', 100),
+ asyncHook('three', 10),
+ ])();
+ expect(calls).toEqual(['one', 'two', 'three']);
+ });
+
+ it('should execute async cleanups in reverse order', async () => {
+ const hooks = [
+ asyncCleanupHook('one', 10),
+ asyncCleanupHook('two', 100),
+ asyncCleanupHook('three', 10),
+ ];
+
+ const cleanup = await composeBeforeAllHooks(hooks)();
+ expect(calls).toEqual(['one', 'two', 'three']);
+
+ await cleanup?.();
+ expect(calls).toEqual(['one', 'two', 'three', 'three cleanup', 'two cleanup', 'one cleanup']);
+ });
+});
diff --git a/code/core/src/preview-api/modules/store/csf/beforeAll.ts b/code/core/src/preview-api/modules/store/csf/beforeAll.ts
new file mode 100644
index 000000000000..50f3fd9881f7
--- /dev/null
+++ b/code/core/src/preview-api/modules/store/csf/beforeAll.ts
@@ -0,0 +1,17 @@
+import { type BeforeAll, type CleanupCallback } from '@storybook/csf';
+
+// Execute all the hooks in sequence, and return a function that will execute cleanups in reverse order
+export const composeBeforeAllHooks = (hooks: BeforeAll[]): BeforeAll => {
+ return async () => {
+ const cleanups: CleanupCallback[] = [];
+ for (const hook of hooks) {
+ const cleanup = await hook();
+ if (cleanup) cleanups.unshift(cleanup);
+ }
+ return async () => {
+ for (const cleanup of cleanups) {
+ await cleanup();
+ }
+ };
+ };
+};
diff --git a/code/core/src/preview-api/modules/store/csf/composeConfigs.test.ts b/code/core/src/preview-api/modules/store/csf/composeConfigs.test.ts
index 4e55cff4a669..0d8f0e2912b0 100644
--- a/code/core/src/preview-api/modules/store/csf/composeConfigs.test.ts
+++ b/code/core/src/preview-api/modules/store/csf/composeConfigs.test.ts
@@ -22,6 +22,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: [],
+ beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
@@ -49,6 +50,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: [],
+ beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
@@ -80,6 +82,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: [],
+ beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
@@ -117,6 +120,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: { x: '2', y: '1', z: '2', obj: { a: '2', c: '2' } },
loaders: [],
+ beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
@@ -157,6 +161,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: { x: '2', y: '1', z: '2', obj: { a: '2', c: '2' } },
loaders: [],
+ beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
@@ -188,6 +193,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: ['1', '2', '3', '4'],
+ beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
@@ -219,6 +225,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: ['1', '2', '3'],
+ beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
@@ -246,6 +253,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: [],
+ beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
@@ -274,6 +282,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: [],
+ beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
@@ -305,6 +314,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: [],
+ beforeAll: expect.any(Function),
beforeEach: [],
render: 'render-2',
renderToCanvas: 'renderToCanvas-2',
@@ -314,6 +324,24 @@ describe('composeConfigs', () => {
});
});
+ it('composes beforeAll hooks', async () => {
+ const one = vi.fn();
+ const two = vi.fn();
+ const three = vi.fn();
+
+ const { beforeAll } = composeConfigs([
+ { beforeAll: one },
+ { beforeAll: two },
+ { beforeAll: three },
+ ]);
+
+ await beforeAll?.();
+
+ expect(one).toHaveBeenCalled();
+ expect(two).toHaveBeenCalled();
+ expect(three).toHaveBeenCalled();
+ });
+
it('composes step runners', () => {
const fn = vi.fn();
diff --git a/code/core/src/preview-api/modules/store/csf/composeConfigs.ts b/code/core/src/preview-api/modules/store/csf/composeConfigs.ts
index 18eac58a98d8..29eb8ec56c36 100644
--- a/code/core/src/preview-api/modules/store/csf/composeConfigs.ts
+++ b/code/core/src/preview-api/modules/store/csf/composeConfigs.ts
@@ -1,10 +1,11 @@
+import type { ModuleExports, ProjectAnnotations } from '@storybook/core/types';
+import type { Renderer } from '@storybook/core/types';
import { global } from '@storybook/global';
import { combineParameters } from '../parameters';
import { composeStepRunners } from './stepRunners';
import { normalizeArrays } from './normalizeArrays';
-import type { ModuleExports, ProjectAnnotations } from '@storybook/core/types';
-import type { Renderer } from '@storybook/core/types';
+import { composeBeforeAllHooks } from './beforeAll';
export function getField(
moduleExportList: ModuleExports[],
@@ -43,6 +44,7 @@ export function composeConfigs(
): ProjectAnnotations {
const allArgTypeEnhancers = getArrayField(moduleExportList, 'argTypesEnhancers');
const stepRunners = getField(moduleExportList, 'runStep');
+ const beforeAllHooks = getArrayField(moduleExportList, 'beforeAll');
return {
parameters: combineParameters(...getField(moduleExportList, 'parameters')),
@@ -60,6 +62,7 @@ export function composeConfigs(
initialGlobals: getObjectField(moduleExportList, 'initialGlobals'),
globalTypes: getObjectField(moduleExportList, 'globalTypes'),
loaders: getArrayField(moduleExportList, 'loaders'),
+ beforeAll: composeBeforeAllHooks(beforeAllHooks),
beforeEach: getArrayField(moduleExportList, 'beforeEach'),
render: getSingletonField(moduleExportList, 'render'),
renderToCanvas: getSingletonField(moduleExportList, 'renderToCanvas'),
diff --git a/code/core/src/preview-api/modules/store/csf/portable-stories.test.ts b/code/core/src/preview-api/modules/store/csf/portable-stories.test.ts
index 28b2778a8473..080a741677a5 100644
--- a/code/core/src/preview-api/modules/store/csf/portable-stories.test.ts
+++ b/code/core/src/preview-api/modules/store/csf/portable-stories.test.ts
@@ -26,6 +26,43 @@ describe('composeStory', () => {
tags: ['metaTag'],
};
+ it('should return composed beforeAll as part of project annotations', async () => {
+ const after = vi.fn();
+ const before = vi.fn((n) => () => after(n));
+ const finalAnnotations = setProjectAnnotations([
+ { beforeAll: () => before(1) },
+ { beforeAll: () => before(2) },
+ { beforeAll: () => before(3) },
+ ]);
+
+ const cleanup = await finalAnnotations.beforeAll?.();
+ expect(before.mock.calls).toEqual([[1], [2], [3]]);
+
+ await cleanup?.();
+ expect(after.mock.calls).toEqual([[3], [2], [1]]);
+ });
+
+ it('should return composed project annotations via setProjectAnnotations', () => {
+ const firstAnnotations = {
+ parameters: { foo: 'bar' },
+ tags: ['autodocs'],
+ };
+
+ const secondAnnotations = {
+ args: {
+ foo: 'bar',
+ },
+ };
+ const finalAnnotations = setProjectAnnotations([firstAnnotations, secondAnnotations]);
+ expect(finalAnnotations).toEqual(
+ expect.objectContaining({
+ parameters: { foo: 'bar' },
+ args: { foo: 'bar' },
+ tags: ['autodocs'],
+ })
+ );
+ });
+
it('should compose project annotations in all module formats', () => {
setProjectAnnotations([defaultExportAnnotations, namedExportAnnotations]);
diff --git a/code/core/src/preview-api/modules/store/csf/portable-stories.ts b/code/core/src/preview-api/modules/store/csf/portable-stories.ts
index d7caadb81f51..279fe7dfc366 100644
--- a/code/core/src/preview-api/modules/store/csf/portable-stories.ts
+++ b/code/core/src/preview-api/modules/store/csf/portable-stories.ts
@@ -38,8 +38,10 @@ const DEFAULT_STORY_NAME = 'Unnamed Story';
function extractAnnotation(
annotation: NamedOrDefaultProjectAnnotations
) {
+ if (!annotation) return {};
// support imports such as
// import * as annotations from '.storybook/preview'
+ // import annotations from '.storybook/preview'
// in both cases: 1 - the file has a default export; 2 - named exports only
return 'default' in annotation ? annotation.default : annotation;
}
@@ -48,9 +50,11 @@ export function setProjectAnnotations(
projectAnnotations:
| NamedOrDefaultProjectAnnotations
| NamedOrDefaultProjectAnnotations[]
-) {
+): ProjectAnnotations {
const annotations = Array.isArray(projectAnnotations) ? projectAnnotations : [projectAnnotations];
globalProjectAnnotations = composeConfigs(annotations.map(extractAnnotation));
+
+ return globalProjectAnnotations;
}
const cleanups: CleanupCallback[] = [];
@@ -287,10 +291,10 @@ export function createPlaywrightTest(
throw new Error(dedent`
Portable stories in Playwright CT only work when referencing JSX elements.
Please use JSX format for your components such as:
-
+
instead of:
await mount(MyComponent, { props: { foo: 'bar' } })
-
+
do:
await mount()
diff --git a/code/core/src/types/modules/indexer.ts b/code/core/src/types/modules/indexer.ts
index a34a2422766c..ceb3bf915e51 100644
--- a/code/core/src/types/modules/indexer.ts
+++ b/code/core/src/types/modules/indexer.ts
@@ -3,7 +3,7 @@ import type { StoryId, ComponentTitle, StoryName, Parameters, Tag, Path } from '
type ExportName = string;
type MetaId = string;
-interface StoriesSpecifier {
+export interface StoriesSpecifier {
/**
* When auto-titling, what to prefix all generated titles with (default: '')
*/
diff --git a/code/core/template/stories/preview.ts b/code/core/template/stories/preview.ts
index 5e1c83e54439..85ed96d67755 100644
--- a/code/core/template/stories/preview.ts
+++ b/code/core/template/stories/preview.ts
@@ -1,5 +1,24 @@
+/* eslint-disable no-underscore-dangle */
import type { PartialStoryFn, StoryContext } from '@storybook/core/types';
+declare global {
+ interface Window {
+ __STORYBOOK_BEFORE_ALL_CALLS__: number;
+ __STORYBOOK_BEFORE_ALL_CLEANUP_CALLS__: number;
+ }
+}
+
+// This is used to test the hooks in our E2E tests (look for storybook-hooks.spec.ts)
+globalThis.parent.__STORYBOOK_BEFORE_ALL_CALLS__ = 0;
+globalThis.parent.__STORYBOOK_BEFORE_ALL_CLEANUP_CALLS__ = 0;
+
+export const beforeAll = async () => {
+ globalThis.parent.__STORYBOOK_BEFORE_ALL_CALLS__ += 1;
+ return () => {
+ globalThis.parent.__STORYBOOK_BEFORE_ALL_CLEANUP_CALLS__ += 1;
+ };
+};
+
export const parameters = {
projectParameter: 'projectParameter',
storyObject: {
diff --git a/code/e2e-tests/storybook-hooks.spec.ts b/code/e2e-tests/storybook-hooks.spec.ts
new file mode 100644
index 000000000000..97e2926eb920
--- /dev/null
+++ b/code/e2e-tests/storybook-hooks.spec.ts
@@ -0,0 +1,78 @@
+/* eslint-disable no-underscore-dangle */
+import { join } from 'node:path';
+import { promises as fs } from 'node:fs';
+import { test } from '@playwright/test';
+import { SbPage } from './util';
+
+declare global {
+ interface Window {
+ __STORYBOOK_BEFORE_ALL_CALLS__: number;
+ __STORYBOOK_BEFORE_ALL_CLEANUP_CALLS__: number;
+ }
+}
+
+const storybookUrl = process.env.STORYBOOK_URL || 'http://localhost:8001';
+const templateName = process.env.STORYBOOK_TEMPLATE_NAME || '';
+const sandboxDir =
+ process.env.STORYBOOK_SANDBOX_DIR ||
+ join(__dirname, '..', '..', 'sandbox', 'react-vite-default-ts');
+const previewFilePath = join(sandboxDir, '.storybook', 'preview.ts');
+const isStorybookDev = process.env.STORYBOOK_TYPE === 'dev';
+
+test.describe('Storybook hooks', () => {
+ test.skip(); // TODO remove
+ test.skip(
+ !templateName?.includes('react-vite/default-ts'),
+ 'Only run this test for react-vite sandbox'
+ );
+ test.beforeEach(async ({ page }) => {
+ await page.goto(storybookUrl);
+ await new SbPage(page).waitUntilLoaded();
+ });
+
+ test('should call beforeAll upon loading Storybook', async ({ page }, { titlePath }) => {
+ const sbPage = new SbPage(page);
+
+ await sbPage.navigateToStory('example/button', 'primary');
+
+ await page.waitForFunction(
+ () =>
+ window.__STORYBOOK_BEFORE_ALL_CALLS__ === 1 &&
+ window.__STORYBOOK_BEFORE_ALL_CLEANUP_CALLS__ === 0,
+ undefined,
+ { timeout: 2000 }
+ );
+ });
+
+ test('should call beforeAll and cleanup on HMR', async ({ page }, { titlePath }) => {
+ test.skip(!isStorybookDev, 'HMR is only applicable in dev mode');
+ const sbPage = new SbPage(page);
+
+ await sbPage.navigateToStory('example/button', 'primary');
+
+ const originalContent = await fs.readFile(previewFilePath, 'utf8');
+ const newContent = `${originalContent}\nconsole.log('Written from E2E test: ${titlePath}');`;
+
+ await page.waitForFunction(
+ () =>
+ window.__STORYBOOK_BEFORE_ALL_CALLS__ === 1 &&
+ window.__STORYBOOK_BEFORE_ALL_CLEANUP_CALLS__ === 0,
+ undefined,
+ { timeout: 2000 }
+ );
+
+ // Save the file to trigger HMR, then wait for it
+ await fs.writeFile(previewFilePath, newContent);
+
+ await page.waitForFunction(
+ () =>
+ window.__STORYBOOK_BEFORE_ALL_CALLS__ === 2 &&
+ window.__STORYBOOK_BEFORE_ALL_CLEANUP_CALLS__ === 1,
+ undefined,
+ { timeout: 2000 }
+ );
+
+ // Restore the original content of the preview file
+ await fs.writeFile(previewFilePath, originalContent);
+ });
+});
diff --git a/code/frameworks/angular/src/client/angular-beta/AbstractRenderer.ts b/code/frameworks/angular/src/client/angular-beta/AbstractRenderer.ts
index 61823a60e7f9..86de5a16897a 100644
--- a/code/frameworks/angular/src/client/angular-beta/AbstractRenderer.ts
+++ b/code/frameworks/angular/src/client/angular-beta/AbstractRenderer.ts
@@ -42,17 +42,6 @@ export abstract class AbstractRenderer {
// Observable to change the properties dynamically without reloading angular module&component
protected storyProps$: Subject;
- constructor() {
- if (typeof NODE_ENV === 'string' && NODE_ENV !== 'development') {
- try {
- // platform should be set after enableProdMode()
- enableProdMode();
- } catch (e) {
- console.debug(e);
- }
- }
- }
-
protected abstract beforeFullRender(domNode?: HTMLElement): Promise;
/**
diff --git a/code/frameworks/angular/src/client/docs/sourceDecorator.ts b/code/frameworks/angular/src/client/docs/sourceDecorator.ts
index 74d313467a5d..31a84d273499 100644
--- a/code/frameworks/angular/src/client/docs/sourceDecorator.ts
+++ b/code/frameworks/angular/src/client/docs/sourceDecorator.ts
@@ -32,14 +32,20 @@ export const sourceDecorator = (
const channel = addons.getChannel();
const { props, template, userDefinedTemplate } = story;
- const { component, argTypes } = context;
+ const { component, argTypes, parameters } = context;
let toEmit: string;
useEffect(() => {
if (toEmit) {
const { id, unmappedArgs } = context;
- channel.emit(SNIPPET_RENDERED, { id, args: unmappedArgs, source: toEmit, format: 'angular' });
+ const format = parameters?.docs?.source?.format ?? true;
+ channel.emit(SNIPPET_RENDERED, {
+ id,
+ args: unmappedArgs,
+ source: toEmit,
+ format: format === true ? 'angular' : format,
+ });
}
});
diff --git a/code/frameworks/angular/src/server/angular-cli-webpack.js b/code/frameworks/angular/src/server/angular-cli-webpack.js
index f4e667fee6ee..e560ceed6596 100644
--- a/code/frameworks/angular/src/server/angular-cli-webpack.js
+++ b/code/frameworks/angular/src/server/angular-cli-webpack.js
@@ -61,7 +61,6 @@ exports.getWebpackConfig = async (baseConfig, { builderOptions, builderContext }
// Default options
index: 'noop-index',
main: 'noop-main',
- outputPath: 'noop-out',
// Options provided by user
...builderOptions,
@@ -71,7 +70,7 @@ exports.getWebpackConfig = async (baseConfig, { builderOptions, builderContext }
outputPath:
typeof builderOptions.outputPath === 'string'
? builderOptions.outputPath
- : builderOptions.outputPath?.base,
+ : builderOptions.outputPath?.base ?? 'noop-out',
// Fixed options
optimization: false,
diff --git a/code/frameworks/angular/src/server/plugins/storybook-normalize-angular-entry-plugin.js b/code/frameworks/angular/src/server/plugins/storybook-normalize-angular-entry-plugin.js
index 9d36fc8893e7..a9cb645c9eed 100644
--- a/code/frameworks/angular/src/server/plugins/storybook-normalize-angular-entry-plugin.js
+++ b/code/frameworks/angular/src/server/plugins/storybook-normalize-angular-entry-plugin.js
@@ -1,39 +1,42 @@
const PLUGIN_NAME = 'storybook-normalize-angular-entry-plugin';
-
/**
- * Angular's webpack plugin @angular-devkit/build-angular/src/webpack/plugins/styles-webpack-plugin.js
- * transforms the original webpackOptions.entry point array into a structure like this:
- *
- * ```js
- * {
- * main: {
- * import: [...]
- * },
- *
- * styles: {
- * import: [...]
- * },
- * }
- * ```
- *
- * Storybook throws an __webpack_require__.nmd is not a function error, when another runtime bundle (styles~runtime.iframe.bundle.js) is loaded.
- * To prevent this error, we have to normalize the entry point to only generate one runtime bundle (main~runtime.iframe.bundle.js).
+ * This plugin is designed to modify the Webpack configuration for Storybook projects that use Angular,
+ * specifically to prevent multiple runtime bundle issues by merging 'main' and 'styles' entry points.
+ * It ensures that only one runtime bundle is generated to avoid '__webpack_require__.nmd is not a function' errors.
*/
+
export default class StorybookNormalizeAngularEntryPlugin {
constructor(options) {
- this.options = options;
+ this.options = options; // Store options if future configuration is needed
}
apply(compiler) {
compiler.hooks.environment.tap(PLUGIN_NAME, () => {
- const webpackOptions = compiler.options;
- const entry =
- typeof webpackOptions.entry === 'function' ? webpackOptions.entry() : webpackOptions.entry;
+ // Store the original entry configuration
+ const originalEntry = compiler.options.entry;
- webpackOptions.entry = async () => {
- const entryResult = await entry;
+ // Overwrite the entry configuration to normalize it
+ compiler.options.entry = async () => {
+ let entryResult;
+
+ // Handle the case where the original entry is a function, which could be async
+ if (typeof originalEntry === 'function') {
+ try {
+ // Execute the function and await its result, in case it returns a promise
+ entryResult = await originalEntry();
+ } catch (error) {
+ // Log the error and re-throw it to ensure it's visible and doesn't silently fail
+ console.error('Failed to execute the entry function:', error);
+ throw error;
+ }
+ } else {
+ // If the original entry is not a function, use it as is
+ entryResult = originalEntry;
+ }
- if (entryResult.main && entryResult.styles) {
+ // Merge 'main' and 'styles' entries if both exist
+ if (entryResult && entryResult.main && entryResult.styles) {
+ // Combine and deduplicate imports from 'main' and 'styles'
return {
main: {
import: Array.from(
@@ -43,7 +46,8 @@ export default class StorybookNormalizeAngularEntryPlugin {
};
}
- return entry;
+ // If not both 'main' and 'styles' are present, return the original or resolved entry result
+ return entryResult;
};
});
diff --git a/code/frameworks/nextjs/src/portable-stories.ts b/code/frameworks/nextjs/src/portable-stories.ts
index 2fe0fe2230ef..52aacbb592bc 100644
--- a/code/frameworks/nextjs/src/portable-stories.ts
+++ b/code/frameworks/nextjs/src/portable-stories.ts
@@ -10,6 +10,7 @@ import type {
StoryAnnotationsOrFn,
Store_CSFExports,
StoriesWithPartialProps,
+ NamedOrDefaultProjectAnnotations,
} from 'storybook/internal/types';
// ! ATTENTION: This needs to be a relative import so it gets prebundled. This is to avoid ESM issues in Nextjs + Jest setups
@@ -34,9 +35,11 @@ import type { ReactRenderer, Meta } from '@storybook/react';
* @param projectAnnotations - e.g. (import projectAnnotations from '../.storybook/preview')
*/
export function setProjectAnnotations(
- projectAnnotations: ProjectAnnotations | ProjectAnnotations[]
-) {
- originalSetProjectAnnotations(projectAnnotations);
+ projectAnnotations:
+ | NamedOrDefaultProjectAnnotations
+ | NamedOrDefaultProjectAnnotations[]
+): ProjectAnnotations {
+ return originalSetProjectAnnotations(projectAnnotations);
}
// This will not be necessary once we have auto preset loading
diff --git a/code/lib/blocks/src/blocks/Source.tsx b/code/lib/blocks/src/blocks/Source.tsx
index 9c68b8a058ab..fda733fc8a5b 100644
--- a/code/lib/blocks/src/blocks/Source.tsx
+++ b/code/lib/blocks/src/blocks/Source.tsx
@@ -1,7 +1,7 @@
import type { ComponentProps, FC } from 'react';
import React, { useContext } from 'react';
-import type { StoryId, PreparedStory, ModuleExport, Args } from '@storybook/core/types';
-import { SourceType } from '@storybook/core/docs-tools';
+import type { StoryId, PreparedStory, ModuleExport, Args } from 'storybook/internal/types';
+import { SourceType } from 'storybook/internal/docs-tools';
import type { SourceCodeProps } from '../components/Source';
import { Source as PureSource, SourceError } from '../components/Source';
diff --git a/code/lib/blocks/src/examples/Button.stories.tsx b/code/lib/blocks/src/examples/Button.stories.tsx
index a49f88f5d8f8..597f5240ebfc 100644
--- a/code/lib/blocks/src/examples/Button.stories.tsx
+++ b/code/lib/blocks/src/examples/Button.stories.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable local-rules/no-uncategorized-errors */
import type { Meta, StoryObj } from '@storybook/react';
import { within, fireEvent, expect } from '@storybook/test';
import React from 'react';
@@ -116,10 +117,18 @@ export const ErrorStory: Story = {
render: () => {
const err = new Error('Rendering problem');
// force stack for consistency in capture
- err.stack = err.stack
- .replace(/\d+:\d+(:\d+)?/g, `000:0001`)
- .replace(/v=[^:]+/g, 'v=00000000')
- .replace(/[^/]+\.js/g, 'file.js');
+ err.stack = `
+ at undecoratedStoryFn (/sb-preview/file.js:000:0001)
+ at hookified (/sb-preview/file.js:000:0001)
+ at defaultDecorateStory (/sb-preview/file.js:000:0001)
+ at jsxDecorator (/assets/file.js:000:0001)
+ at hookified (/sb-preview/file.js:000:0001)
+ at decorateStory (/sb-preview/file.js:000:0001)
+ at renderWithHooks (/sb-preview/file.js:000:0001)
+ at mountIndeterminateComponent (/assets/file.js:000:0001)
+ at beginWork (/assets/file.js:000:0001)
+ at longMockedPath (/node_modules/.cache/storybook/da6a511058d185c3c92ed7790fc88078d8a947a8d0ac75815e8fd5704bcd4baa/sb-vite/deps/file.js?v=00000000:000:0001)
+ `;
throw err;
},
args: { label: 'Button' },
diff --git a/code/lib/cli/src/generators/configure.test.ts b/code/lib/cli/src/generators/configure.test.ts
index cd54a95c34f1..d0e593a3c026 100644
--- a/code/lib/cli/src/generators/configure.test.ts
+++ b/code/lib/cli/src/generators/configure.test.ts
@@ -29,7 +29,7 @@ describe('configureMain', () => {
expect(mainConfigContent).toMatchInlineSnapshot(`
"/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
- stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs)'],
+ stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [],
framework: {
name: '@storybook/react-vite',
@@ -95,7 +95,7 @@ describe('configureMain', () => {
/** @type { import('@storybook/react-webpack5').StorybookConfig } */
const config = {
- stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs)'],
+ stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
path.dirname(require.resolve(path.join('@storybook/addon-links', 'package.json'))),
path.dirname(require.resolve(path.join('@storybook/addon-essentials', 'package.json'))),
diff --git a/code/lib/cli/src/generators/configure.ts b/code/lib/cli/src/generators/configure.ts
index a2c30f5198f5..73c57dfc8fa3 100644
--- a/code/lib/cli/src/generators/configure.ts
+++ b/code/lib/cli/src/generators/configure.ts
@@ -49,8 +49,6 @@ const sanitizeFramework = (framework: string) => {
return matches[0];
};
-const typescriptExtensions = ['ts', 'tsx', 'mts', 'cts'];
-
export async function configureMain({
addons,
extensions = ['js', 'jsx', 'mjs', 'ts', 'tsx'],
@@ -59,14 +57,10 @@ export async function configureMain({
prefixes = [],
...custom
}: ConfigureMainOptions) {
- const isLanguageJavascript = language === SupportedLanguage.JAVASCRIPT;
- const filteredExtensions = extensions.filter((extension) =>
- isLanguageJavascript ? !typescriptExtensions.includes(extension) : true
- );
const srcPath = path.resolve(storybookConfigFolder, '../src');
const prefix = (await fse.pathExists(srcPath)) ? '../src' : '../stories';
const config = {
- stories: [`${prefix}/**/*.mdx`, `${prefix}/**/*.stories.@(${filteredExtensions.join('|')})`],
+ stories: [`${prefix}/**/*.mdx`, `${prefix}/**/*.stories.@(${extensions.join('|')})`],
addons,
...custom,
};
diff --git a/code/lib/cli/src/sandbox-templates.ts b/code/lib/cli/src/sandbox-templates.ts
index cf3020564924..30773df41ea6 100644
--- a/code/lib/cli/src/sandbox-templates.ts
+++ b/code/lib/cli/src/sandbox-templates.ts
@@ -1,4 +1,5 @@
-import type { StorybookConfigRaw } from '@storybook/core/types';
+import type { StoriesEntry, StorybookConfigRaw } from '@storybook/core/types';
+import type { ConfigFile } from '@storybook/core/csf-tools';
export type SkippableTask =
| 'smoke-test'
@@ -70,7 +71,9 @@ export type Template = {
*/
modifications?: {
skipTemplateStories?: boolean;
- mainConfig?: Partial;
+ mainConfig?:
+ | Partial
+ | ((config: ConfigFile) => Partial);
testBuild?: boolean;
disableDocs?: boolean;
extraDependencies?: string[];
@@ -92,7 +95,10 @@ type BaseTemplates = Template & {
const baseTemplates = {
'cra/default-js': {
name: 'Create React App Latest (Webpack | JavaScript)',
- script: 'npx create-react-app {{beforeDir}}',
+ script: `
+ npx create-react-app {{beforeDir}} && cd {{beforeDir}} && \
+ jq '.browserslist.production[0] = ">0.9%"' package.json > tmp.json && mv tmp.json package.json
+ `,
expected: {
// TODO: change this to @storybook/cra once that package is created
framework: '@storybook/react-webpack5',
@@ -100,10 +106,27 @@ const baseTemplates = {
builder: '@storybook/builder-webpack5',
},
skipTasks: ['e2e-tests-dev', 'bench'],
+ modifications: {
+ mainConfig: (config) => {
+ const stories = config.getFieldValue>(['stories']);
+ return {
+ stories: stories?.map((s) => {
+ if (typeof s === 'string') {
+ return s.replace(/\|(tsx?|ts)\b|\b(tsx?|ts)\|/g, '');
+ } else {
+ return s;
+ }
+ }),
+ };
+ },
+ },
},
'cra/default-ts': {
name: 'Create React App Latest (Webpack | TypeScript)',
- script: 'npx create-react-app {{beforeDir}} --template typescript',
+ script: `
+ npx create-react-app {{beforeDir}} --template typescript && cd {{beforeDir}} && \
+ jq '.browserslist.production[0] = ">0.9%"' package.json > tmp.json && mv tmp.json package.json
+ `,
// Re-enable once https://github.com/storybookjs/storybook/issues/19351 is fixed.
skipTasks: ['smoke-test', 'bench'],
expected: {
diff --git a/code/renderers/react/src/portable-stories.tsx b/code/renderers/react/src/portable-stories.tsx
index 357dfd521719..e562d68963d4 100644
--- a/code/renderers/react/src/portable-stories.tsx
+++ b/code/renderers/react/src/portable-stories.tsx
@@ -37,8 +37,8 @@ export function setProjectAnnotations(
projectAnnotations:
| NamedOrDefaultProjectAnnotations
| NamedOrDefaultProjectAnnotations[]
-) {
- originalSetProjectAnnotations(projectAnnotations);
+): ProjectAnnotations {
+ return originalSetProjectAnnotations(projectAnnotations);
}
// This will not be necessary once we have auto preset loading
diff --git a/code/renderers/svelte/src/portable-stories.ts b/code/renderers/svelte/src/portable-stories.ts
index 563f1527cd2e..51c4ed0d6c82 100644
--- a/code/renderers/svelte/src/portable-stories.ts
+++ b/code/renderers/svelte/src/portable-stories.ts
@@ -10,6 +10,7 @@ import type {
Store_CSFExports,
StoriesWithPartialProps,
ComposedStoryFn,
+ NamedOrDefaultProjectAnnotations,
} from 'storybook/internal/types';
import * as svelteProjectAnnotations from './entry-preview';
@@ -52,9 +53,11 @@ type MapToComposed = {
* @param projectAnnotations - e.g. (import projectAnnotations from '../.storybook/preview')
*/
export function setProjectAnnotations(
- projectAnnotations: ProjectAnnotations | ProjectAnnotations[]
-) {
- originalSetProjectAnnotations(projectAnnotations);
+ projectAnnotations:
+ | NamedOrDefaultProjectAnnotations
+ | NamedOrDefaultProjectAnnotations[]
+): ProjectAnnotations {
+ return originalSetProjectAnnotations(projectAnnotations);
}
// This will not be necessary once we have auto preset loading
diff --git a/code/renderers/svelte/src/public-types.test.ts b/code/renderers/svelte/src/public-types.test.ts
index f92e3d736d5b..53bf88178b3e 100644
--- a/code/renderers/svelte/src/public-types.test.ts
+++ b/code/renderers/svelte/src/public-types.test.ts
@@ -1,7 +1,7 @@
// this file tests Typescript types that's why there are no assertions
import { describe, it } from 'vitest';
import { satisfies } from 'storybook/internal/common';
-import type { ComponentAnnotations, StoryAnnotations } from 'storybook/internal/types';
+import type { Canvas, ComponentAnnotations, StoryAnnotations } from 'storybook/internal/types';
import { expectTypeOf } from 'expect-type';
import type { ComponentProps, SvelteComponent } from 'svelte';
import Button from './__test__/Button.svelte';
@@ -235,7 +235,7 @@ describe('Story args can be inferred', () => {
it('mount accepts a Component and props', () => {
const Basic: StoryObj