diff --git a/code/addons/interactions/src/preview.ts b/code/addons/interactions/src/preview.ts
index 6abc30f63b93..482b6933279f 100644
--- a/code/addons/interactions/src/preview.ts
+++ b/code/addons/interactions/src/preview.ts
@@ -1,6 +1,9 @@
import type { PlayFunction, StepLabel, StoryContext } from 'storybook/internal/types';
import { instrument } from '@storybook/instrumenter';
+// This makes sure that storybook test loaders are always loaded when addon-interactions is used
+// For 9.0 we want to merge storybook/test and addon-interactions into one addon.
+import '@storybook/test';
export const { step: runStep } = instrument(
{
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 1525b6e3e6d8..fd50bd57c2dd 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
@@ -76,7 +76,17 @@ export function setProjectAnnotations(
const annotations = Array.isArray(projectAnnotations) ? projectAnnotations : [projectAnnotations];
globalThis.globalProjectAnnotations = composeConfigs(annotations.map(extractAnnotation));
- return globalThis.globalProjectAnnotations;
+ /*
+ We must return the composition of default and global annotations here
+ To ensure that the user has the full project annotations, eg. when running
+
+ const projectAnnotations = setProjectAnnotations(...);
+ beforeAll(projectAnnotations.beforeAll)
+ */
+ return composeConfigs([
+ globalThis.defaultProjectAnnotations ?? {},
+ globalThis.globalProjectAnnotations ?? {},
+ ]);
}
const cleanups: CleanupCallback[] = [];
diff --git a/code/core/template/stories/preview.ts b/code/core/template/stories/preview.ts
index 4cd4c64abff5..bba2716864bc 100644
--- a/code/core/template/stories/preview.ts
+++ b/code/core/template/stories/preview.ts
@@ -30,14 +30,14 @@ export const parameters = {
export const loaders = [async () => ({ projectValue: 2 })];
-export const decorators = [
- (storyFn: PartialStoryFn, context: StoryContext) => {
- if (context.parameters.useProjectDecorator) {
- return storyFn({ args: { ...context.args, text: `project ${context.args.text}` } });
- }
- return storyFn();
- },
-];
+const testProjectDecorator = (storyFn: PartialStoryFn, context: StoryContext) => {
+ if (context.parameters.useProjectDecorator) {
+ return storyFn({ args: { ...context.args, text: `project ${context.args.text}` } });
+ }
+ return storyFn();
+};
+
+export const decorators = [testProjectDecorator];
export const initialGlobals = {
foo: 'fooValue',
diff --git a/code/frameworks/experimental-nextjs-vite/package.json b/code/frameworks/experimental-nextjs-vite/package.json
index e5073f6746d4..0750542cb10e 100644
--- a/code/frameworks/experimental-nextjs-vite/package.json
+++ b/code/frameworks/experimental-nextjs-vite/package.json
@@ -99,7 +99,7 @@
"@storybook/react": "workspace:*",
"@storybook/test": "workspace:*",
"styled-jsx": "5.1.6",
- "vite-plugin-storybook-nextjs": "^1.0.10"
+ "vite-plugin-storybook-nextjs": "^1.0.11"
},
"devDependencies": {
"@types/node": "^18.0.0",
diff --git a/code/frameworks/nextjs/src/config/webpack.ts b/code/frameworks/nextjs/src/config/webpack.ts
index 3860207e124e..a0ea2d47bded 100644
--- a/code/frameworks/nextjs/src/config/webpack.ts
+++ b/code/frameworks/nextjs/src/config/webpack.ts
@@ -2,7 +2,7 @@ import type { NextConfig } from 'next';
import type { Configuration as WebpackConfig } from 'webpack';
import { DefinePlugin } from 'webpack';
-import { addScopedAlias, resolveNextConfig } from '../utils';
+import { addScopedAlias, resolveNextConfig, setAlias } from '../utils';
const tryResolve = (path: string) => {
try {
@@ -22,12 +22,32 @@ export const configureConfig = async ({
const nextConfig = await resolveNextConfig({ nextConfigPath });
addScopedAlias(baseConfig, 'next/config');
+
+ // @ts-expect-error We know that alias is an object
+ if (baseConfig.resolve?.alias?.['react-dom']) {
+ // Removing the alias to react-dom to avoid conflicts with the alias we are setting
+ // because the react-dom alias is an exact match and we need to alias separate parts of react-dom
+ // in different places
+ // @ts-expect-error We know that alias is an object
+ delete baseConfig.resolve.alias?.['react-dom'];
+ }
+
if (tryResolve('next/dist/compiled/react')) {
addScopedAlias(baseConfig, 'react', 'next/dist/compiled/react');
}
+ if (tryResolve('next/dist/compiled/react-dom/cjs/react-dom-test-utils.production.js')) {
+ setAlias(
+ baseConfig,
+ 'react-dom/test-utils',
+ 'next/dist/compiled/react-dom/cjs/react-dom-test-utils.production.js'
+ );
+ }
if (tryResolve('next/dist/compiled/react-dom')) {
- addScopedAlias(baseConfig, 'react-dom', 'next/dist/compiled/react-dom');
+ setAlias(baseConfig, 'react-dom$', 'next/dist/compiled/react-dom');
+ setAlias(baseConfig, 'react-dom/client', 'next/dist/compiled/react-dom/client');
+ setAlias(baseConfig, 'react-dom/server', 'next/dist/compiled/react-dom/server');
}
+
setupRuntimeConfig(baseConfig, nextConfig);
return nextConfig;
diff --git a/code/frameworks/nextjs/src/utils.ts b/code/frameworks/nextjs/src/utils.ts
index 9c8abc6c88c8..198917513166 100644
--- a/code/frameworks/nextjs/src/utils.ts
+++ b/code/frameworks/nextjs/src/utils.ts
@@ -27,23 +27,27 @@ export const resolveNextConfig = async ({
return loadConfig(PHASE_DEVELOPMENT_SERVER, dir, undefined);
};
-// This is to help the addon in development
-// Without it, webpack resolves packages in its node_modules instead of the example's node_modules
-export const addScopedAlias = (baseConfig: WebpackConfig, name: string, alias?: string): void => {
+export function setAlias(baseConfig: WebpackConfig, name: string, alias: string) {
baseConfig.resolve ??= {};
baseConfig.resolve.alias ??= {};
const aliasConfig = baseConfig.resolve.alias;
- const scopedAlias = scopedResolve(`${alias ?? name}`);
-
if (Array.isArray(aliasConfig)) {
aliasConfig.push({
name,
- alias: scopedAlias,
+ alias,
});
} else {
- aliasConfig[name] = scopedAlias;
+ aliasConfig[name] = alias;
}
+}
+
+// This is to help the addon in development
+// Without it, webpack resolves packages in its node_modules instead of the example's node_modules
+export const addScopedAlias = (baseConfig: WebpackConfig, name: string, alias?: string): void => {
+ const scopedAlias = scopedResolve(`${alias ?? name}`);
+
+ setAlias(baseConfig, name, scopedAlias);
};
/**
@@ -64,7 +68,7 @@ export const scopedResolve = (id: string): string => {
let scopedModulePath;
try {
- // TODO: Remove in next major release (SB 8.0) and use the statement in the catch block per default instead
+ // TODO: Remove in next major release (SB 9.0) and use the statement in the catch block per default instead
scopedModulePath = require.resolve(id, { paths: [resolve()] });
} catch (e) {
scopedModulePath = require.resolve(id);
diff --git a/code/frameworks/sveltekit/src/preview.ts b/code/frameworks/sveltekit/src/preview.ts
index f93c06862c29..6eb8a816dd12 100644
--- a/code/frameworks/sveltekit/src/preview.ts
+++ b/code/frameworks/sveltekit/src/preview.ts
@@ -15,125 +15,125 @@ const normalizeHrefConfig = (hrefConfig: HrefConfig): NormalizedHrefConfig => {
return hrefConfig;
};
-export const decorators: Decorator[] = [
- (Story, ctx) => {
- const svelteKitParameters: SvelteKitParameters = ctx.parameters?.sveltekit_experimental ?? {};
- setPage(svelteKitParameters?.stores?.page);
- setNavigating(svelteKitParameters?.stores?.navigating);
- setUpdated(svelteKitParameters?.stores?.updated);
- setAfterNavigateArgument(svelteKitParameters?.navigation?.afterNavigate);
+const svelteKitMocksDecorator: Decorator = (Story, ctx) => {
+ const svelteKitParameters: SvelteKitParameters = ctx.parameters?.sveltekit_experimental ?? {};
+ setPage(svelteKitParameters?.stores?.page);
+ setNavigating(svelteKitParameters?.stores?.navigating);
+ setUpdated(svelteKitParameters?.stores?.updated);
+ setAfterNavigateArgument(svelteKitParameters?.navigation?.afterNavigate);
- onMount(() => {
- const globalClickListener = (e: MouseEvent) => {
- // we add a global click event listener and we check if there's a link in the composedPath
- const path = e.composedPath();
- const element = path.findLast((el) => el instanceof HTMLElement && el.tagName === 'A');
- if (element && element instanceof HTMLAnchorElement) {
- // if the element is an a-tag we get the href of the element
- // and compare it to the hrefs-parameter set by the user
- const to = element.getAttribute('href');
- if (!to) {
- return;
- }
- e.preventDefault();
- const defaultActionCallback = () => action('navigate')(to, e);
- if (!svelteKitParameters.hrefs) {
- defaultActionCallback();
- return;
- }
-
- let callDefaultCallback = true;
- // we loop over every href set by the user and check if the href matches
- // if it does we call the callback provided by the user and disable the default callback
- Object.entries(svelteKitParameters.hrefs).forEach(([href, hrefConfig]) => {
- const { callback, asRegex } = normalizeHrefConfig(hrefConfig);
- const isMatch = asRegex ? new RegExp(href).test(to) : to === href;
- if (isMatch) {
- callDefaultCallback = false;
- callback?.(to, e);
- }
- });
- if (callDefaultCallback) {
- defaultActionCallback();
- }
+ onMount(() => {
+ const globalClickListener = (e: MouseEvent) => {
+ // we add a global click event listener and we check if there's a link in the composedPath
+ const path = e.composedPath();
+ const element = path.findLast((el) => el instanceof HTMLElement && el.tagName === 'A');
+ if (element && element instanceof HTMLAnchorElement) {
+ // if the element is an a-tag we get the href of the element
+ // and compare it to the hrefs-parameter set by the user
+ const to = element.getAttribute('href');
+ if (!to) {
+ return;
+ }
+ e.preventDefault();
+ const defaultActionCallback = () => action('navigate')(to, e);
+ if (!svelteKitParameters.hrefs) {
+ defaultActionCallback();
+ return;
}
- };
-
- /**
- * Function that create and add listeners for the event that are emitted by the mocked
- * functions. The event name is based on the function name
- *
- * Eg. storybook:goto, storybook:invalidateAll
- *
- * @param baseModule The base module where the function lives (navigation|forms)
- * @param functions The list of functions in that module that emit events
- * @param {boolean} [defaultToAction] The list of functions in that module that emit events
- * @returns A function to remove all the listener added
- */
- function createListeners(
- baseModule: keyof SvelteKitParameters,
- functions: string[],
- defaultToAction?: boolean
- ) {
- // the array of every added listener, we can use this in the return function
- // to clean them
- const toRemove: Array<{
- eventType: string;
- listener: (event: { detail: any[] }) => void;
- }> = [];
- functions.forEach((func) => {
- // we loop over every function and check if the user actually passed
- // a function in sveltekit_experimental[baseModule][func] eg. sveltekit_experimental.navigation.goto
- const hasFunction =
- (svelteKitParameters as any)[baseModule]?.[func] &&
- (svelteKitParameters as any)[baseModule][func] instanceof Function;
- // if we default to an action we still add the listener (this will be the case for goto, invalidate, invalidateAll)
- if (hasFunction || defaultToAction) {
- // we create the listener that will just get the detail array from the custom element
- // and call the user provided function spreading this args in...this will basically call
- // the function that the user provide with the same arguments the function is invoked to
- // eg. if it calls goto("/my-route") inside the component the function sveltekit_experimental.navigation.goto
- // it provided to storybook will be called with "/my-route"
- const listener = ({ detail = [] as any[] }) => {
- const args = Array.isArray(detail) ? detail : [];
- // if it has a function in the parameters we call that function
- // otherwise we invoke the action
- const fnToCall = hasFunction
- ? (svelteKitParameters as any)[baseModule][func]
- : action(func);
- fnToCall(...args);
- };
- const eventType = `storybook:${func}`;
- toRemove.push({ eventType, listener });
- // add the listener to window
- (window.addEventListener as any)(eventType, listener);
+ let callDefaultCallback = true;
+ // we loop over every href set by the user and check if the href matches
+ // if it does we call the callback provided by the user and disable the default callback
+ Object.entries(svelteKitParameters.hrefs).forEach(([href, hrefConfig]) => {
+ const { callback, asRegex } = normalizeHrefConfig(hrefConfig);
+ const isMatch = asRegex ? new RegExp(href).test(to) : to === href;
+ if (isMatch) {
+ callDefaultCallback = false;
+ callback?.(to, e);
}
});
- return () => {
- // loop over every listener added and remove them
- toRemove.forEach(({ eventType, listener }) => {
- // @ts-expect-error apparently you can't remove a custom listener to the window with TS
- window.removeEventListener(eventType, listener);
- });
- };
+ if (callDefaultCallback) {
+ defaultActionCallback();
+ }
}
+ };
- const removeNavigationListeners = createListeners(
- 'navigation',
- ['goto', 'invalidate', 'invalidateAll', 'pushState', 'replaceState'],
- true
- );
- const removeFormsListeners = createListeners('forms', ['enhance']);
- window.addEventListener('click', globalClickListener);
+ /**
+ * Function that create and add listeners for the event that are emitted by the mocked
+ * functions. The event name is based on the function name
+ *
+ * Eg. storybook:goto, storybook:invalidateAll
+ *
+ * @param baseModule The base module where the function lives (navigation|forms)
+ * @param functions The list of functions in that module that emit events
+ * @param {boolean} [defaultToAction] The list of functions in that module that emit events
+ * @returns A function to remove all the listener added
+ */
+ function createListeners(
+ baseModule: keyof SvelteKitParameters,
+ functions: string[],
+ defaultToAction?: boolean
+ ) {
+ // the array of every added listener, we can use this in the return function
+ // to clean them
+ const toRemove: Array<{
+ eventType: string;
+ listener: (event: { detail: any[] }) => void;
+ }> = [];
+ functions.forEach((func) => {
+ // we loop over every function and check if the user actually passed
+ // a function in sveltekit_experimental[baseModule][func] eg. sveltekit_experimental.navigation.goto
+ const hasFunction =
+ (svelteKitParameters as any)[baseModule]?.[func] &&
+ (svelteKitParameters as any)[baseModule][func] instanceof Function;
+ // if we default to an action we still add the listener (this will be the case for goto, invalidate, invalidateAll)
+ if (hasFunction || defaultToAction) {
+ // we create the listener that will just get the detail array from the custom element
+ // and call the user provided function spreading this args in...this will basically call
+ // the function that the user provide with the same arguments the function is invoked to
+ // eg. if it calls goto("/my-route") inside the component the function sveltekit_experimental.navigation.goto
+ // it provided to storybook will be called with "/my-route"
+ const listener = ({ detail = [] as any[] }) => {
+ const args = Array.isArray(detail) ? detail : [];
+ // if it has a function in the parameters we call that function
+ // otherwise we invoke the action
+ const fnToCall = hasFunction
+ ? (svelteKitParameters as any)[baseModule][func]
+ : action(func);
+ fnToCall(...args);
+ };
+ const eventType = `storybook:${func}`;
+ toRemove.push({ eventType, listener });
+ // add the listener to window
+ (window.addEventListener as any)(eventType, listener);
+ }
+ });
return () => {
- window.removeEventListener('click', globalClickListener);
- removeNavigationListeners();
- removeFormsListeners();
+ // loop over every listener added and remove them
+ toRemove.forEach(({ eventType, listener }) => {
+ // @ts-expect-error apparently you can't remove a custom listener to the window with TS
+ window.removeEventListener(eventType, listener);
+ });
};
- });
+ }
+
+ const removeNavigationListeners = createListeners(
+ 'navigation',
+ ['goto', 'invalidate', 'invalidateAll', 'pushState', 'replaceState'],
+ true
+ );
+ const removeFormsListeners = createListeners('forms', ['enhance']);
+ window.addEventListener('click', globalClickListener);
+
+ return () => {
+ window.removeEventListener('click', globalClickListener);
+ removeNavigationListeners();
+ removeFormsListeners();
+ };
+ });
+
+ return Story();
+};
- return Story();
- },
-];
+export const decorators: Decorator[] = [svelteKitMocksDecorator];
diff --git a/code/lib/react-dom-shim/src/preventActChecks.tsx b/code/lib/react-dom-shim/src/preventActChecks.tsx
deleted file mode 100644
index f35e2fb25dc5..000000000000
--- a/code/lib/react-dom-shim/src/preventActChecks.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-export {};
-
-declare const globalThis: {
- IS_REACT_ACT_ENVIRONMENT?: boolean;
-};
-
-// TODO(9.0): We should actually wrap all those lines in `act`, but that might be a breaking change.
-// We should make that breaking change for SB 9.0
-export function preventActChecks(callback: () => void): void {
- const originalActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT;
- globalThis.IS_REACT_ACT_ENVIRONMENT = false;
- try {
- callback();
- } finally {
- globalThis.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
- }
-}
diff --git a/code/lib/react-dom-shim/src/react-16.tsx b/code/lib/react-dom-shim/src/react-16.tsx
index a1e7b1e97009..8c7b2c8f5a67 100644
--- a/code/lib/react-dom-shim/src/react-16.tsx
+++ b/code/lib/react-dom-shim/src/react-16.tsx
@@ -2,14 +2,12 @@
import type { ReactElement } from 'react';
import * as ReactDOM from 'react-dom';
-import { preventActChecks } from './preventActChecks';
-
export const renderElement = async (node: ReactElement, el: Element) => {
return new Promise((resolve) => {
- preventActChecks(() => void ReactDOM.render(node, el, () => resolve(null)));
+ ReactDOM.render(node, el, () => resolve(null));
});
};
export const unmountElement = (el: Element) => {
- preventActChecks(() => void ReactDOM.unmountComponentAtNode(el));
+ ReactDOM.unmountComponentAtNode(el);
};
diff --git a/code/lib/react-dom-shim/src/react-18.tsx b/code/lib/react-dom-shim/src/react-18.tsx
index 5eb72b20eb17..f3398fc65ff0 100644
--- a/code/lib/react-dom-shim/src/react-18.tsx
+++ b/code/lib/react-dom-shim/src/react-18.tsx
@@ -1,15 +1,21 @@
/* eslint-disable @typescript-eslint/no-unnecessary-type-constraint */
-import type { FC, ReactElement } from 'react';
+import type { ReactElement } from 'react';
import * as React from 'react';
import type { Root as ReactRoot, RootOptions } from 'react-dom/client';
import * as ReactDOM from 'react-dom/client';
-import { preventActChecks } from './preventActChecks';
-
// A map of all rendered React 18 nodes
const nodes = new Map();
-const WithCallback: FC<{ callback: () => void; children: ReactElement }> = ({
+declare const globalThis: {
+ IS_REACT_ACT_ENVIRONMENT: boolean;
+};
+
+function getIsReactActEnvironment() {
+ return globalThis.IS_REACT_ACT_ENVIRONMENT;
+}
+
+const WithCallback: React.FC<{ callback: () => void; children: ReactElement }> = ({
callback,
children,
}) => {
@@ -43,8 +49,13 @@ export const renderElement = async (node: ReactElement, el: Element, rootOptions
// Create Root Element conditionally for new React 18 Root Api
const root = await getReactRoot(el, rootOptions);
+ if (getIsReactActEnvironment()) {
+ root.render(node);
+ return;
+ }
+
const { promise, resolve } = Promise.withResolvers();
- preventActChecks(() => root.render({node}));
+ root.render({node});
return promise;
};
@@ -52,7 +63,7 @@ export const unmountElement = (el: Element, shouldUseNewRootApi?: boolean) => {
const root = nodes.get(el);
if (root) {
- preventActChecks(() => root.unmount());
+ root.unmount();
nodes.delete(el);
}
};
diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json
index 1b803593b614..cbf3dfd37c07 100644
--- a/code/renderers/react/package.json
+++ b/code/renderers/react/package.json
@@ -94,12 +94,16 @@
"require-from-string": "^2.0.2"
},
"peerDependencies": {
+ "@storybook/test": "workspace:*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"storybook": "workspace:^",
"typescript": ">= 4.2.x"
},
"peerDependenciesMeta": {
+ "@storybook/test": {
+ "optional": true
+ },
"typescript": {
"optional": true
}
diff --git a/code/renderers/react/src/__test__/Button.stories.tsx b/code/renderers/react/src/__test__/Button.stories.tsx
index bde220fdf469..0e6e0d6e8c67 100644
--- a/code/renderers/react/src/__test__/Button.stories.tsx
+++ b/code/renderers/react/src/__test__/Button.stories.tsx
@@ -103,7 +103,6 @@ export const HooksStory: CSF3Story = {
);
},
play: async ({ canvasElement, step }) => {
- console.log('start of play function');
const canvas = within(canvasElement);
await step('Step label', async () => {
const inputEl = canvas.getByTestId('input');
@@ -112,8 +111,8 @@ export const HooksStory: CSF3Story = {
await userEvent.type(inputEl, 'Hello world!');
await expect(inputEl).toHaveValue('Hello world!');
+ await expect(buttonEl).toHaveTextContent('I am clicked');
});
- console.log('end of play function');
},
};
@@ -182,6 +181,12 @@ export const MountInPlayFunction: CSF3Story<{ mockFn: (val: string) => string }>
},
};
+export const MountInPlayFunctionThrow: CSF3Story<{ mockFn: (val: string) => string }> = {
+ play: async () => {
+ throw new Error('Error thrown in play');
+ },
+};
+
export const WithActionArg: CSF3Story<{ someActionArg: HandlerFunction }> = {
args: {
someActionArg: action('some-action-arg'),
diff --git a/code/renderers/react/src/__test__/ComponentWithError.stories.tsx b/code/renderers/react/src/__test__/ComponentWithError.stories.tsx
new file mode 100644
index 000000000000..627055e2d965
--- /dev/null
+++ b/code/renderers/react/src/__test__/ComponentWithError.stories.tsx
@@ -0,0 +1,13 @@
+import type { Meta, StoryObj } from '..';
+import { ComponentWithError } from './ComponentWithError';
+
+const meta = {
+ title: 'Example/ComponentWithError',
+ component: ComponentWithError as any,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const ThrowsError: Story = {};
diff --git a/code/renderers/react/src/__test__/ComponentWithError.tsx b/code/renderers/react/src/__test__/ComponentWithError.tsx
new file mode 100644
index 000000000000..37f667cb4f2c
--- /dev/null
+++ b/code/renderers/react/src/__test__/ComponentWithError.tsx
@@ -0,0 +1,4 @@
+export function ComponentWithError() {
+ // eslint-disable-next-line local-rules/no-uncategorized-errors
+ throw new Error('Error in render');
+}
diff --git a/code/renderers/react/src/__test__/__snapshots__/portable-stories-legacy.test.tsx.snap b/code/renderers/react/src/__test__/__snapshots__/portable-stories-legacy.test.tsx.snap
index b4753327aaf1..b690349bed8d 100644
--- a/code/renderers/react/src/__test__/__snapshots__/portable-stories-legacy.test.tsx.snap
+++ b/code/renderers/react/src/__test__/__snapshots__/portable-stories-legacy.test.tsx.snap
@@ -147,6 +147,40 @@ exports[`Legacy Portable Stories API > Renders Modal story 1`] = `
+
+
+ loaded data
+
+
+ mockFn return value
+
+
+
+
+
+ loaded data
+
+
+ mockFn return value
+
+
+
diff --git a/code/renderers/react/src/__test__/portable-stories-legacy.test.tsx b/code/renderers/react/src/__test__/portable-stories-legacy.test.tsx
index 3c7321cdfe63..5567b1fd9fbc 100644
--- a/code/renderers/react/src/__test__/portable-stories-legacy.test.tsx
+++ b/code/renderers/react/src/__test__/portable-stories-legacy.test.tsx
@@ -200,7 +200,11 @@ describe('Legacy Portable Stories API', () => {
it.each(testCases)('Renders %s story', async (_storyName, Story) => {
cleanup();
- if (_storyName === 'CSF2StoryWithLocale' || _storyName === 'MountInPlayFunction') {
+ if (
+ _storyName === 'CSF2StoryWithLocale' ||
+ _storyName === 'MountInPlayFunction' ||
+ _storyName === 'MountInPlayFunctionThrow'
+ ) {
return;
}
diff --git a/code/renderers/react/src/__test__/portable-stories.test.tsx b/code/renderers/react/src/__test__/portable-stories.test.tsx
index 90346edff991..94de89e093a5 100644
--- a/code/renderers/react/src/__test__/portable-stories.test.tsx
+++ b/code/renderers/react/src/__test__/portable-stories.test.tsx
@@ -2,7 +2,7 @@
/* eslint-disable import/namespace */
import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import React from 'react';
@@ -16,23 +16,28 @@ import { expectTypeOf } from 'expect-type';
import { composeStories, composeStory, setProjectAnnotations } from '..';
import type { Button } from './Button';
-import * as stories from './Button.stories';
+import * as ButtonStories from './Button.stories';
+import * as ComponentWithErrorStories from './ComponentWithError.stories';
-setProjectAnnotations([]);
+const HooksStory = composeStory(ButtonStories.HooksStory, ButtonStories.default);
+
+const projectAnnotations = setProjectAnnotations([]);
// example with composeStories, returns an object with all stories composed with args/decorators
-const { CSF3Primary, LoaderStory, MountInPlayFunction } = composeStories(stories);
+const { CSF3Primary, LoaderStory, MountInPlayFunction, MountInPlayFunctionThrow } =
+ composeStories(ButtonStories);
+const { ThrowsError } = composeStories(ComponentWithErrorStories);
+
+beforeAll(async () => {
+ await projectAnnotations.beforeAll?.();
+});
afterEach(() => {
cleanup();
});
-declare const globalThis: {
- IS_REACT_ACT_ENVIRONMENT?: boolean;
-};
-
// example with composeStory, returns a single story composed with args/decorators
-const Secondary = composeStory(stories.CSF2Secondary, stories.default);
+const Secondary = composeStory(ButtonStories.CSF2Secondary, ButtonStories.default);
describe('renders', () => {
it('renders primary button', () => {
render(
Hello world);
@@ -60,6 +65,10 @@ describe('renders', () => {
expect(buttonElement).not.toBeNull();
});
+ it('should throw error when rendering a component with a render error', async () => {
+ await expect(() => ThrowsError.run()).rejects.toThrowError('Error in render');
+ });
+
it('should render component mounted in play function', async () => {
await MountInPlayFunction.run();
@@ -67,6 +76,10 @@ describe('renders', () => {
expect(screen.getByTestId('loaded-data').textContent).toEqual('loaded data');
});
+ it('should throw an error in play function', () => {
+ expect(() => MountInPlayFunctionThrow.run()).rejects.toThrowError('Error thrown in play');
+ });
+
it('should call and compose loaders data', async () => {
await LoaderStory.load();
const { getByTestId } = render(
);
@@ -78,10 +91,6 @@ describe('renders', () => {
});
describe('projectAnnotations', () => {
- afterEach(() => {
- cleanup();
- });
-
it('renders with default projectAnnotations', () => {
setProjectAnnotations([
{
@@ -91,7 +100,7 @@ describe('projectAnnotations', () => {
},
},
]);
- const WithEnglishText = composeStory(stories.CSF2StoryWithLocale, stories.default);
+ const WithEnglishText = composeStory(ButtonStories.CSF2StoryWithLocale, ButtonStories.default);
const { getByText } = render(
);
const buttonElement = getByText('Hello!');
expect(buttonElement).not.toBeNull();
@@ -99,24 +108,31 @@ describe('projectAnnotations', () => {
});
it('renders with custom projectAnnotations via composeStory params', () => {
- const WithPortugueseText = composeStory(stories.CSF2StoryWithLocale, stories.default, {
- initialGlobals: { locale: 'pt' },
- });
+ const WithPortugueseText = composeStory(
+ ButtonStories.CSF2StoryWithLocale,
+ ButtonStories.default,
+ {
+ initialGlobals: { locale: 'pt' },
+ }
+ );
const { getByText } = render(
);
const buttonElement = getByText('Olá!');
expect(buttonElement).not.toBeNull();
});
it('has action arg from argTypes when addon-actions annotations are added', () => {
- //@ts-expect-error our tsconfig.jsn#moduleResulution is set to 'node', which doesn't support this import
- const Story = composeStory(stories.WithActionArgType, stories.default, addonActionsPreview);
+ const Story = composeStory(
+ ButtonStories.WithActionArgType,
+ ButtonStories.default,
+ addonActionsPreview
+ );
expect(Story.args.someActionArg).toHaveProperty('isAction', true);
});
});
describe('CSF3', () => {
it('renders with inferred globalRender', () => {
- const Primary = composeStory(stories.CSF3Button, stories.default);
+ const Primary = composeStory(ButtonStories.CSF3Button, ButtonStories.default);
render(
Hello world);
const buttonElement = screen.getByText(/Hello world/i);
@@ -124,14 +140,17 @@ describe('CSF3', () => {
});
it('renders with custom render function', () => {
- const Primary = composeStory(stories.CSF3ButtonWithRender, stories.default);
+ const Primary = composeStory(ButtonStories.CSF3ButtonWithRender, ButtonStories.default);
render(
);
expect(screen.getByTestId('custom-render')).not.toBeNull();
});
it('renders with play function without canvas element', async () => {
- const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default);
+ const CSF3InputFieldFilled = composeStory(
+ ButtonStories.CSF3InputFieldFilled,
+ ButtonStories.default
+ );
await CSF3InputFieldFilled.run();
const input = screen.getByTestId('input') as HTMLInputElement;
@@ -139,7 +158,10 @@ describe('CSF3', () => {
});
it('renders with play function with canvas element', async () => {
- const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default);
+ const CSF3InputFieldFilled = composeStory(
+ ButtonStories.CSF3InputFieldFilled,
+ ButtonStories.default
+ );
const div = document.createElement('div');
document.body.appendChild(div);
@@ -153,21 +175,16 @@ describe('CSF3', () => {
});
it('renders with hooks', async () => {
- // TODO find out why act is not working here
- globalThis.IS_REACT_ACT_ENVIRONMENT = false;
- const HooksStory = composeStory(stories.HooksStory, stories.default);
-
await HooksStory.run();
const input = screen.getByTestId('input') as HTMLInputElement;
expect(input.value).toEqual('Hello world!');
- globalThis.IS_REACT_ACT_ENVIRONMENT = true;
});
});
// common in addons that need to communicate between manager and preview
it('should pass with decorators that need addons channel', () => {
- const PrimaryWithChannels = composeStory(stories.CSF3Primary, stories.default, {
+ const PrimaryWithChannels = composeStory(ButtonStories.CSF3Primary, ButtonStories.default, {
decorators: [
(StoryFn: any) => {
addons.getChannel();
@@ -186,27 +203,24 @@ describe('ComposeStories types', () => {
type ComposeStoriesParam = Parameters
[0];
expectTypeOf({
- ...stories,
- default: stories.default as Meta,
+ ...ButtonStories,
+ default: ButtonStories.default as Meta,
}).toMatchTypeOf();
expectTypeOf({
- ...stories,
- default: stories.default satisfies Meta,
+ ...ButtonStories,
+ default: ButtonStories.default satisfies Meta,
}).toMatchTypeOf();
});
});
-// Batch snapshot testing
-const testCases = Object.values(composeStories(stories)).map(
+const testCases = Object.values(composeStories(ButtonStories)).map(
(Story) => [Story.storyName, Story] as [string, typeof Story]
);
it.each(testCases)('Renders %s story', async (_storyName, Story) => {
- if (_storyName === 'CSF2StoryWithLocale') {
+ if (_storyName === 'CSF2StoryWithLocale' || _storyName === 'MountInPlayFunctionThrow') {
return;
}
- globalThis.IS_REACT_ACT_ENVIRONMENT = false;
await Story.run();
- globalThis.IS_REACT_ACT_ENVIRONMENT = true;
expect(document.body).toMatchSnapshot();
});
diff --git a/code/renderers/react/src/act-compat.ts b/code/renderers/react/src/act-compat.ts
new file mode 100644
index 000000000000..3eab722d3bb1
--- /dev/null
+++ b/code/renderers/react/src/act-compat.ts
@@ -0,0 +1,66 @@
+// Copied from
+// https://github.com/testing-library/react-testing-library/blob/3dcd8a9649e25054c0e650d95fca2317b7008576/src/act-compat.js
+import * as React from 'react';
+
+import * as DeprecatedReactTestUtils from 'react-dom/test-utils';
+
+declare const globalThis: {
+ IS_REACT_ACT_ENVIRONMENT: boolean;
+};
+
+const reactAct =
+ // @ts-expect-error act might not be available in some versions of React
+ typeof React.act === 'function' ? React.act : DeprecatedReactTestUtils.act;
+
+export function setReactActEnvironment(isReactActEnvironment: boolean) {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment;
+}
+
+export function getReactActEnvironment() {
+ return globalThis.IS_REACT_ACT_ENVIRONMENT;
+}
+
+function withGlobalActEnvironment(actImplementation: (callback: () => void) => Promise) {
+ return (callback: () => any) => {
+ const previousActEnvironment = getReactActEnvironment();
+ setReactActEnvironment(true);
+ try {
+ // The return value of `act` is always a thenable.
+ let callbackNeedsToBeAwaited = false;
+ const actResult = actImplementation(() => {
+ const result = callback();
+ if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
+ callbackNeedsToBeAwaited = true;
+ }
+ return result;
+ });
+ if (callbackNeedsToBeAwaited) {
+ const thenable: Promise = actResult;
+ return {
+ then: (resolve: (param: any) => void, reject: (param: any) => void) => {
+ thenable.then(
+ (returnValue) => {
+ setReactActEnvironment(previousActEnvironment);
+ resolve(returnValue);
+ },
+ (error) => {
+ setReactActEnvironment(previousActEnvironment);
+ reject(error);
+ }
+ );
+ },
+ };
+ } else {
+ setReactActEnvironment(previousActEnvironment);
+ return actResult;
+ }
+ } catch (error) {
+ // Can't be a `finally {}` block since we don't know if we have to immediately restore IS_REACT_ACT_ENVIRONMENT
+ // or if we have to await the callback first.
+ setReactActEnvironment(previousActEnvironment);
+ throw error;
+ }
+ };
+}
+
+export const act = withGlobalActEnvironment(reactAct);
diff --git a/code/renderers/react/src/portable-stories.tsx b/code/renderers/react/src/portable-stories.tsx
index 2ea196e85b4b..7b906c9f4bde 100644
--- a/code/renderers/react/src/portable-stories.tsx
+++ b/code/renderers/react/src/portable-stories.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import * as React from 'react';
import {
composeStories as originalComposeStories,
@@ -17,6 +17,7 @@ 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';
@@ -54,9 +55,68 @@ export function setProjectAnnotations(
// This will not be necessary once we have auto preset loading
export const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations = {
...reactProjectAnnotations,
- renderToCanvas: (renderContext, canvasElement) => {
+ 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) {
- return reactProjectAnnotations.renderToCanvas(renderContext, canvasElement);
+ let unmount: () => void;
+
+ await act(async () => {
+ unmount = await reactProjectAnnotations.renderToCanvas(renderContext, canvasElement);
+ });
+
+ return async () => {
+ await act(() => {
+ unmount();
+ });
+ };
}
const {
storyContext: { context, unboundStoryFn: Story, testingLibraryRender: render },
@@ -149,3 +209,19 @@ 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 f3a4231d078c..3ae6136f9582 100644
--- a/code/renderers/react/src/renderToCanvas.tsx
+++ b/code/renderers/react/src/renderToCanvas.tsx
@@ -5,6 +5,7 @@ import type { RenderContext } from 'storybook/internal/types';
import { global } from '@storybook/global';
+import { getReactActEnvironment } from './act-compat';
import type { ReactRenderer, StoryContext } from './types';
const { FRAMEWORK_OPTIONS } = global;
@@ -57,7 +58,11 @@ export async function renderToCanvas(
const { renderElement, unmountElement } = await import('@storybook/react-dom-shim');
const Story = unboundStoryFn as FC>;
- const content = (
+ const isActEnabled = getReactActEnvironment();
+
+ const content = isActEnabled ? (
+
+ ) : (
diff --git a/code/vitest-setup.ts b/code/vitest-setup.ts
index 8edd64c36314..5eba16740d1d 100644
--- a/code/vitest-setup.ts
+++ b/code/vitest-setup.ts
@@ -7,6 +7,7 @@ const ignoreList = [
(error: any) => error.message.includes('":nth-child" is potentially unsafe'),
(error: any) => error.message.includes('":first-child" is potentially unsafe'),
(error: any) => error.message.match(/Browserslist: .* is outdated. Please run:/),
+ (error: any) => error.message.includes('Consider adding an error boundary'),
(error: any) =>
error.message.includes('react-async-component-lifecycle-hooks') &&
error.stack.includes('addons/knobs/src/components/__tests__/Options.js'),
diff --git a/code/yarn.lock b/code/yarn.lock
index 80ebe411b341..52f251440bb3 100644
--- a/code/yarn.lock
+++ b/code/yarn.lock
@@ -6266,7 +6266,7 @@ __metadata:
sharp: "npm:^0.33.3"
styled-jsx: "npm:5.1.6"
typescript: "npm:^5.3.2"
- vite-plugin-storybook-nextjs: "npm:^1.0.10"
+ vite-plugin-storybook-nextjs: "npm:^1.0.11"
peerDependencies:
"@storybook/test": "workspace:*"
next: ^14.1.0
@@ -6780,11 +6780,14 @@ __metadata:
type-fest: "npm:~2.19"
util-deprecate: "npm:^1.0.2"
peerDependencies:
+ "@storybook/test": "workspace:*"
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
storybook: "workspace:^"
typescript: ">= 4.2.x"
peerDependenciesMeta:
+ "@storybook/test":
+ optional: true
typescript:
optional: true
languageName: unknown
@@ -28551,9 +28554,9 @@ __metadata:
languageName: node
linkType: hard
-"vite-plugin-storybook-nextjs@npm:^1.0.10":
- version: 1.0.10
- resolution: "vite-plugin-storybook-nextjs@npm:1.0.10"
+"vite-plugin-storybook-nextjs@npm:^1.0.11":
+ version: 1.0.11
+ resolution: "vite-plugin-storybook-nextjs@npm:1.0.11"
dependencies:
"@next/env": "npm:^14.2.5"
image-size: "npm:^1.1.1"
@@ -28569,7 +28572,7 @@ __metadata:
dependenciesMeta:
sharp:
optional: true
- checksum: 10c0/e0e373ef94e1761b871b2cc846c205a846901d93c7e61f9d9ee3c69740681e42e6403a7d61109c59f2d98d5829476c3e6d4e9d5a329c4bd51e758b763fa8ea9e
+ checksum: 10c0/9652b76c13a682b688d9a4f617b1a66263f25f395a99af8e258bedef4f3b3ce1c856ec1ff66cc0359d6aedc96adee9750fd6b0432514dd575ad7896cd1de70df
languageName: node
linkType: hard