diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts index 832ad437139f..9322d3c9d2a3 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts @@ -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) diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts index c4aa34874040..a4385605685e 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts @@ -29,6 +29,9 @@ import { normalizeProjectAnnotations } from './normalizeProjectAnnotations'; let globalProjectAnnotations: ProjectAnnotations = {}; +const DEFAULT_STORY_TITLE = 'ComposedStory'; +const DEFAULT_STORY_NAME = 'Unnamed Story'; + function extractAnnotation( annotation: NamedOrDefaultProjectAnnotations ) { @@ -47,7 +50,7 @@ export function setProjectAnnotations( globalProjectAnnotations = composeConfigs(annotations.map(extractAnnotation)); } -const cleanupCallbacks: CleanupCallback[] = []; +const cleanups: { storyName: string; callback: CleanupCallback }[] = []; export function composeStory( storyAnnotations: LegacyStoryAnnotationsOrFn, @@ -63,7 +66,7 @@ export function composeStory(componentAnnotations); @@ -72,7 +75,7 @@ export function composeStory( storyName, @@ -115,6 +118,8 @@ export function composeStory> = Object.assign( function storyFn(extraArgs?: Partial) { context.args = { @@ -122,6 +127,27 @@ export function composeStory 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)); }, { @@ -129,13 +155,19 @@ export function composeStory { // 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, parameters: story.parameters as Parameters, diff --git a/docs/api/portable-stories-jest.md b/docs/api/portable-stories-jest.md index 4c4141a53922..3abe9798112c 100644 --- a/docs/api/portable-stories-jest.md +++ b/docs/api/portable-stories-jest.md @@ -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` | 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` | The story's [parameters](./parameters.md) | -| load | `() => Promise` | Executes all the [loaders](#2-load-optional) for a given story | -| play | `(context?: StoryContext) => Promise \| undefined` | Executes the [play function](#4-play-optional) of a given story | +| Property | Type | Description | +| ---------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| storyName | `string` | The story's name | +| args | `Record` | 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` | The story's [parameters](./parameters.md) | +| load | `() => Promise` | [Prepares](#3-prepare) the story for rendering and and cleans up all previous stories | +| play | `(context?: StoryContext) => Promise \| undefined` | Executes the [play function](#5-play) of a given story | ## composeStory @@ -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. + + +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. + + + ` | 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` | The story's [parameters](./parameters.md) | -| load | `() => Promise` | Executes all the [loaders](#2-load-optional) for a given story | -| play | `(context) => Promise \| undefined` | Executes the [play function](#4-play-optional) of a given story | +| Property | Type | Description | +| ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------- | +| storyName | `string` | The story's name | +| args | `Record` | 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` | The story's [parameters](./parameters.md) | +| load | `() => Promise` | [Prepares](#3-prepare) the story for rendering and and cleans up all previous stories | +| play | `(context) => Promise \| undefined` | Executes the [play function](#5-play) of a given story | ## composeStory @@ -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. + + +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. + + +