Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Portable Stories: Warn when rendering stories without cleaning up first #27008

Merged
merged 12 commits into from
May 4, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,49 @@ describe('composeStory', () => {
expect(spyFn).toHaveBeenNthCalledWith(2, 'from beforeEach');
});

it('should warn when previous cleanups are still around when rendering a story', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const cleanupSpy = vi.fn();
const beforeEachSpy = vi.fn(() => {
return () => {
cleanupSpy();
};
});

const PreviousStory: Story = {
render: () => 'first',
beforeEach: beforeEachSpy,
};
const CurrentStory: Story = {
render: () => 'second',
args: {
firstArg: false,
secondArg: true,
},
};
const firstComposedStory = composeStory(PreviousStory, {});
await firstComposedStory.load();
firstComposedStory();

expect(beforeEachSpy).toHaveBeenCalled();
expect(cleanupSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).not.toHaveBeenCalled();

const secondComposedStory = composeStory(CurrentStory, {});
secondComposedStory();

expect(cleanupSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledOnce();
expect(consoleWarnSpy.mock.calls[0][0]).toMatchInlineSnapshot(
`
"Some stories were not cleaned up before rendering 'Unnamed Story (firstArg, secondArg)'.

You should load the story with \`await Story.load()\` before rendering it.
See https://storybook.js.org/docs/api/portable-stories-vitest#3-load for more information."
`
);
});

it('should throw an error if Story is undefined', () => {
expect(() => {
// @ts-expect-error (invalid input)
Expand Down
44 changes: 38 additions & 6 deletions code/lib/preview-api/src/modules/store/csf/portable-stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import { normalizeProjectAnnotations } from './normalizeProjectAnnotations';

let globalProjectAnnotations: ProjectAnnotations<any> = {};

const DEFAULT_STORY_TITLE = 'ComposedStory';
const DEFAULT_STORY_NAME = 'Unnamed Story';

function extractAnnotation<TRenderer extends Renderer = Renderer>(
annotation: NamedOrDefaultProjectAnnotations<TRenderer>
) {
Expand All @@ -47,7 +50,7 @@ export function setProjectAnnotations<TRenderer extends Renderer = Renderer>(
globalProjectAnnotations = composeConfigs(annotations.map(extractAnnotation));
}

const cleanupCallbacks: CleanupCallback[] = [];
const cleanups: { storyName: string; callback: CleanupCallback }[] = [];

export function composeStory<TRenderer extends Renderer = Renderer, TArgs extends Args = Args>(
storyAnnotations: LegacyStoryAnnotationsOrFn<TRenderer>,
Expand All @@ -63,7 +66,7 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend

// @TODO: Support auto title

componentAnnotations.title = componentAnnotations.title ?? 'ComposedStory';
componentAnnotations.title = componentAnnotations.title ?? DEFAULT_STORY_TITLE;
const normalizedComponentAnnotations =
normalizeComponentAnnotations<TRenderer>(componentAnnotations);

Expand All @@ -72,7 +75,7 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend
storyAnnotations.storyName ||
storyAnnotations.story?.name ||
storyAnnotations.name ||
'Unnamed Story';
DEFAULT_STORY_NAME;

const normalizedStory = normalizeStory<TRenderer>(
storyName,
Expand Down Expand Up @@ -115,27 +118,56 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend
})
: undefined;

let previousCleanupsDone = false;

const composedStory: ComposedStoryFn<TRenderer, Partial<TArgs>> = Object.assign(
function storyFn(extraArgs?: Partial<TArgs>) {
context.args = {
...context.initialArgs,
...extraArgs,
};

if (cleanups.length > 0 && !previousCleanupsDone) {
let humanReadableIdentifier = storyName;
if (story.title !== DEFAULT_STORY_TITLE) {
// prefix with title unless it's the generic ComposedStory title
humanReadableIdentifier = `${story.title} - ${humanReadableIdentifier}`;
}
if (storyName === DEFAULT_STORY_NAME && Object.keys(context.args).length > 0) {
// suffix with args if it's an unnamed story and there are args
humanReadableIdentifier = `${humanReadableIdentifier} (${Object.keys(context.args).join(
', '
)})`;
}
console.warn(
dedent`Some stories were not cleaned up before rendering '${humanReadableIdentifier}'.

You should load the story with \`await Story.load()\` before rendering it.
JReinhold marked this conversation as resolved.
Show resolved Hide resolved
See https://storybook.js.org/docs/api/portable-stories-${
process.env.JEST_WORKER_ID !== undefined ? 'jest' : 'vitest'
}#3-load for more information.`
);
}
return story.unboundStoryFn(prepareContext(context));
},
{
id: story.id,
storyName,
load: async () => {
// First run any registered cleanup function
for (const callback of [...cleanupCallbacks].reverse()) await callback();
cleanupCallbacks.length = 0;
for (const { callback } of [...cleanups].reverse()) await callback();
cleanups.length = 0;

previousCleanupsDone = true;

const loadedContext = await story.applyLoaders(context);
context.loaded = loadedContext.loaded;

cleanupCallbacks.push(...(await story.applyBeforeEach(context)));
cleanups.push(
...(await story.applyBeforeEach(context))
.filter(Boolean)
.map((callback) => ({ storyName, callback }))
);
},
args: story.initialArgs as Partial<TArgs>,
parameters: story.parameters as Parameters,
Expand Down
32 changes: 18 additions & 14 deletions docs/api/portable-stories-jest.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,15 @@ An object where the keys are the names of the stories and the values are the com

Additionally, the composed story will have the following properties:

| Property | Type | Description |
| ---------- | -------------------------------------------------------- | --------------------------------------------------------------- |
| storyName | `string` | The story's name |
| args | `Record<string, any>` | The story's [args](../writing-stories/args.md) |
| argTypes | `ArgType` | The story's [argTypes](./arg-types.md) |
| id | `string` | The story's id |
| parameters | `Record<string, any>` | The story's [parameters](./parameters.md) |
| load | `() => Promise<void>` | Executes all the [loaders](#2-load-optional) for a given story |
| play | `(context?: StoryContext) => Promise<void> \| undefined` | Executes the [play function](#4-play-optional) of a given story |
| Property | Type | Description |
| ---------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| storyName | `string` | The story's name |
| args | `Record<string, any>` | The story's [args](../writing-stories/args.md) |
| argTypes | `ArgType` | The story's [argTypes](./arg-types.md) |
| id | `string` | The story's id |
| parameters | `Record<string, any>` | The story's [parameters](./parameters.md) |
| load | `() => Promise<void>` | [Prepares](#3-prepare) the story for rendering and and cleans up all previous stories |
| play | `(context?: StoryContext) => Promise<void> \| undefined` | Executes the [play function](#5-play) of a given story |

## composeStory

Expand Down Expand Up @@ -239,18 +239,22 @@ When you want to reuse a story in a different environment, however, it's crucial

👉 For this, you use the [`setProjectAnnotations`](#setprojectannotations) API.

### 2. Prepare
### 2. Compose

The story is prepared by running [`composeStories`](#composestories) or [`composeStory`](#composestory). You do not need to do anything for this step.

### 3. Load
### 3. Prepare

**(optional)**

Stories can prepare data they need (e.g. setting up some mocks or fetching data) before rendering by defining [loaders](../writing-stories/loaders.md). In portable stories, the loaders are not applied automatically—you have to apply them yourself.
Stories can prepare data they need (e.g. setting up some mocks or fetching data) before rendering by defining [loaders](../writing-stories/loaders.md) or [beforeEach](../writing-tests/interaction-testing.md#run-code-before-each-test). In portable stories, loaders and beforeEach are not applied automatically — you have to apply them yourself.

👉 For this, you use the [`composeStories`](#composestories) or [`composeStory`](#composestory) API. The composed story will return a `load` method to be called **before** it is rendered.

<Callout variant="info">

It is recommended to always run `load` before rendering, even if the story doesn't have any loaders or beforeEach applied. By doing so, you ensure that the tests are cleaned up properly to maintain isolation and you will not have to update your test if you later add them to your story.

</Callout>

<!-- prettier-ignore-start -->

<CodeSnippets
Expand Down
32 changes: 18 additions & 14 deletions docs/api/portable-stories-vitest.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,15 @@ An object where the keys are the names of the stories and the values are the com

Additionally, the composed story will have the following properties:

| Property | Type | Description |
| ---------- | ----------------------------------------- | --------------------------------------------------------------- |
| storyName | `string` | The story's name |
| args | `Record<string, any>` | The story's [args](../writing-stories/args.md) |
| argTypes | `ArgType` | The story's [argTypes](./arg-types.md) |
| id | `string` | The story's id |
| parameters | `Record<string, any>` | The story's [parameters](./parameters.md) |
| load | `() => Promise<void>` | Executes all the [loaders](#2-load-optional) for a given story |
| play | `(context) => Promise<void> \| undefined` | Executes the [play function](#4-play-optional) of a given story |
| Property | Type | Description |
| ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------- |
| storyName | `string` | The story's name |
| args | `Record<string, any>` | The story's [args](../writing-stories/args.md) |
| argTypes | `ArgType` | The story's [argTypes](./arg-types.md) |
| id | `string` | The story's id |
| parameters | `Record<string, any>` | The story's [parameters](./parameters.md) |
| load | `() => Promise<void>` | [Prepares](#3-prepare) the story for rendering and and cleans up all previous stories |
| play | `(context) => Promise<void> \| undefined` | Executes the [play function](#5-play) of a given story |

## composeStory

Expand Down Expand Up @@ -234,18 +234,22 @@ When you want to reuse a story in a different environment, however, it's crucial

👉 For this, you use the [`setProjectAnnotations`](#setprojectannotations) API.

### 2. Prepare
### 2. Compose

The story is prepared by running [`composeStories`](#composestories) or [`composeStory`](#composestory). You do not need to do anything for this step.

### 3. Load
### 3. Prepare

**(optional)**

Stories can prepare data they need (e.g. setting up some mocks or fetching data) before rendering by defining [loaders](../writing-stories/loaders.md). In portable stories, the loaders are not applied automatically—you have to apply them yourself.
Stories can prepare data they need (e.g. setting up some mocks or fetching data) before rendering by defining [loaders](../writing-stories/loaders.md) or [beforeEach](../writing-tests/interaction-testing.md#run-code-before-each-test). In portable stories, loaders and beforeEach are not applied automatically — you have to apply them yourself.

👉 For this, you use the [`composeStories`](#composestories) or [`composeStory`](#composestory) API. The composed story will return a `load` method to be called **before** it is rendered.

<Callout variant="info">

It is recommended to always run `load` before rendering, even if the story doesn't have any loaders or beforeEach applied. By doing so, you ensure that the tests are cleaned up properly to maintain isolation and you will not have to update your test if you later add them to your story.

</Callout>

<!-- prettier-ignore-start -->

<CodeSnippets
Expand Down