diff --git a/README.md b/README.md index 190ee0898cac..fdb6bbb2855d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Backers on Open Collective - Sponsors on Open Collective + Sponsors on Open Collective Official Twitter Handle @@ -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