diff --git a/code/renderers/react/src/act-compat.ts b/code/renderers/react/src/act-compat.ts index 36e56712e02b..eb9aa02e7482 100644 --- a/code/renderers/react/src/act-compat.ts +++ b/code/renderers/react/src/act-compat.ts @@ -25,47 +25,4 @@ 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); +export const act = reactAct; diff --git a/code/renderers/react/src/entry-preview.tsx b/code/renderers/react/src/entry-preview.tsx index 08d625b5729d..a835cee23f19 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,71 @@ export const decorators: Decorator[] = [ ); }, ]; + +export const beforeAll = async () => { + setReactActEnvironment(true); + + 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 + } +}; + +/** 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); + }); + }; }