diff --git a/code/jest.config.js b/code/jest.config.js index ee2ebd55a9ac..6e72f4acb974 100644 --- a/code/jest.config.js +++ b/code/jest.config.js @@ -4,7 +4,7 @@ module.exports = { '/frameworks/!(angular)*', '/lib/*', '/renderers/*', - '/ui/*', + '/ui/!(node_modules)*', ], collectCoverage: false, collectCoverageFrom: [ diff --git a/code/lib/preview-api/src/modules/preview-web/Preview.tsx b/code/lib/preview-api/src/modules/preview-web/Preview.tsx index f1c2da44c15b..18aba716d046 100644 --- a/code/lib/preview-api/src/modules/preview-web/Preview.tsx +++ b/code/lib/preview-api/src/modules/preview-web/Preview.tsx @@ -265,7 +265,11 @@ export class Preview { async onUpdateArgs({ storyId, updatedArgs }: { storyId: StoryId; updatedArgs: Args }) { this.storyStore.args.update(storyId, updatedArgs); - await Promise.all(this.storyRenders.filter((r) => r.id === storyId).map((r) => r.rerender())); + await Promise.all( + this.storyRenders + .filter((r) => r.id === storyId && !r.renderOptions.forceInitialArgs) + .map((r) => r.rerender()) + ); this.channel.emit(STORY_ARGS_UPDATED, { storyId, diff --git a/code/lib/preview-api/src/modules/preview-web/PreviewWeb.test.ts b/code/lib/preview-api/src/modules/preview-web/PreviewWeb.test.ts index 9639db4617a2..e6dc88486c0a 100644 --- a/code/lib/preview-api/src/modules/preview-web/PreviewWeb.test.ts +++ b/code/lib/preview-api/src/modules/preview-web/PreviewWeb.test.ts @@ -1131,6 +1131,39 @@ describe('PreviewWeb', () => { 'story-element' ); }); + + it('does not re-render the story when forceInitialArgs=true', async () => { + document.location.search = '?id=component-one--docs&viewMode=docs'; + + const preview = await createAndRenderPreview(); + await waitForRender(); + + mockChannel.emit.mockClear(); + const story = await preview.storyStore.loadStory({ storyId: 'component-one--a' }); + preview.renderStoryToElement(story, 'story-element' as any, { forceInitialArgs: true }); + await waitForRender(); + + expect(projectAnnotations.renderToCanvas).toHaveBeenCalledWith( + expect.objectContaining({ + storyContext: expect.objectContaining({ + args: { foo: 'a' }, + }), + }), + 'story-element' + ); + + docsRenderer.render.mockClear(); + mockChannel.emit.mockClear(); + emitter.emit(UPDATE_STORY_ARGS, { + storyId: 'component-one--a', + updatedArgs: { new: 'arg' }, + }); + await waitForEvents([STORY_ARGS_UPDATED]); + + // We don't re-render the story + await expect(waitForRender).rejects.toThrow(); + expect(projectAnnotations.renderToCanvas).toHaveBeenCalledTimes(1); + }); }); }); }); diff --git a/code/lib/preview-api/src/modules/preview-web/render/StoryRender.test.ts b/code/lib/preview-api/src/modules/preview-web/render/StoryRender.test.ts index d40adca995f0..cb382cb3506b 100644 --- a/code/lib/preview-api/src/modules/preview-web/render/StoryRender.test.ts +++ b/code/lib/preview-api/src/modules/preview-web/render/StoryRender.test.ts @@ -101,4 +101,47 @@ describe('StoryRender', () => { await render.renderToElement({} as any); expect(story.playFunction).not.toHaveBeenCalled(); }); + + it('passes the initialArgs to loaders and render function if forceInitialArgs is true', async () => { + const story = { + id: 'id', + title: 'title', + name: 'name', + tags: [], + initialArgs: { a: 'b' }, + applyLoaders: jest.fn(), + unboundStoryFn: jest.fn(), + playFunction: jest.fn(), + }; + + const renderToScreen = jest.fn(); + + const render = new StoryRender( + new Channel(), + { getStoryContext: () => ({ args: { a: 'c ' } }) } as any, + renderToScreen as any, + {} as any, + entry.id, + 'story', + { forceInitialArgs: true }, + story as any + ); + + await render.renderToElement({} as any); + + expect(story.applyLoaders).toHaveBeenCalledWith( + expect.objectContaining({ + args: { a: 'b' }, + }) + ); + + expect(renderToScreen).toHaveBeenCalledWith( + expect.objectContaining({ + storyContext: expect.objectContaining({ + args: { a: 'b' }, + }), + }), + expect.any(Object) + ); + }); }); diff --git a/code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts b/code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts index 89c7422872cd..9df19ae2d1d6 100644 --- a/code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts +++ b/code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts @@ -73,7 +73,7 @@ export class StoryRender implements Render, public id: StoryId, public viewMode: ViewMode, - public renderOptions: StoryRenderOptions = { autoplay: true }, + public renderOptions: StoryRenderOptions = { autoplay: true, forceInitialArgs: false }, story?: PreparedStory ) { this.abortController = new AbortController(); @@ -154,8 +154,17 @@ export class StoryRender implements Render implements Render ({ + ...this.storyContext(), + ...(this.renderOptions.forceInitialArgs && { args: initialArgs }), + }); + let loadedContext: Awaited>; await this.runPhase(abortSignal, 'loading', async () => { loadedContext = await applyLoaders({ - ...this.storyContext(), + ...getCurrentContext(), viewMode: this.viewMode, } as StoryContextForLoaders); }); @@ -185,7 +199,7 @@ export class StoryRender implements Render { diff --git a/code/ui/blocks/src/blocks/DocsStory.tsx b/code/ui/blocks/src/blocks/DocsStory.tsx index 44063200d09d..62341ecfcc93 100644 --- a/code/ui/blocks/src/blocks/DocsStory.tsx +++ b/code/ui/blocks/src/blocks/DocsStory.tsx @@ -13,6 +13,7 @@ export const DocsStory: FC = ({ expanded = true, withToolbar = false, parameters = {}, + __forceInitialArgs = false, }) => { let description; const { docs } = parameters; @@ -27,7 +28,7 @@ export const DocsStory: FC = ({ {subheading && {subheading}} {description && } - + ); diff --git a/code/ui/blocks/src/blocks/Stories.tsx b/code/ui/blocks/src/blocks/Stories.tsx index 7f635e0d110c..76cb3a6a1e04 100644 --- a/code/ui/blocks/src/blocks/Stories.tsx +++ b/code/ui/blocks/src/blocks/Stories.tsx @@ -10,7 +10,7 @@ interface StoriesProps { includePrimary?: boolean; } -export const Stories: FC = ({ title, includePrimary = false }) => { +export const Stories: FC = ({ title, includePrimary = true }) => { const { componentStories } = useContext(DocsContext); let stories: DocsStoryProps[] = componentStories(); @@ -23,7 +23,9 @@ export const Stories: FC = ({ title, includePrimary = false }) => return ( <> {title} - {stories.map((story) => story && )} + {stories.map( + (story) => story && + )} ); }; diff --git a/code/ui/blocks/src/blocks/Story.stories.tsx b/code/ui/blocks/src/blocks/Story.stories.tsx index a64517faf40f..7d99064d3960 100644 --- a/code/ui/blocks/src/blocks/Story.stories.tsx +++ b/code/ui/blocks/src/blocks/Story.stories.tsx @@ -2,11 +2,12 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { Story as StoryComponent } from './Story'; +import { Story as StoryBlock } from './Story'; import * as ButtonStories from '../examples/Button.stories'; +import * as StoryComponentStories from '../components/Story.stories'; -const meta: Meta = { - component: StoryComponent, +const meta: Meta = { + component: StoryBlock, parameters: { relativeCsfPaths: ['../examples/Button.stories', '../blocks/Story.stories'], }, @@ -178,3 +179,12 @@ export const WithInteractionsAutoplayInStory: Story = { chromatic: { delay: 500 }, }, }; + +export const IgnoreArgsUpdates: Story = { + ...StoryComponentStories.ForceInitialArgs, + args: { + of: ButtonStories.Primary, + storyExport: ButtonStories.Primary, + __forceInitialArgs: true, + } as any, +}; diff --git a/code/ui/blocks/src/blocks/Story.tsx b/code/ui/blocks/src/blocks/Story.tsx index 439550156a24..ef8309d027d5 100644 --- a/code/ui/blocks/src/blocks/Story.tsx +++ b/code/ui/blocks/src/blocks/Story.tsx @@ -72,6 +72,10 @@ type StoryParameters = { * Whether to run the story's play function */ autoplay?: boolean; + /** + * Internal prop to control if a story re-renders on args updates + */ + __forceInitialArgs?: boolean; }; export type StoryProps = (StoryDefProps | StoryRefProps) & StoryParameters; @@ -131,6 +135,8 @@ export const getStoryProps = ( inline: true, height, autoplay, + // eslint-disable-next-line no-underscore-dangle + forceInitialArgs: !!props.__forceInitialArgs, renderStoryToElement: context.renderStoryToElement, }; } @@ -145,7 +151,7 @@ export const getStoryProps = ( }; }; -const Story: FC = (props) => { +const Story: FC = (props = { __forceInitialArgs: false }) => { const context = useContext(DocsContext); const storyId = getStoryId(props, context); const story = useStory(storyId, context); diff --git a/code/ui/blocks/src/blocks/types.ts b/code/ui/blocks/src/blocks/types.ts index b48e9ac5f2bf..27d62c250387 100644 --- a/code/ui/blocks/src/blocks/types.ts +++ b/code/ui/blocks/src/blocks/types.ts @@ -14,4 +14,5 @@ export interface StoryData { export type DocsStoryProps = StoryData & { expanded?: boolean; withToolbar?: boolean; + __forceInitialArgs?: boolean; }; diff --git a/code/ui/blocks/src/components/Story.stories.tsx b/code/ui/blocks/src/components/Story.stories.tsx index 20c4107bc7dc..15c9f1c447f7 100644 --- a/code/ui/blocks/src/components/Story.stories.tsx +++ b/code/ui/blocks/src/components/Story.stories.tsx @@ -1,6 +1,10 @@ import React from 'react'; -import type { StoryObj, Meta } from '@storybook/react'; -import type { StoryProps } from './Story'; +import type { Meta } from '@storybook/react'; +import { within } from '@storybook/testing-library'; +import type { PlayFunctionContext } from '@storybook/csf'; +import type { WebRenderer } from '@storybook/types'; +import { RESET_STORY_ARGS, STORY_ARGS_UPDATED, UPDATE_STORY_ARGS } from '@storybook/core-events'; + import { Story as StoryComponent, StorySkeleton } from './Story'; import type { DocsContextProps } from '../blocks'; import * as ButtonStories from '../examples/Button.stories'; @@ -8,26 +12,22 @@ import * as ButtonStories from '../examples/Button.stories'; const preview = __STORYBOOK_PREVIEW__; const renderStoryToElement = preview.renderStoryToElement.bind(preview); -// TODO: can't quite figure out types here. -// type OverriddenStoryProps = StoryProps & { -// story: typeof ButtonStories.Primary; -// }; - -const meta: Meta = { +const meta: Meta = { component: StoryComponent, parameters: { relativeCsfPaths: ['../examples/Button.stories'], }, args: { height: '100px', - // NOTE: the real story arg is a PreparedStory, which we'll get in the render function below - story: ButtonStories.Primary as any, + storyExport: ButtonStories.Primary, + autoplay: false, + ignoreArgsUpdates: false, }, render(args, { loaded }) { const docsContext = loaded.docsContext as DocsContextProps; - const storyId = docsContext.storyIdByModuleExport(args.story); + const storyId = docsContext.storyIdByModuleExport(args.storyExport); const story = docsContext.storyById(storyId); - return ; + return ; }, }; export default meta; @@ -48,9 +48,38 @@ export const IFrame = { }, }; +export const ForceInitialArgs = { + args: { + storyExport: ButtonStories.Primary, + inline: true, + autoplay: true, + forceInitialArgs: true, + renderStoryToElement, + }, + play: async ({ args, canvasElement, loaded }: PlayFunctionContext) => { + const docsContext = loaded.docsContext as DocsContextProps; + const storyId = docsContext.storyIdByModuleExport(args.storyExport); + + const channel = globalThis.__STORYBOOK_ADDONS_CHANNEL__; + await within(canvasElement).findByText(/Button/); + + const updatedPromise = new Promise((resolve) => { + channel.once(STORY_ARGS_UPDATED, resolve); + }); + await channel.emit(UPDATE_STORY_ARGS, { storyId, updatedArgs: { label: 'Updated' } }); + await updatedPromise; + + await within(canvasElement).findByText(/Button/); + await channel.emit(RESET_STORY_ARGS, { storyId }); + await new Promise((resolve) => { + channel.once(STORY_ARGS_UPDATED, resolve); + }); + }, +}; + export const Autoplay = { args: { - story: ButtonStories.Clicking, + storyExport: ButtonStories.Clicking, inline: true, autoplay: true, renderStoryToElement, diff --git a/code/ui/blocks/src/components/Story.tsx b/code/ui/blocks/src/components/Story.tsx index e5b9b20c0f80..e0907d466d47 100644 --- a/code/ui/blocks/src/components/Story.tsx +++ b/code/ui/blocks/src/components/Story.tsx @@ -18,6 +18,7 @@ interface InlineStoryProps extends CommonProps { inline: true; height?: string; autoplay: boolean; + forceInitialArgs: boolean; renderStoryToElement: DocsContextProps['renderStoryToElement']; } @@ -32,6 +33,7 @@ const InlineStory: FunctionComponent = ({ story, height, autoplay, + forceInitialArgs, renderStoryToElement, }) => { const storyRef = useRef(); @@ -42,7 +44,7 @@ const InlineStory: FunctionComponent = ({ return () => {}; } const element = storyRef.current as HTMLElement; - const cleanup = renderStoryToElement(story, element, { autoplay }); + const cleanup = renderStoryToElement(story, element, { autoplay, forceInitialArgs }); setShowLoader(false); return () => { cleanup();