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,48 @@ 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
43 changes: 37 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_TITLE = 'ComposedStory';
JReinhold marked this conversation as resolved.
Show resolved Hide resolved
const DEFAULT_NAME = 'Unnamed Story';
JReinhold marked this conversation as resolved.
Show resolved Hide resolved

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_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_NAME;

const normalizedStory = normalizeStory<TRenderer>(
storyName,
Expand Down Expand Up @@ -115,27 +118,55 @@ 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_TITLE) {
// prefix with title unless it's the generic ComposedStory title
humanReadableIdentifier = `${story.title} - ${humanReadableIdentifier}`;
}
if (storyName === DEFAULT_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
2 changes: 2 additions & 0 deletions code/lib/types/src/modules/frameworks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export type SupportedFrameworks =
| 'svelte-vite'
| 'svelte-webpack5'
| 'sveltekit'
| 'vue-vite'
| 'vue-webpack5'
| 'vue3-vite'
| 'vue3-webpack5'
| 'web-components-vite'
Expand Down
28 changes: 16 additions & 12 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-load) the story for rendering and and cleans up all previous stories |
| play | `(context?: StoryContext) => Promise<void> \| undefined` | Executes the [play function](#4-play-optional) of a given story |

## composeStory

Expand Down Expand Up @@ -245,12 +245,16 @@ The story is prepared by running [`composeStories`](#composestories) or [`compos

### 3. Load

**(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-stories/mocking-modules.md#setting-up-and-cleaning-up). In portable stories, loaders and beforeEach are not applied automatically — you have to apply them yourself.
JReinhold marked this conversation as resolved.
Show resolved Hide resolved

👉 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">

While it's technically optional to run `load` before rendering, it is highly encouraged to always do this, even if the story doesn't have any loaders or beforeEach. If you later add any of these to the story, you don't need to remember to also call `load`. Cleaning up previous stories is also important, and calling `load` ensures that later modifying other stories doesn't affect the current story.
JReinhold marked this conversation as resolved.
Show resolved Hide resolved
JReinhold marked this conversation as resolved.
Show resolved Hide resolved

</Callout>

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

<CodeSnippets
Expand Down
28 changes: 16 additions & 12 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-load) the story for rendering and and cleans up all previous stories |
JReinhold marked this conversation as resolved.
Show resolved Hide resolved
| play | `(context) => Promise<void> \| undefined` | Executes the [play function](#4-play-optional) of a given story |

## composeStory

Expand Down Expand Up @@ -240,12 +240,16 @@ The story is prepared by running [`composeStories`](#composestories) or [`compos

### 3. Load

**(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-stories/mocking-modules.md#setting-up-and-cleaning-up). 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">

While it's technically optional to run `load` before rendering, it is highly encouraged to always do this, even if the story doesn't have any loaders or beforeEach. If you later add any of these to the story, you don't need to remember to also call `load`. Cleaning up previous stories is also important, and calling `load` ensures that later modifying other stories doesn't affect the current story.

</Callout>

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

<CodeSnippets
Expand Down
Loading