From 13e3869b70a9f16cf856ca829737f540cc07f5a9 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Dec 2024 10:22:04 +0100 Subject: [PATCH 01/13] React: Use Act wrapper in Storybook for component rendering --- code/renderers/react/src/act-compat.ts | 6 +- code/renderers/react/src/entry-preview.tsx | 68 ++++++++++++++++ code/renderers/react/src/portable-stories.tsx | 78 +------------------ code/renderers/react/src/renderToCanvas.tsx | 17 ++-- 4 files changed, 87 insertions(+), 82 deletions(-) diff --git a/code/renderers/react/src/act-compat.ts b/code/renderers/react/src/act-compat.ts index 36e56712e02b..a28b81a61b7e 100644 --- a/code/renderers/react/src/act-compat.ts +++ b/code/renderers/react/src/act-compat.ts @@ -40,15 +40,15 @@ function withGlobalActEnvironment(actImplementation: (callback: () => void) => P return result; }); if (callbackNeedsToBeAwaited) { - const thenable: Promise = actResult; + const thenable = actResult; return { then: (resolve: (param: any) => void, reject: (param: any) => void) => { thenable.then( - (returnValue) => { + (returnValue: any) => { setReactActEnvironment(previousActEnvironment); resolve(returnValue); }, - (error) => { + (error: any) => { setReactActEnvironment(previousActEnvironment); reject(error); } diff --git a/code/renderers/react/src/entry-preview.tsx b/code/renderers/react/src/entry-preview.tsx index 08d625b5729d..c42679d0f3cf 100644 --- a/code/renderers/react/src/entry-preview.tsx +++ b/code/renderers/react/src/entry-preview.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import semver from 'semver'; +import { act, getReactActEnvironment, setReactActEnvironment } from './act-compat'; import type { Decorator } from './public-types'; export const parameters = { renderer: 'react' }; @@ -28,3 +29,70 @@ export const decorators: Decorator[] = [ ); }, ]; + +export const beforeAll = async () => { + try { + // copied from + // https://github.com/testing-library/react-testing-library/blob/3dcd8a9649e25054c0e650d95fca2317b7008576/src/pure.js + const { configure } = await import('@storybook/test'); + + configure({ + unstable_advanceTimersWrapper: (cb) => { + return act(cb); + }, + // For more context about why we need disable act warnings in waitFor: + // https://github.com/reactwg/react-18/discussions/102 + asyncWrapper: async (cb) => { + const previousActEnvironment = getReactActEnvironment(); + setReactActEnvironment(false); + try { + const result = await cb(); + // Drain microtask queue. + // Otherwise we'll restore the previous act() environment, before we resolve the `waitFor` call. + // The caller would have no chance to wrap the in-flight Promises in `act()` + await new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 0); + + if (jestFakeTimersAreEnabled()) { + // @ts-expect-error global jest + jest.advanceTimersByTime(0); + } + }); + + return result; + } finally { + setReactActEnvironment(previousActEnvironment); + } + }, + eventWrapper: (cb) => { + let result; + act(() => { + result = cb(); + return result; + }); + return result; + }, + }); + } catch (e) { + // no-op + // @storybook/test might not be available + } +}; + +/** The function is used to configure jest's fake timers in environments where React's act is enabled */ +function jestFakeTimersAreEnabled() { + // @ts-expect-error global jest + if (typeof jest !== 'undefined' && jest !== null) { + return ( + // legacy timers + + // eslint-disable-next-line no-underscore-dangle + (setTimeout as any)._isMockFunction === true || // modern timers + Object.prototype.hasOwnProperty.call(setTimeout, 'clock') + ); + } + + return false; +} diff --git a/code/renderers/react/src/portable-stories.tsx b/code/renderers/react/src/portable-stories.tsx index 7b906c9f4bde..ca29c8c7de72 100644 --- a/code/renderers/react/src/portable-stories.tsx +++ b/code/renderers/react/src/portable-stories.tsx @@ -17,7 +17,6 @@ import type { StoryAnnotationsOrFn, } from 'storybook/internal/types'; -import { act, getReactActEnvironment, setReactActEnvironment } from './act-compat'; import * as reactProjectAnnotations from './entry-preview'; import type { Meta } from './public-types'; import type { ReactRenderer } from './types'; @@ -55,67 +54,14 @@ export function setProjectAnnotations( // This will not be necessary once we have auto preset loading export const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations = { ...reactProjectAnnotations, - beforeAll: async function reactBeforeAll() { - try { - // copied from - // https://github.com/testing-library/react-testing-library/blob/3dcd8a9649e25054c0e650d95fca2317b7008576/src/pure.js - const { configure } = await import('@storybook/test'); - - configure({ - unstable_advanceTimersWrapper: (cb) => { - return act(cb); - }, - // For more context about why we need disable act warnings in waitFor: - // https://github.com/reactwg/react-18/discussions/102 - asyncWrapper: async (cb) => { - const previousActEnvironment = getReactActEnvironment(); - setReactActEnvironment(false); - try { - const result = await cb(); - // Drain microtask queue. - // Otherwise we'll restore the previous act() environment, before we resolve the `waitFor` call. - // The caller would have no chance to wrap the in-flight Promises in `act()` - await new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, 0); - - if (jestFakeTimersAreEnabled()) { - // @ts-expect-error global jest - jest.advanceTimersByTime(0); - } - }); - - return result; - } finally { - setReactActEnvironment(previousActEnvironment); - } - }, - eventWrapper: (cb) => { - let result; - act(() => { - result = cb(); - }); - return result; - }, - }); - } catch (e) { - // no-op - // @storybook/test might not be available - } - }, renderToCanvas: async (renderContext, canvasElement) => { if (renderContext.storyContext.testingLibraryRender == null) { - let unmount: () => void; - - await act(async () => { - unmount = await reactProjectAnnotations.renderToCanvas(renderContext, canvasElement); - }); + // eslint-disable-next-line no-underscore-dangle + renderContext.storyContext.parameters.__isPortableStory = true; + const unmount = await reactProjectAnnotations.renderToCanvas(renderContext, canvasElement); return async () => { - await act(() => { - unmount(); - }); + await unmount(); }; } const { @@ -209,19 +155,3 @@ export function composeStories; } - -/** The function is used to configure jest's fake timers in environments where React's act is enabled */ -function jestFakeTimersAreEnabled() { - // @ts-expect-error global jest - if (typeof jest !== 'undefined' && jest !== null) { - return ( - // legacy timers - - // eslint-disable-next-line no-underscore-dangle - (setTimeout as any)._isMockFunction === true || // modern timers - Object.prototype.hasOwnProperty.call(setTimeout, 'clock') - ); - } - - return false; -} diff --git a/code/renderers/react/src/renderToCanvas.tsx b/code/renderers/react/src/renderToCanvas.tsx index 3ae6136f9582..4ae1acbb7fe9 100644 --- a/code/renderers/react/src/renderToCanvas.tsx +++ b/code/renderers/react/src/renderToCanvas.tsx @@ -5,7 +5,7 @@ import type { RenderContext } from 'storybook/internal/types'; import { global } from '@storybook/global'; -import { getReactActEnvironment } from './act-compat'; +import { act } from './act-compat'; import type { ReactRenderer, StoryContext } from './types'; const { FRAMEWORK_OPTIONS } = global; @@ -58,9 +58,10 @@ export async function renderToCanvas( const { renderElement, unmountElement } = await import('@storybook/react-dom-shim'); const Story = unboundStoryFn as FC>; - const isActEnabled = getReactActEnvironment(); + // eslint-disable-next-line no-underscore-dangle + const isPortableStory = storyContext.parameters.__isPortableStory; - const content = isActEnabled ? ( + const content = isPortableStory ? ( ) : ( @@ -80,7 +81,13 @@ export async function renderToCanvas( unmountElement(canvasElement); } - await renderElement(element, canvasElement, storyContext?.parameters?.react?.rootOptions); + await act(async () => { + await renderElement(element, canvasElement, storyContext?.parameters?.react?.rootOptions); + }); - return () => unmountElement(canvasElement); + return async () => { + await act(() => { + unmountElement(canvasElement); + }); + }; } From 9ea0238314a317c92c7ffcee7c30542e586c9c26 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 13 Dec 2024 12:35:35 +0100 Subject: [PATCH 02/13] Update act implementation for production environment and adjust NODE_ENV handling in build scripts --- code/renderers/react/src/act-compat.ts | 5 ++++- scripts/prepare/bundle.ts | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/code/renderers/react/src/act-compat.ts b/code/renderers/react/src/act-compat.ts index a28b81a61b7e..7d64e0f7c3be 100644 --- a/code/renderers/react/src/act-compat.ts +++ b/code/renderers/react/src/act-compat.ts @@ -68,4 +68,7 @@ function withGlobalActEnvironment(actImplementation: (callback: () => void) => P }; } -export const act = withGlobalActEnvironment(reactAct); +export const act = + process.env.NODE_ENV === 'production' + ? (cb: (...args: any[]) => any) => cb() + : withGlobalActEnvironment(reactAct); diff --git a/scripts/prepare/bundle.ts b/scripts/prepare/bundle.ts index d3a671034d17..c9431c22047d 100755 --- a/scripts/prepare/bundle.ts +++ b/scripts/prepare/bundle.ts @@ -117,6 +117,11 @@ const run = async ({ cwd, flags }: { cwd: string; flags: string[] }) => { clean: false, ...(dtsBuild === 'esm' ? dtsConfig : {}), platform: platform || 'browser', + define: { + // tsup replaces 'process.env.NODE_ENV' during build time. We don't want to do this. Instead, the builders (vite/webpack) should replace it + // Then, the variable can be set accordingly in dev/build mode + 'process.env.NODE_ENV': 'process.env.NODE_ENV', + }, esbuildPlugins: platform === 'node' ? [] From a52aec21485c87a9939f8a4425f94d346a44fe06 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Sat, 14 Dec 2024 23:48:33 +0100 Subject: [PATCH 03/13] Apply NODE_ENV development for react-based projects --- code/builders/builder-webpack5/src/index.ts | 8 +++++--- code/frameworks/experimental-nextjs-vite/src/preset.ts | 5 +++++ code/frameworks/nextjs/src/config/webpack.ts | 1 + code/frameworks/react-vite/src/preset.ts | 5 +++++ code/frameworks/react-webpack5/src/preset.ts | 9 +++++++++ 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/code/builders/builder-webpack5/src/index.ts b/code/builders/builder-webpack5/src/index.ts index a8af6e699ad4..71be9982973a 100644 --- a/code/builders/builder-webpack5/src/index.ts +++ b/code/builders/builder-webpack5/src/index.ts @@ -16,13 +16,15 @@ import prettyTime from 'pretty-hrtime'; import sirv from 'sirv'; import { corePath } from 'storybook/core-path'; import type { Configuration, Stats, StatsOptions } from 'webpack'; -import webpack, { ProgressPlugin } from 'webpack'; +import webpackDep, { DefinePlugin, ProgressPlugin } from 'webpack'; import webpackDevMiddleware from 'webpack-dev-middleware'; import webpackHotMiddleware from 'webpack-hot-middleware'; export * from './types'; export * from './preview/virtual-module-mapping'; +export const WebpackDefinePlugin = DefinePlugin; + export const printDuration = (startTime: [number, number]) => prettyTime(process.hrtime(startTime)) .replace(' ms', ' milliseconds') @@ -51,8 +53,8 @@ export const executor = { get: async (options: Options) => { const version = ((await options.presets.apply('webpackVersion')) || '5') as string; const webpackInstance = - (await options.presets.apply<{ default: typeof webpack }>('webpackInstance'))?.default || - webpack; + (await options.presets.apply<{ default: typeof webpackDep }>('webpackInstance'))?.default || + webpackDep; checkWebpackVersion({ version }, '5', 'builder-webpack5'); return webpackInstance; }, diff --git a/code/frameworks/experimental-nextjs-vite/src/preset.ts b/code/frameworks/experimental-nextjs-vite/src/preset.ts index 633f62a5dceb..78c9e0a4f277 100644 --- a/code/frameworks/experimental-nextjs-vite/src/preset.ts +++ b/code/frameworks/experimental-nextjs-vite/src/preset.ts @@ -43,5 +43,10 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, option const nextDir = nextConfigPath ? path.dirname(nextConfigPath) : undefined; plugins.push(vitePluginStorybookNextjs({ dir: nextDir })); + reactConfig.define = { + ...reactConfig.define, + 'process.env.NODE_ENV': JSON.stringify('development'), + }; + return reactConfig; }; diff --git a/code/frameworks/nextjs/src/config/webpack.ts b/code/frameworks/nextjs/src/config/webpack.ts index 4b10922c9df9..01076e3ed95e 100644 --- a/code/frameworks/nextjs/src/config/webpack.ts +++ b/code/frameworks/nextjs/src/config/webpack.ts @@ -63,6 +63,7 @@ const setupRuntimeConfig = async ( serverRuntimeConfig: {}, publicRuntimeConfig: nextConfig.publicRuntimeConfig, }), + 'process.env.NODE_ENV': JSON.stringify('development'), }; const newNextLinkBehavior = (nextConfig.experimental as any)?.newNextLinkBehavior; diff --git a/code/frameworks/react-vite/src/preset.ts b/code/frameworks/react-vite/src/preset.ts index a01721dadacc..72a6b7f84887 100644 --- a/code/frameworks/react-vite/src/preset.ts +++ b/code/frameworks/react-vite/src/preset.ts @@ -51,5 +51,10 @@ export const viteFinal: NonNullable = async (confi ); } + config.define = { + ...config.define, + 'process.env.NODE_ENV': JSON.stringify('development'), + }; + return config; }; diff --git a/code/frameworks/react-webpack5/src/preset.ts b/code/frameworks/react-webpack5/src/preset.ts index 9e233459c10b..2234fe584c4a 100644 --- a/code/frameworks/react-webpack5/src/preset.ts +++ b/code/frameworks/react-webpack5/src/preset.ts @@ -2,6 +2,8 @@ import { dirname, join } from 'node:path'; import type { PresetProperty } from 'storybook/internal/types'; +import { WebpackDefinePlugin } from '@storybook/builder-webpack5'; + import type { StorybookConfig } from './types'; const getAbsolutePath = (input: I): I => @@ -31,5 +33,12 @@ export const webpack: StorybookConfig['webpack'] = async (config) => { ...config.resolve?.alias, '@storybook/react': getAbsolutePath('@storybook/react'), }; + config.plugins = [ + // @ts-expect-error TODO + ...config.plugins, + new WebpackDefinePlugin({ + NODE_ENV: JSON.stringify('development'), + }), + ]; return config; }; From 8719f068af753eeeccc593d1db146756342f0e09 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 18 Dec 2024 15:27:49 +0100 Subject: [PATCH 04/13] Switch orders of stories --- .../template/stories/rendering.stories.ts | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/code/core/template/stories/rendering.stories.ts b/code/core/template/stories/rendering.stories.ts index b0d0a7b06a1d..77e9432ef512 100644 --- a/code/core/template/stories/rendering.stories.ts +++ b/code/core/template/stories/rendering.stories.ts @@ -42,6 +42,38 @@ export const ForceRemount = { tags: ['!test', '!vitest'], }; +let loadedLabel = 'Initial'; + +/** + * This story demonstrates what happens when rendering (loaders) have side effects, and can possibly + * interleave with each other Triggering multiple force remounts quickly should only result in a + * single remount in the end and the label should be 'Loaded. Click Me' at the end. If loaders are + * interleaving it would result in a label of 'Error: Interleaved loaders. Click Me' Similarly, + * changing args rapidly should only cause one rerender at a time, producing the same result. + */ +export const SlowLoader = { + parameters: { + chromatic: { disable: true }, + }, + loaders: [ + async () => { + loadedLabel = 'Loading...'; + await new Promise((resolve) => setTimeout(resolve, 1000)); + loadedLabel = loadedLabel === 'Loading...' ? 'Loaded.' : 'Error: Interleaved loaders.'; + return { label: loadedLabel }; + }, + ], + decorators: [ + (storyFn: any, context: any) => + storyFn({ + args: { + ...context.args, + label: `${context.loaded.label} ${context.args.label}`, + }, + }), + ], +}; + export const ChangeArgs = { play: async ({ canvasElement, id }: PlayFunctionContext) => { const channel = globalThis.__STORYBOOK_ADDONS_CHANNEL__; @@ -74,35 +106,3 @@ export const ChangeArgs = { await expect(button).toHaveFocus(); }, }; - -let loadedLabel = 'Initial'; - -/** - * This story demonstrates what happens when rendering (loaders) have side effects, and can possibly - * interleave with each other Triggering multiple force remounts quickly should only result in a - * single remount in the end and the label should be 'Loaded. Click Me' at the end. If loaders are - * interleaving it would result in a label of 'Error: Interleaved loaders. Click Me' Similarly, - * changing args rapidly should only cause one rerender at a time, producing the same result. - */ -export const SlowLoader = { - parameters: { - chromatic: { disable: true }, - }, - loaders: [ - async () => { - loadedLabel = 'Loading...'; - await new Promise((resolve) => setTimeout(resolve, 1000)); - loadedLabel = loadedLabel === 'Loading...' ? 'Loaded.' : 'Error: Interleaved loaders.'; - return { label: loadedLabel }; - }, - ], - decorators: [ - (storyFn: any, context: any) => - storyFn({ - args: { - ...context.args, - label: `${context.loaded.label} ${context.args.label}`, - }, - }), - ], -}; From 4bf0f02922187953a01e13e4c90990e29da3af0e Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 18 Dec 2024 20:37:52 +0100 Subject: [PATCH 05/13] Add developmentModeForBuild feature and update NODE_ENV handling - Introduced a new feature flag `developmentModeForBuild` to enhance testability in built Storybooks. - Updated build configurations to set `process.env.NODE_ENV` to `development` when the feature is enabled. - Cleaned up unnecessary definitions in various preset and configuration files. --- MIGRATION.md | 40 +++++++++++----- code/builders/builder-vite/src/build.ts | 13 ++++- code/builders/builder-vite/src/index.ts | 2 - .../src/preview/iframe-webpack.config.ts | 4 +- code/core/src/types/modules/core-common.ts | 2 + .../experimental-nextjs-vite/src/preset.ts | 5 -- code/frameworks/nextjs/src/config/webpack.ts | 1 - code/frameworks/react-vite/src/preset.ts | 5 -- .../cli-storybook/src/sandbox-templates.ts | 47 ++++++++++++++++--- 9 files changed, 85 insertions(+), 34 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 28b4bc5d6ddc..7ab60984a274 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,6 +1,7 @@

Migration

- [From version 8.4.x to 8.5.x](#from-version-84x-to-85x) + - [Introducing features.developmentModeForBuild](#introducing-featuresdevelopmentmodeforbuild) - [Added source code panel to docs](#added-source-code-panel-to-docs) - [Addon-a11y: Component test integration](#addon-a11y-component-test-integration) - [Addon-a11y: Deprecated `parameters.a11y.manual`](#addon-a11y-deprecated-parametersa11ymanual) @@ -110,17 +111,17 @@ - [Tab addons cannot manually route, Tool addons can filter their visibility via tabId](#tab-addons-cannot-manually-route-tool-addons-can-filter-their-visibility-via-tabid) - [Removed `config` preset](#removed-config-preset-1) - [From version 7.5.0 to 7.6.0](#from-version-750-to-760) - - [CommonJS with Vite is deprecated](#commonjs-with-vite-is-deprecated) - - [Using implicit actions during rendering is deprecated](#using-implicit-actions-during-rendering-is-deprecated) - - [typescript.skipBabel deprecated](#typescriptskipbabel-deprecated) - - [Primary doc block accepts of prop](#primary-doc-block-accepts-of-prop) - - [Addons no longer need a peer dependency on React](#addons-no-longer-need-a-peer-dependency-on-react) + - [CommonJS with Vite is deprecated](#commonjs-with-vite-is-deprecated) + - [Using implicit actions during rendering is deprecated](#using-implicit-actions-during-rendering-is-deprecated) + - [typescript.skipBabel deprecated](#typescriptskipbabel-deprecated) + - [Primary doc block accepts of prop](#primary-doc-block-accepts-of-prop) + - [Addons no longer need a peer dependency on React](#addons-no-longer-need-a-peer-dependency-on-react) - [From version 7.4.0 to 7.5.0](#from-version-740-to-750) - - [`storyStoreV6` and `storiesOf` is deprecated](#storystorev6-and-storiesof-is-deprecated) - - [`storyIndexers` is replaced with `experimental_indexers`](#storyindexers-is-replaced-with-experimental_indexers) + - [`storyStoreV6` and `storiesOf` is deprecated](#storystorev6-and-storiesof-is-deprecated) + - [`storyIndexers` is replaced with `experimental_indexers`](#storyindexers-is-replaced-with-experimental_indexers) - [From version 7.0.0 to 7.2.0](#from-version-700-to-720) - - [Addon API is more type-strict](#addon-api-is-more-type-strict) - - [Addon-controls hideNoControlsWarning parameter is deprecated](#addon-controls-hidenocontrolswarning-parameter-is-deprecated) + - [Addon API is more type-strict](#addon-api-is-more-type-strict) + - [Addon-controls hideNoControlsWarning parameter is deprecated](#addon-controls-hidenocontrolswarning-parameter-is-deprecated) - [From version 6.5.x to 7.0.0](#from-version-65x-to-700) - [7.0 breaking changes](#70-breaking-changes) - [Dropped support for Node 15 and below](#dropped-support-for-node-15-and-below) @@ -146,7 +147,7 @@ - [Deploying build artifacts](#deploying-build-artifacts) - [Dropped support for file URLs](#dropped-support-for-file-urls) - [Serving with nginx](#serving-with-nginx) - - [Ignore story files from node\_modules](#ignore-story-files-from-node_modules) + - [Ignore story files from node_modules](#ignore-story-files-from-node_modules) - [7.0 Core changes](#70-core-changes) - [7.0 feature flags removed](#70-feature-flags-removed) - [Story context is prepared before for supporting fine grained updates](#story-context-is-prepared-before-for-supporting-fine-grained-updates) @@ -160,7 +161,7 @@ - [Addon-interactions: Interactions debugger is now default](#addon-interactions-interactions-debugger-is-now-default) - [7.0 Vite changes](#70-vite-changes) - [Vite builder uses Vite config automatically](#vite-builder-uses-vite-config-automatically) - - [Vite cache moved to node\_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook) + - [Vite cache moved to node_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook) - [7.0 Webpack changes](#70-webpack-changes) - [Webpack4 support discontinued](#webpack4-support-discontinued) - [Babel mode v7 exclusively](#babel-mode-v7-exclusively) @@ -211,7 +212,7 @@ - [Dropped addon-docs manual babel configuration](#dropped-addon-docs-manual-babel-configuration) - [Dropped addon-docs manual configuration](#dropped-addon-docs-manual-configuration) - [Autoplay in docs](#autoplay-in-docs) - - [Removed STORYBOOK\_REACT\_CLASSES global](#removed-storybook_react_classes-global) + - [Removed STORYBOOK_REACT_CLASSES global](#removed-storybook_react_classes-global) - [7.0 Deprecations and default changes](#70-deprecations-and-default-changes) - [storyStoreV7 enabled by default](#storystorev7-enabled-by-default) - [`Story` type deprecated](#story-type-deprecated) @@ -426,6 +427,21 @@ ## From version 8.4.x to 8.5.x +### Introducing features.developmentModeForBuild + +As part of our ongoing efforts to improve the testability and debuggability of Storybook, we are introducing a new feature flag: `developmentModeForBuild`. This feature flag allows you to set `process.env.NODE_ENV` to `development` in built Storybooks, enabling development-related optimizations that are typically disabled in production builds. + +In development mode, React and other libraries often include additional checks and warnings that help catch potential issues early. These checks are usually stripped out in production builds to optimize performance. However, when running tests or debugging issues in a built Storybook, having these additional checks can be incredibly valuable. One such feature is React's `act`, which ensures that all updates related to a test are processed and applied before making assertions. `act` is crucial for reliable and predictable test results, but it only works correctly when `NODE_ENV` is set to `development`. + +```js +// main.js +export default { + features: { + developmentModeForBuild: true, + }, +}; +``` + ### Added source code panel to docs Starting in 8.5, Storybook Docs (`@storybook/addon-docs`) automatically adds a new addon panel to stories that displays a source snippet beneath each story. This works similarly to the existing [source snippet doc block](https://storybook.js.org/docs/writing-docs/doc-blocks#source), but in the story view. It is intended to replace the [Storysource addon](https://storybook.js.org/addons/@storybook/addon-storysource). diff --git a/code/builders/builder-vite/src/build.ts b/code/builders/builder-vite/src/build.ts index fa7d1ee4f76e..ea8705f2193f 100644 --- a/code/builders/builder-vite/src/build.ts +++ b/code/builders/builder-vite/src/build.ts @@ -20,6 +20,9 @@ export async function build(options: Options) { const config = await commonConfig(options, 'build'); config.build = mergeConfig(config, { + define: { + 'process.env.NODE_ENV': JSON.stringify('development'), + }, build: { outDir: options.outputDir, emptyOutDir: false, // do not clean before running Vite build - Storybook has already added assets in there! @@ -35,7 +38,15 @@ export async function build(options: Options) { } : {}), }, - }).build; + } as InlineConfig).build; + + if (options.features?.developmentModeForBuild) { + config.build = mergeConfig(config.build ?? {}, { + define: { + 'process.env.NODE_ENV': JSON.stringify('development'), + }, + } as InlineConfig); + } const finalConfig = await presets.apply('viteFinal', config, options); diff --git a/code/builders/builder-vite/src/index.ts b/code/builders/builder-vite/src/index.ts index 785db459cec4..7051cc116363 100644 --- a/code/builders/builder-vite/src/index.ts +++ b/code/builders/builder-vite/src/index.ts @@ -4,8 +4,6 @@ import { readFile } from 'node:fs/promises'; import { NoStatsForViteDevError } from 'storybook/internal/server-errors'; import type { Middleware, Options } from 'storybook/internal/types'; -import sirv from 'sirv'; -import { corePath } from 'storybook/core-path'; import type { ViteDevServer } from 'vite'; import { build as viteBuild } from './build'; diff --git a/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts b/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts index 763f2bf15646..aa9e34c73afe 100644 --- a/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts +++ b/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts @@ -195,7 +195,9 @@ export default async ( }), new DefinePlugin({ ...stringifyProcessEnvs(envs), - NODE_ENV: JSON.stringify(process.env.NODE_ENV), + NODE_ENV: JSON.stringify( + features?.developmentModeForBuild && isProd ? 'development' : process.env.NODE_ENV + ), }), new ProvidePlugin({ process: require.resolve('process/browser.js') }), isProd ? null : new HotModuleReplacementPlugin(), diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 3254723fc4ee..2b4624a0564b 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -380,6 +380,8 @@ export interface StorybookConfigRaw { viewportStoryGlobals?: boolean; /** Use globals & globalTypes for configuring the backgrounds addon */ backgroundsStoryGlobals?: boolean; + /** Set NODE_ENV to development in built Storybooks for better testability and debuggability */ + developmentModeForBuild?: boolean; }; build?: TestBuildConfig; diff --git a/code/frameworks/experimental-nextjs-vite/src/preset.ts b/code/frameworks/experimental-nextjs-vite/src/preset.ts index 78c9e0a4f277..633f62a5dceb 100644 --- a/code/frameworks/experimental-nextjs-vite/src/preset.ts +++ b/code/frameworks/experimental-nextjs-vite/src/preset.ts @@ -43,10 +43,5 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, option const nextDir = nextConfigPath ? path.dirname(nextConfigPath) : undefined; plugins.push(vitePluginStorybookNextjs({ dir: nextDir })); - reactConfig.define = { - ...reactConfig.define, - 'process.env.NODE_ENV': JSON.stringify('development'), - }; - return reactConfig; }; diff --git a/code/frameworks/nextjs/src/config/webpack.ts b/code/frameworks/nextjs/src/config/webpack.ts index 01076e3ed95e..4b10922c9df9 100644 --- a/code/frameworks/nextjs/src/config/webpack.ts +++ b/code/frameworks/nextjs/src/config/webpack.ts @@ -63,7 +63,6 @@ const setupRuntimeConfig = async ( serverRuntimeConfig: {}, publicRuntimeConfig: nextConfig.publicRuntimeConfig, }), - 'process.env.NODE_ENV': JSON.stringify('development'), }; const newNextLinkBehavior = (nextConfig.experimental as any)?.newNextLinkBehavior; diff --git a/code/frameworks/react-vite/src/preset.ts b/code/frameworks/react-vite/src/preset.ts index 72a6b7f84887..a01721dadacc 100644 --- a/code/frameworks/react-vite/src/preset.ts +++ b/code/frameworks/react-vite/src/preset.ts @@ -51,10 +51,5 @@ export const viteFinal: NonNullable = async (confi ); } - config.define = { - ...config.define, - 'process.env.NODE_ENV': JSON.stringify('development'), - }; - return config; }; diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index f905d2841803..dd362b7d3155 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -150,7 +150,10 @@ const baseTemplates = { }, modifications: { mainConfig: { - features: { experimentalRSC: true }, + features: { + experimentalRSC: true, + developmentModeForBuild: true, + }, }, extraDependencies: ['server-only', 'prop-types'], }, @@ -167,7 +170,10 @@ const baseTemplates = { }, modifications: { mainConfig: { - features: { experimentalRSC: true }, + features: { + experimentalRSC: true, + developmentModeForBuild: true, + }, }, extraDependencies: ['server-only', 'prop-types'], }, @@ -184,7 +190,10 @@ const baseTemplates = { }, modifications: { mainConfig: { - features: { experimentalRSC: true }, + features: { + experimentalRSC: true, + developmentModeForBuild: true, + }, }, extraDependencies: ['server-only', 'prop-types'], }, @@ -200,10 +209,13 @@ const baseTemplates = { builder: '@storybook/builder-webpack5', }, modifications: { - extraDependencies: ['server-only', 'prop-types'], mainConfig: { - features: { experimentalRSC: true }, + features: { + experimentalRSC: true, + developmentModeForBuild: true, + }, }, + extraDependencies: ['server-only', 'prop-types'], }, skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, @@ -219,7 +231,10 @@ const baseTemplates = { modifications: { mainConfig: { framework: '@storybook/experimental-nextjs-vite', - features: { experimentalRSC: true }, + features: { + experimentalRSC: true, + developmentModeForBuild: true, + }, }, extraDependencies: [ 'server-only', @@ -242,7 +257,10 @@ const baseTemplates = { modifications: { mainConfig: { framework: '@storybook/experimental-nextjs-vite', - features: { experimentalRSC: true }, + features: { + experimentalRSC: true, + developmentModeForBuild: true, + }, }, extraDependencies: [ 'server-only', @@ -263,6 +281,11 @@ const baseTemplates = { }, modifications: { extraDependencies: ['prop-types'], + mainConfig: { + features: { + developmentModeForBuild: true, + }, + }, }, skipTasks: ['e2e-tests-dev', 'bench'], }, @@ -276,6 +299,11 @@ const baseTemplates = { }, modifications: { extraDependencies: ['prop-types'], + mainConfig: { + features: { + developmentModeForBuild: true, + }, + }, }, skipTasks: ['bench'], }, @@ -302,6 +330,11 @@ const baseTemplates = { }, modifications: { extraDependencies: ['prop-types'], + mainConfig: { + features: { + developmentModeForBuild: true, + }, + }, }, skipTasks: ['e2e-tests-dev', 'bench'], }, From 26535162d4956cdf5c737ec9f02edca14f6e49b1 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 19 Dec 2024 11:04:06 +0100 Subject: [PATCH 06/13] Fix develop mode handling --- code/.storybook/main.ts | 1 + code/builders/builder-vite/src/build.ts | 12 ++++-------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index ab8af9af8f4a..623d6f5c525e 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -132,6 +132,7 @@ const config: StorybookConfig = { features: { viewportStoryGlobals: true, backgroundsStoryGlobals: true, + developmentModeForBuild: true, }, viteFinal: async (viteConfig, { configType }) => { const { mergeConfig } = await import('vite'); diff --git a/code/builders/builder-vite/src/build.ts b/code/builders/builder-vite/src/build.ts index ea8705f2193f..07ea78c7af18 100644 --- a/code/builders/builder-vite/src/build.ts +++ b/code/builders/builder-vite/src/build.ts @@ -20,9 +20,6 @@ export async function build(options: Options) { const config = await commonConfig(options, 'build'); config.build = mergeConfig(config, { - define: { - 'process.env.NODE_ENV': JSON.stringify('development'), - }, build: { outDir: options.outputDir, emptyOutDir: false, // do not clean before running Vite build - Storybook has already added assets in there! @@ -41,11 +38,10 @@ export async function build(options: Options) { } as InlineConfig).build; if (options.features?.developmentModeForBuild) { - config.build = mergeConfig(config.build ?? {}, { - define: { - 'process.env.NODE_ENV': JSON.stringify('development'), - }, - } as InlineConfig); + config.define = { + ...config.define, + 'process.env.NODE_ENV': JSON.stringify('development'), + }; } const finalConfig = await presets.apply('viteFinal', config, options); From 45971d9d3b485c54b855417dc55358eaef80a977 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 19 Dec 2024 12:29:59 +0100 Subject: [PATCH 07/13] Refactor development mode handling in Vite build configuration --- code/builders/builder-vite/src/build.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/code/builders/builder-vite/src/build.ts b/code/builders/builder-vite/src/build.ts index 07ea78c7af18..9782081c0465 100644 --- a/code/builders/builder-vite/src/build.ts +++ b/code/builders/builder-vite/src/build.ts @@ -37,15 +37,21 @@ export async function build(options: Options) { }, } as InlineConfig).build; + const finalConfig = (await presets.apply('viteFinal', config, options)) as InlineConfig; + if (options.features?.developmentModeForBuild) { - config.define = { - ...config.define, - 'process.env.NODE_ENV': JSON.stringify('development'), - }; + finalConfig.plugins?.push({ + name: 'storybook:define-env', + config: () => { + return { + define: { + 'process.env.NODE_ENV': JSON.stringify('development'), + }, + }; + }, + }); } - const finalConfig = await presets.apply('viteFinal', config, options); - const turbosnapPluginName = 'rollup-plugin-turbosnap'; const hasTurbosnapPlugin = finalConfig.plugins && (await hasVitePlugins(finalConfig.plugins, [turbosnapPluginName])); From 0203747b0821bc2b3f44215fb5b79aea901cc93c Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 20 Dec 2024 14:15:23 +0100 Subject: [PATCH 08/13] Add documentation --- ...fig-features-development-mode-for-build.md | 25 +++++++++++++++++++ docs/api/main-config/main-config-features.mdx | 13 ++++++++++ docs/writing-tests/accessibility-testing.mdx | 20 +++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 docs/_snippets/main-config-features-development-mode-for-build.md diff --git a/docs/_snippets/main-config-features-development-mode-for-build.md b/docs/_snippets/main-config-features-development-mode-for-build.md new file mode 100644 index 000000000000..2650c75c39ab --- /dev/null +++ b/docs/_snippets/main-config-features-development-mode-for-build.md @@ -0,0 +1,25 @@ +```js filename=".storybook/main.js" renderer="common" language="js" +export default { + // Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite) + framework: '@storybook/your-framework', + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + features: { + developmentModeForBuild: true, + }, +}; +``` + +```ts filename=".storybook/main.ts" renderer="common" language="ts" +// Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite) +import type { StorybookConfig } from '@storybook/your-framework'; + +const config: StorybookConfig = { + framework: '@storybook/your-framework', + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + features: { + developmentModeForBuild: true, + }, +}; + +export default config; +``` diff --git a/docs/api/main-config/main-config-features.mdx b/docs/api/main-config/main-config-features.mdx index 47dd9c107d49..38baac908cf6 100644 --- a/docs/api/main-config/main-config-features.mdx +++ b/docs/api/main-config/main-config-features.mdx @@ -15,6 +15,7 @@ Type: backgroundsStoryGlobals?: boolean; legacyDecoratorFileOrder?: boolean; viewportStoryGlobals?: boolean; + developmentModeForBuild?: boolean; } ``` @@ -69,3 +70,15 @@ Configures the [Viewports addon](../../essentials/viewport.mdx) to opt-in to the {/* prettier-ignore-end */} + +## `developmentModeForBuild` + +Type: `boolean` + +Set NODE_ENV to development in built Storybooks for better testability and debuggability. + +{/* prettier-ignore-start */} + + + +{/* prettier-ignore-end */} diff --git a/docs/writing-tests/accessibility-testing.mdx b/docs/writing-tests/accessibility-testing.mdx index 8fdbe571d9bc..d275839c2138 100644 --- a/docs/writing-tests/accessibility-testing.mdx +++ b/docs/writing-tests/accessibility-testing.mdx @@ -237,6 +237,26 @@ If you enabled the experimental test addon (i.e.,`@storybook/experimental-addon- + + +### The a11y addon panel does not show violations, although I know there are some + +Modern React components often use asynchronous techniques like Suspense or React Server Components (RSC) to handle complex data fetching and rendering. These components don’t immediately render their final UI state. Storybook doesn’t inherently know when an async component has fully rendered. As a result, the a11y addon sometimes run too early, before the component finishes rendering, leading to false negatives (no reported violations even if they exist). + +To address this issue, we have introduced a feature flag: developmentModeForBuild. This feature flag allows you to set process.env.NODE_ENV to development in built Storybooks, enabling development-related optimizations that are typically disabled in production builds. By enabling this feature flag, you can ensure that React’s act utility is used, which helps ensure that all updates related to a test are processed and applied before making assertions. + +#### How to enable the feature flag + +To enable this feature flag, add the following configuration to your .storybook/main. file: + +{/* prettier-ignore-start */} + + + +{/* prettier-ignore-end */} + + + **Learn about other UI tests** * [Component tests](./component-testing.mdx) for user behavior simulation From 9e94c25f69729c6b5029400904819db68dae90d5 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 20 Dec 2024 14:53:25 +0100 Subject: [PATCH 09/13] Refactor Button and Header components to use not use React's defaultProps --- code/renderers/react/template/cli/js/Button.jsx | 15 +++++++-------- code/renderers/react/template/cli/js/Header.jsx | 6 +----- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/code/renderers/react/template/cli/js/Button.jsx b/code/renderers/react/template/cli/js/Button.jsx index 5b36a6347d07..dabe38e0e82a 100644 --- a/code/renderers/react/template/cli/js/Button.jsx +++ b/code/renderers/react/template/cli/js/Button.jsx @@ -5,7 +5,13 @@ import PropTypes from 'prop-types'; import './button.css'; /** Primary UI component for user interaction */ -export const Button = ({ primary, backgroundColor, size, label, ...props }) => { +export const Button = ({ + primary = false, + backgroundColor = null, + size = 'medium', + label, + ...props +}) => { const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; return (