diff --git a/code/e2e-tests/addon-docs.spec.ts b/code/e2e-tests/addon-docs.spec.ts index 402ba144ece0..1e50e4433794 100644 --- a/code/e2e-tests/addon-docs.spec.ts +++ b/code/e2e-tests/addon-docs.spec.ts @@ -58,11 +58,12 @@ test.describe('addon-docs', () => { await sbPage.navigateToStory('addons/docs/docspage/basic', 'docs'); // The `` block should render the "Basic" story, and the `` block should - // render the "Another" story + // render both the "Basic" and "Another" story const root = sbPage.previewRoot(); const stories = root.locator('.sbdocs-h3'); - await expect(await stories.count()).toBe(1); - await expect(stories.first()).toHaveText('Another'); + await expect(await stories.count()).toBe(2); + await expect(stories.first()).toHaveText('Basic'); + await expect(stories.last()).toHaveText('Another'); }); }); diff --git a/code/e2e-tests/preview-web.spec.ts b/code/e2e-tests/preview-web.spec.ts index 3dec28a68910..28c3b2e1b01d 100644 --- a/code/e2e-tests/preview-web.spec.ts +++ b/code/e2e-tests/preview-web.spec.ts @@ -26,7 +26,7 @@ test.describe('preview-web', () => { await expect(sbPage.page.locator('.sidebar-container')).toBeVisible(); - await sbPage.previewRoot().getByRole('button').getByText('Submit').press('s'); + await sbPage.previewRoot().getByRole('button').getByText('Submit').first().press('s'); await expect(sbPage.page.locator('.sidebar-container')).not.toBeVisible(); }); }); diff --git a/code/frameworks/angular/src/client/angular-beta/AbstractRenderer.ts b/code/frameworks/angular/src/client/angular-beta/AbstractRenderer.ts index 8caee3001496..5716c9464ddb 100644 --- a/code/frameworks/angular/src/client/angular-beta/AbstractRenderer.ts +++ b/code/frameworks/angular/src/client/angular-beta/AbstractRenderer.ts @@ -93,7 +93,7 @@ export abstract class AbstractRenderer { parameters: Parameters; targetDOMNode: HTMLElement; }) { - const targetSelector = `${this.generateTargetSelectorFromStoryId()}`; + const targetSelector = this.generateTargetSelectorFromStoryId(targetDOMNode.id); const newStoryProps$ = new BehaviorSubject(storyFnAngular.props); @@ -142,12 +142,10 @@ export abstract class AbstractRenderer { * @protected * @memberof AbstractRenderer */ - protected generateTargetSelectorFromStoryId() { + protected generateTargetSelectorFromStoryId(id: string) { const invalidHtmlTag = /[^A-Za-z0-9-]/g; - const storyIdIsInvalidHtmlTagName = invalidHtmlTag.test(this.storyId); - return storyIdIsInvalidHtmlTagName - ? `sb-${this.storyId.replace(invalidHtmlTag, '')}-component` - : this.storyId; + const storyIdIsInvalidHtmlTagName = invalidHtmlTag.test(id); + return storyIdIsInvalidHtmlTagName ? `sb-${id.replace(invalidHtmlTag, '')}-component` : id; } protected initAngularRootElement(targetDOMNode: HTMLElement, targetSelector: string) { diff --git a/code/frameworks/angular/src/client/angular-beta/RendererFactory.ts b/code/frameworks/angular/src/client/angular-beta/RendererFactory.ts index 9ae768c57c29..3bf703d83d07 100644 --- a/code/frameworks/angular/src/client/angular-beta/RendererFactory.ts +++ b/code/frameworks/angular/src/client/angular-beta/RendererFactory.ts @@ -12,6 +12,7 @@ export class RendererFactory { storyId: string, targetDOMNode: HTMLElement ): Promise { + const targetId = targetDOMNode.id; // do nothing if the target node is null // fix a problem when the docs asks 2 times the same component at the same time // the 1st targetDOMNode of the 1st requested rendering becomes null 🤷‍♂️ @@ -27,12 +28,12 @@ export class RendererFactory { this.rendererMap.clear(); } - if (!this.rendererMap.has(storyId)) { - this.rendererMap.set(storyId, this.buildRenderer(storyId, renderType)); + if (!this.rendererMap.has(targetId)) { + this.rendererMap.set(targetId, this.buildRenderer(storyId, renderType)); } this.lastRenderType = renderType; - return this.rendererMap.get(storyId); + return this.rendererMap.get(targetId); } private buildRenderer(storyId: string, renderType: RenderType) { 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 fa0a12a0d246..0f301ef7364c 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 6aa8bfb4ffdb..766bc4edf117 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 = ({ expanded = true, withToolbar = false, parameters = {}, + __forceInitialArgs = false, + __primary = false, }) => { let description; const { docs } = parameters; @@ -27,7 +29,12 @@ export const DocsStory: FC = ({ {subheading && {subheading}} {description && } - + ); diff --git a/code/ui/blocks/src/blocks/Primary.tsx b/code/ui/blocks/src/blocks/Primary.tsx index c430345e7717..8095d079323e 100644 --- a/code/ui/blocks/src/blocks/Primary.tsx +++ b/code/ui/blocks/src/blocks/Primary.tsx @@ -12,5 +12,5 @@ export const Primary: FC = ({ name }) => { const docsContext = useContext(DocsContext); const storyId = name && docsContext.storyIdByName(name); const story = docsContext.storyById(storyId); - return story ? : null; + return story ? : null; }; 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 efdb2aef5b8d..34cc6738af61 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'], }, @@ -199,3 +200,12 @@ export const WithInteractionsAutoplayInStory: Story = { chromatic: { delay: 500 }, }, }; + +export const ForceInitialArgs: 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 7d8e90c11da9..7cae4fa51d45 100644 --- a/code/ui/blocks/src/blocks/Story.tsx +++ b/code/ui/blocks/src/blocks/Story.tsx @@ -15,8 +15,6 @@ import type { DocsContextProps } from './DocsContext'; import { DocsContext } from './DocsContext'; import { useStory } from './useStory'; -export const storyBlockIdFromId = (storyId: string) => `story--${storyId}`; - type PureStoryProps = ComponentProps; /** @@ -72,6 +70,14 @@ 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; + /** + * Internal prop if this story is the primary story + */ + __primary?: boolean; }; export type StoryProps = (StoryDefProps | StoryRefProps) & StoryParameters; @@ -136,6 +142,10 @@ export const getStoryProps = ( inline: true, height, autoplay, + // eslint-disable-next-line no-underscore-dangle + forceInitialArgs: !!props.__forceInitialArgs, + // eslint-disable-next-line no-underscore-dangle + primary: !!props.__primary, renderStoryToElement: context.renderStoryToElement, }; } @@ -147,10 +157,12 @@ export const getStoryProps = ( story, inline: false, height, + // eslint-disable-next-line no-underscore-dangle + primary: !!props.__primary, }; }; -const Story: FC = (props) => { +const Story: FC = (props = { __forceInitialArgs: false, __primary: false }) => { const context = useContext(DocsContext); const storyId = getStoryId(props, context); const story = useStory(storyId, context); @@ -164,11 +176,7 @@ const Story: FC = (props) => { return null; } - return ( -
- -
- ); + return ; }; export { Story }; diff --git a/code/ui/blocks/src/blocks/types.ts b/code/ui/blocks/src/blocks/types.ts index b48e9ac5f2bf..5aab18554b8e 100644 --- a/code/ui/blocks/src/blocks/types.ts +++ b/code/ui/blocks/src/blocks/types.ts @@ -14,4 +14,6 @@ export interface StoryData { export type DocsStoryProps = StoryData & { expanded?: boolean; withToolbar?: boolean; + __forceInitialArgs?: boolean; + __primary?: boolean; }; diff --git a/code/ui/blocks/src/components/Preview.stories.tsx b/code/ui/blocks/src/components/Preview.stories.tsx index 61596130556c..3f50ef522d44 100644 --- a/code/ui/blocks/src/components/Preview.stories.tsx +++ b/code/ui/blocks/src/components/Preview.stories.tsx @@ -107,6 +107,8 @@ export const WithToolbar = ( story={getPreparedStory(docsContext, ButtonStories.Primary)} renderStoryToElement={renderStoryToElement} autoplay={false} + forceInitialArgs={false} + primary={false} height="100px" /> @@ -137,6 +139,8 @@ export const WithToolbarMulti = ( story={getPreparedStory(docsContext, ButtonStories.Primary)} renderStoryToElement={renderStoryToElement} autoplay={false} + forceInitialArgs={false} + primary={false} height="100px" /> @@ -159,6 +165,8 @@ export const WithFullscreenSingle = ( story={getPreparedStory(docsContext, ButtonStories.Primary)} renderStoryToElement={renderStoryToElement} autoplay={false} + forceInitialArgs={false} + primary={false} height="100px" /> @@ -174,6 +182,8 @@ export const WithFullscreenMulti = ( story={getPreparedStory(docsContext, ButtonStories.Primary)} renderStoryToElement={renderStoryToElement} autoplay={false} + forceInitialArgs={false} + primary={false} height="100px" /> @@ -196,6 +208,8 @@ export const WithCenteredSingle = ( story={getPreparedStory(docsContext, ButtonStories.Primary)} renderStoryToElement={renderStoryToElement} autoplay={false} + forceInitialArgs={false} + primary={false} height="100px" /> @@ -211,6 +225,8 @@ export const WithCenteredMulti = ( story={getPreparedStory(docsContext, ButtonStories.Primary)} renderStoryToElement={renderStoryToElement} autoplay={false} + forceInitialArgs={false} + primary={false} height="100px" /> diff --git a/code/ui/blocks/src/components/Story.stories.tsx b/code/ui/blocks/src/components/Story.stories.tsx index 82bad2fa8482..ec80ff650d3c 100644 --- a/code/ui/blocks/src/components/Story.stories.tsx +++ b/code/ui/blocks/src/components/Story.stories.tsx @@ -1,13 +1,20 @@ import React from 'react'; -import type { Meta, StoryObj } from '@storybook/react'; -import type { ModuleExport } from '@storybook/types'; +import type { Meta, ReactRenderer, StoryObj } from '@storybook/react'; +import { within } from '@storybook/testing-library'; +import type { PlayFunctionContext } from '@storybook/csf'; +import type { WebRenderer, ModuleExport } from '@storybook/types'; +import { RESET_STORY_ARGS, STORY_ARGS_UPDATED, UPDATE_STORY_ARGS } from '@storybook/core-events'; +import type { PreviewWeb } from '@storybook/preview-api'; +import type { Channel } from '@storybook/channels'; + import type { StoryProps } from './Story'; import { Story as StoryComponent, StorySkeleton } from './Story'; import type { DocsContextProps } from '../blocks'; import * as ButtonStories from '../examples/Button.stories'; // eslint-disable-next-line no-underscore-dangle -const preview = (window as any).__STORYBOOK_PREVIEW__; +const preview = (window as any).__STORYBOOK_PREVIEW__ as PreviewWeb; +const channel = (window as any).__STORYBOOK_ADDONS_CHANNEL__ as Channel; const renderStoryToElement = preview.renderStoryToElement.bind(preview); type ExtendedStoryProps = Omit & { @@ -22,6 +29,7 @@ const meta: Meta = { }, args: { height: '100px', + primary: false, // NOTE: the real story arg is a PreparedStory, which we'll get in the render function below storyExport: ButtonStories.Primary as any, }, @@ -44,6 +52,7 @@ export const Inline: Story = { inline: true, // @ts-expect-error getting too complex with props autoplay: false, + forceInitialArgs: false, renderStoryToElement, }, }; @@ -55,6 +64,39 @@ export const IFrame: Story = { }, }; +export const ForceInitialArgs = { + args: { + storyExport: ButtonStories.Primary, + inline: true, + autoplay: true, + forceInitialArgs: true, + renderStoryToElement, + }, + // test that it ignores updated args by emitting an arg update and assert that it isn't reflected in the DOM + play: async ({ args, canvasElement, loaded }: PlayFunctionContext) => { + const docsContext = loaded.docsContext as DocsContextProps; + const resolved = docsContext.resolveModuleExport(args.storyExport); + if (resolved.type !== 'story') throw new Error('Bad export, pass a story!'); + + await within(canvasElement).findByText(/Button/); + + const updatedPromise = new Promise((resolve) => { + channel.once(STORY_ARGS_UPDATED, resolve); + }); + await channel.emit(UPDATE_STORY_ARGS, { + storyId: resolved.story.id, + updatedArgs: { label: 'Updated' }, + }); + await updatedPromise; + await within(canvasElement).findByText(/Button/); + + await channel.emit(RESET_STORY_ARGS, { storyId: resolved.story.id }); + await new Promise((resolve) => { + channel.once(STORY_ARGS_UPDATED, resolve); + }); + }, +}; + export const Autoplay: Story = { args: { storyExport: ButtonStories.Clicking, diff --git a/code/ui/blocks/src/components/Story.tsx b/code/ui/blocks/src/components/Story.tsx index e5b9b20c0f80..78153eae5ef5 100644 --- a/code/ui/blocks/src/components/Story.tsx +++ b/code/ui/blocks/src/components/Story.tsx @@ -12,12 +12,14 @@ const BASE_URL = PREVIEW_URL || 'iframe.html'; interface CommonProps { story: PreparedStory; inline: boolean; + primary: boolean; } interface InlineStoryProps extends CommonProps { inline: true; height?: string; autoplay: boolean; + forceInitialArgs: boolean; renderStoryToElement: DocsContextProps['renderStoryToElement']; } @@ -28,21 +30,21 @@ interface IFrameStoryProps extends CommonProps { export type StoryProps = InlineStoryProps | IFrameStoryProps; -const InlineStory: FunctionComponent = ({ - story, - height, - autoplay, - renderStoryToElement, -}) => { +export const storyBlockIdFromId = ({ story, primary }: StoryProps) => + `story--${story.id}${primary ? '--primary' : ''}`; + +const InlineStory: FunctionComponent = (props) => { const storyRef = useRef(); const [showLoader, setShowLoader] = useState(true); + const { story, height, autoplay, forceInitialArgs, renderStoryToElement } = props; + useEffect(() => { if (!(story && storyRef.current)) { return () => {}; } const element = storyRef.current as HTMLElement; - const cleanup = renderStoryToElement(story, element, { autoplay }); + const cleanup = renderStoryToElement(story, element, { autoplay, forceInitialArgs }); setShowLoader(false); return () => { cleanup(); @@ -55,11 +57,14 @@ const InlineStory: FunctionComponent = ({ return ( <> {height ? ( - + ) : null} {showLoader && }
= ({ story, height = '500 */ const Story: FunctionComponent = (props) => { const { inline } = props; - return inline ? ( - - ) : ( - + + return ( +
+ {inline ? ( + + ) : ( + + )} +
); };