Skip to content

Commit

Permalink
Merge pull request #27008 from storybookjs/jeppe/warn-cleanup-ps
Browse files Browse the repository at this point in the history
Portable Stories: Warn when rendering stories without cleaning up first
  • Loading branch information
JReinhold committed May 4, 2024
2 parents 314fbd6 + eebfd15 commit ef1d144
Show file tree
Hide file tree
Showing 4 changed files with 117 additions and 34 deletions.
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.
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

0 comments on commit ef1d144

Please sign in to comment.