Skip to content

Commit

Permalink
React: Use Act wrapper in Storybook for component rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
valentinpalkovic committed Dec 12, 2024
1 parent 47cbf5a commit 95e2429
Show file tree
Hide file tree
Showing 4 changed files with 86 additions and 123 deletions.
45 changes: 1 addition & 44 deletions code/renderers/react/src/act-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,47 +25,4 @@ export function getReactActEnvironment() {
return globalThis.IS_REACT_ACT_ENVIRONMENT;
}

function withGlobalActEnvironment(actImplementation: (callback: () => void) => Promise<any>) {
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<any> = 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;
69 changes: 69 additions & 0 deletions code/renderers/react/src/entry-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand All @@ -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<void>((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;
}
78 changes: 4 additions & 74 deletions code/renderers/react/src/portable-stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<ReactRenderer> = {
...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<void>((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 {
Expand Down Expand Up @@ -209,19 +155,3 @@ export function composeStories<TModule extends Store_CSFExports<ReactRenderer, a
keyof Store_CSFExports
>;
}

/** 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;
}
17 changes: 12 additions & 5 deletions code/renderers/react/src/renderToCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,9 +58,10 @@ export async function renderToCanvas(
const { renderElement, unmountElement } = await import('@storybook/react-dom-shim');
const Story = unboundStoryFn as FC<StoryContext<ReactRenderer>>;

const isActEnabled = getReactActEnvironment();
// eslint-disable-next-line no-underscore-dangle
const isPortableStory = storyContext.parameters.__isPortableStory;

const content = isActEnabled ? (
const content = isPortableStory ? (
<Story {...storyContext} />
) : (
<ErrorBoundary showMain={showMain} showException={showException}>
Expand All @@ -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);
});
};
}

0 comments on commit 95e2429

Please sign in to comment.