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`] = ` `; +exports[`Legacy Portable Stories API > Renders MountInPlayFunction story 1`] = ` + +
+
+ loaded data +
+
+ mockFn return value +
+
+ +`; + +exports[`Legacy Portable Stories API > Renders MountInPlayFunctionThrow story 1`] = ` + +
+
+ loaded data +
+
+ mockFn return value +
+
+ +`; + exports[`Legacy Portable Stories API > Renders WithActionArg story 1`] = `
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