diff --git a/code/addons/docs/docs/multiframework.md b/code/addons/docs/docs/multiframework.md index aeaa394ea3ba..f852c8d42ebe 100644 --- a/code/addons/docs/docs/multiframework.md +++ b/code/addons/docs/docs/multiframework.md @@ -129,7 +129,9 @@ export const jsxDecorator = (storyFn: any, context: StoryContext) => { const options = {}; // retrieve from story parameters const jsx = renderJsx(story, options); - channel.emit(SNIPPET_RENDERED, (context || {}).id, jsx); + + const { id, args } = context; + channel.emit(SNIPPET_RENDERED, { id, args, source: jsx }); return story; }; diff --git a/code/e2e-tests/addon-docs.spec.ts b/code/e2e-tests/addon-docs.spec.ts index 37048da3d882..c5298b19cecc 100644 --- a/code/e2e-tests/addon-docs.spec.ts +++ b/code/e2e-tests/addon-docs.spec.ts @@ -41,6 +41,56 @@ test.describe('addon-docs', () => { } }); + test('source snippet should not change in stories block', async ({ page }) => { + const skipped = [ + 'vue3', + 'vue-cli', + 'preact', + // NextJS snippets are broken, see: https://github.com/storybookjs/storybook/issues/20356 + 'nextjs', + // SSv6 does not render stories in the correct order in our sandboxes + 'internal\\/ssv6', + // Angular bug: https://github.com/storybookjs/storybook/issues/21066 + 'angular', + // Lit seems to render incorrectly for our template-stories but not real stories + // - template: https://638db567ed97c3fb3e21cc22-ulhjwkqzzj.chromatic.com/?path=/docs/addons-docs-docspage-basic--docs + // - real: https://638db567ed97c3fb3e21cc22-ulhjwkqzzj.chromatic.com/?path=/docs/example-button--docs + 'lit-vite', + // Vue doesn't update when you change args, apparently fixed by this: + // https://github.com/storybookjs/storybook/pull/20995 + 'vue2-vite', + ]; + test.skip( + new RegExp(`^${skipped.join('|')}`, 'i').test(`${templateName}`), + `Skipping ${templateName}, which does not support dynamic source snippets` + ); + + const sbPage = new SbPage(page); + await sbPage.navigateToStory('addons/docs/docspage/basic', 'docs'); + const root = sbPage.previewRoot(); + const toggles = root.locator('.docblock-code-toggle'); + + // Open up the first and second code toggle (i.e the "Basic" story outside and inside the Stories block) + await (await toggles.nth(0)).click({ force: true }); + await (await toggles.nth(1)).click({ force: true }); + + // Check they both say "Basic" + const codes = root.locator('pre.prismjs'); + const primaryCode = await codes.nth(0); + const storiesCode = await codes.nth(1); + await expect(primaryCode).toContainText('Basic'); + await expect(storiesCode).toContainText('Basic'); + + const labelControl = root.locator('textarea[name=label]'); + labelControl.fill('Changed'); + labelControl.blur(); + + // Check the Primary one has changed + await expect(primaryCode).toContainText('Changed'); + // Check the stories one still says "Basic" + await expect(storiesCode).toContainText('Basic'); + }); + test('should not run autoplay stories without parameter', async ({ page }) => { const sbPage = new SbPage(page); await sbPage.navigateToStory('addons/docs/docspage/autoplay', 'docs'); diff --git a/code/frameworks/angular/src/client/docs/sourceDecorator.ts b/code/frameworks/angular/src/client/docs/sourceDecorator.ts index b60ad0fa9a40..e86df2a05835 100644 --- a/code/frameworks/angular/src/client/docs/sourceDecorator.ts +++ b/code/frameworks/angular/src/client/docs/sourceDecorator.ts @@ -38,7 +38,8 @@ export const sourceDecorator = ( useEffect(() => { if (toEmit) { - channel.emit(SNIPPET_RENDERED, context.id, toEmit, 'angular'); + const { id, args } = context; + channel.emit(SNIPPET_RENDERED, { id, args, source: toEmit, format: 'angular' }); } }); diff --git a/code/renderers/html/src/docs/sourceDecorator.test.ts b/code/renderers/html/src/docs/sourceDecorator.test.ts index 06534288bf71..deadcfe315ef 100644 --- a/code/renderers/html/src/docs/sourceDecorator.test.ts +++ b/code/renderers/html/src/docs/sourceDecorator.test.ts @@ -47,11 +47,11 @@ describe('sourceDecorator', () => { const context = makeContext('args', { __isArgsStory: true }, {}); sourceDecorator(storyFn, context); await tick(); - expect(mockChannel.emit).toHaveBeenCalledWith( - SNIPPET_RENDERED, - 'html-test--args', - '
args story
' - ); + expect(mockChannel.emit).toHaveBeenCalledWith(SNIPPET_RENDERED, { + id: 'html-test--args', + args: {}, + source: '
args story
', + }); }); it('should dedent source by default', async () => { @@ -63,11 +63,11 @@ describe('sourceDecorator', () => { const context = makeContext('args', { __isArgsStory: true }, {}); sourceDecorator(storyFn, context); await tick(); - expect(mockChannel.emit).toHaveBeenCalledWith( - SNIPPET_RENDERED, - 'html-test--args', - ['
', ' args story', '
'].join('\n') - ); + expect(mockChannel.emit).toHaveBeenCalledWith(SNIPPET_RENDERED, { + id: 'html-test--args', + args: {}, + source: ['
', ' args story', '
'].join('\n'), + }); }); it('should skip dynamic rendering for no-args stories', async () => { @@ -98,11 +98,11 @@ describe('sourceDecorator', () => { ); sourceDecorator(decoratedStoryFn, context); await tick(); - expect(mockChannel.emit).toHaveBeenCalledWith( - SNIPPET_RENDERED, - 'html-test--args', - '
args story
' - ); + expect(mockChannel.emit).toHaveBeenCalledWith(SNIPPET_RENDERED, { + id: 'html-test--args', + args: {}, + source: '
args story
', + }); }); it('allows the snippet output to be modified by transformSource', async () => { @@ -112,11 +112,11 @@ describe('sourceDecorator', () => { const context = makeContext('args', { __isArgsStory: true, docs }, {}); sourceDecorator(storyFn, context); await tick(); - expect(mockChannel.emit).toHaveBeenCalledWith( - SNIPPET_RENDERED, - 'html-test--args', - '

args story

' - ); + expect(mockChannel.emit).toHaveBeenCalledWith(SNIPPET_RENDERED, { + id: 'html-test--args', + args: {}, + source: '

args story

', + }); }); it('provides the story context to transformSource', () => { diff --git a/code/renderers/html/src/docs/sourceDecorator.ts b/code/renderers/html/src/docs/sourceDecorator.ts index 459ea267ce9f..9630dcda9a04 100644 --- a/code/renderers/html/src/docs/sourceDecorator.ts +++ b/code/renderers/html/src/docs/sourceDecorator.ts @@ -54,7 +54,8 @@ export function sourceDecorator(storyFn: PartialStoryFn, context: } } useEffect(() => { - if (source) addons.getChannel().emit(SNIPPET_RENDERED, context.id, source); + const { id, args } = context; + if (source) addons.getChannel().emit(SNIPPET_RENDERED, { id, args, source }); }); return story; diff --git a/code/renderers/react/src/docs/jsxDecorator.test.tsx b/code/renderers/react/src/docs/jsxDecorator.test.tsx index 9adeea67d5ec..aee1a97685db 100644 --- a/code/renderers/react/src/docs/jsxDecorator.test.tsx +++ b/code/renderers/react/src/docs/jsxDecorator.test.tsx @@ -203,11 +203,11 @@ describe('jsxDecorator', () => { const context = makeContext('args', { __isArgsStory: true }, {}); jsxDecorator(storyFn, context); await new Promise((r) => setTimeout(r, 0)); - expect(mockChannel.emit).toHaveBeenCalledWith( - SNIPPET_RENDERED, - 'jsx-test--args', - '
\n args story\n
' - ); + expect(mockChannel.emit).toHaveBeenCalledWith(SNIPPET_RENDERED, { + id: 'jsx-test--args', + args: {}, + source: '
\n args story\n
', + }); }); it('should not render decorators when provided excludeDecorators parameter', async () => { @@ -231,11 +231,11 @@ describe('jsxDecorator', () => { jsxDecorator(decoratedStoryFn, context); await new Promise((r) => setTimeout(r, 0)); - expect(mockChannel.emit).toHaveBeenCalledWith( - SNIPPET_RENDERED, - 'jsx-test--args', - '
\n args story\n
' - ); + expect(mockChannel.emit).toHaveBeenCalledWith(SNIPPET_RENDERED, { + id: 'jsx-test--args', + args: {}, + source: '
\n args story\n
', + }); }); it('should skip dynamic rendering for no-args stories', async () => { @@ -255,11 +255,11 @@ describe('jsxDecorator', () => { jsxDecorator(storyFn, context); await new Promise((r) => setTimeout(r, 0)); - expect(mockChannel.emit).toHaveBeenCalledWith( - SNIPPET_RENDERED, - 'jsx-test--args', - '

\n args story\n

' - ); + expect(mockChannel.emit).toHaveBeenCalledWith(SNIPPET_RENDERED, { + id: 'jsx-test--args', + args: {}, + source: '

\n args story\n

', + }); }); it('provides the story context to transformSource', () => { @@ -286,11 +286,11 @@ describe('jsxDecorator', () => { jsxDecorator(() => mdxElement, makeContext('mdx-args', { __isArgsStory: true }, {})); await new Promise((r) => setTimeout(r, 0)); - expect(mockChannel.emit).toHaveBeenCalledWith( - SNIPPET_RENDERED, - 'jsx-test--mdx-args', - '
' - ); + expect(mockChannel.emit).toHaveBeenCalledWith(SNIPPET_RENDERED, { + id: 'jsx-test--mdx-args', + args: {}, + source: '
', + }); }); it('handles stories that trigger Suspense', async () => { @@ -314,12 +314,15 @@ describe('jsxDecorator', () => { await new Promise((r) => setTimeout(r, 0)); expect(mockChannel.emit).toHaveBeenCalledTimes(2); - expect(mockChannel.emit).nthCalledWith(1, SNIPPET_RENDERED, 'jsx-test--args', ''); - expect(mockChannel.emit).nthCalledWith( - 2, - SNIPPET_RENDERED, - 'jsx-test--args', - '
\n resolved args story\n
' - ); + expect(mockChannel.emit).nthCalledWith(1, SNIPPET_RENDERED, { + id: 'jsx-test--args', + args: {}, + source: '', + }); + expect(mockChannel.emit).nthCalledWith(2, SNIPPET_RENDERED, { + id: 'jsx-test--args', + args: {}, + source: '
\n resolved args story\n
', + }); }); }); diff --git a/code/renderers/react/src/docs/jsxDecorator.tsx b/code/renderers/react/src/docs/jsxDecorator.tsx index 3042618fe552..e86e1a38db03 100644 --- a/code/renderers/react/src/docs/jsxDecorator.tsx +++ b/code/renderers/react/src/docs/jsxDecorator.tsx @@ -190,7 +190,12 @@ export const jsxDecorator = ( useEffect(() => { if (!skip) { - channel.emit(SNIPPET_RENDERED, (context || {}).id, jsx); + const { id, args } = context; + channel.emit(SNIPPET_RENDERED, { + id, + source: jsx, + args, + }); } }); diff --git a/code/renderers/svelte/src/docs/sourceDecorator.ts b/code/renderers/svelte/src/docs/sourceDecorator.ts index 6f9dbdb282c6..efc3852255b1 100644 --- a/code/renderers/svelte/src/docs/sourceDecorator.ts +++ b/code/renderers/svelte/src/docs/sourceDecorator.ts @@ -157,7 +157,8 @@ export const sourceDecorator = (storyFn: any, context: StoryContext) = useEffect(() => { if (!skip && source) { - channel.emit(SNIPPET_RENDERED, (context || {}).id, source); + const { id, args } = context; + channel.emit(SNIPPET_RENDERED, { id, args, source }); } }); diff --git a/code/renderers/vue/src/docs/sourceDecorator.ts b/code/renderers/vue/src/docs/sourceDecorator.ts index 8c9839acb138..8d64ffbb217b 100644 --- a/code/renderers/vue/src/docs/sourceDecorator.ts +++ b/code/renderers/vue/src/docs/sourceDecorator.ts @@ -54,7 +54,13 @@ export const sourceDecorator = (storyFn: any, context: StoryContext) => { // @ts-expect-error TS says it is called $vnode const code = vnodeToString(storyNode._vnode); - channel.emit(SNIPPET_RENDERED, (context || {}).id, ``, 'vue'); + const { id, args } = context; + channel.emit(SNIPPET_RENDERED, { + id, + args, + source: ``, + format: 'vue', + }); } catch (e) { logger.warn(`Failed to generate dynamic story source: ${e}`); } diff --git a/code/renderers/vue3/src/docs/sourceDecorator.ts b/code/renderers/vue3/src/docs/sourceDecorator.ts index 182b8b6a758e..93a2fbd18c38 100644 --- a/code/renderers/vue3/src/docs/sourceDecorator.ts +++ b/code/renderers/vue3/src/docs/sourceDecorator.ts @@ -289,7 +289,8 @@ export const sourceDecorator = (storyFn: any, context: StoryContext) = useEffect(() => { if (!skip && source) { - channel.emit(SNIPPET_RENDERED, (context || {}).id, source, 'vue'); + const { id, args } = context; + channel.emit(SNIPPET_RENDERED, { id, args, source, format: 'vue' }); } }); diff --git a/code/renderers/web-components/src/docs/sourceDecorator.test.ts b/code/renderers/web-components/src/docs/sourceDecorator.test.ts index 23b805609acf..0a0108ca38f3 100644 --- a/code/renderers/web-components/src/docs/sourceDecorator.test.ts +++ b/code/renderers/web-components/src/docs/sourceDecorator.test.ts @@ -43,11 +43,11 @@ describe('sourceDecorator', () => { const context = makeContext('args', { __isArgsStory: true }, {}); sourceDecorator(storyFn, context); await tick(); - expect(mockChannel.emit).toHaveBeenCalledWith( - SNIPPET_RENDERED, - 'lit-test--args', - '
args story
' - ); + expect(mockChannel.emit).toHaveBeenCalledWith(SNIPPET_RENDERED, { + id: 'lit-test--args', + args: {}, + source: '
args story
', + }); }); it('should skip dynamic rendering for no-args stories', async () => { @@ -78,11 +78,11 @@ describe('sourceDecorator', () => { ); sourceDecorator(decoratedStoryFn, context); await tick(); - expect(mockChannel.emit).toHaveBeenCalledWith( - SNIPPET_RENDERED, - 'lit-test--args', - '
args story
' - ); + expect(mockChannel.emit).toHaveBeenCalledWith(SNIPPET_RENDERED, { + id: 'lit-test--args', + args: {}, + source: '
args story
', + }); }); it('allows the snippet output to be modified by transformSource', async () => { @@ -92,11 +92,11 @@ describe('sourceDecorator', () => { const context = makeContext('args', { __isArgsStory: true, docs }, {}); sourceDecorator(storyFn, context); await tick(); - expect(mockChannel.emit).toHaveBeenCalledWith( - SNIPPET_RENDERED, - 'lit-test--args', - '

args story

' - ); + expect(mockChannel.emit).toHaveBeenCalledWith(SNIPPET_RENDERED, { + id: 'lit-test--args', + args: {}, + source: '

args story

', + }); }); it('provides the story context to transformSource', () => { @@ -120,10 +120,10 @@ describe('sourceDecorator', () => { const boundStoryFn = storyFn.bind(null, context.args); sourceDecorator(boundStoryFn, context); await tick(); - expect(mockChannel.emit).toHaveBeenCalledWith( - SNIPPET_RENDERED, - 'lit-test--args', - '
some content
' - ); + expect(mockChannel.emit).toHaveBeenCalledWith(SNIPPET_RENDERED, { + id: 'lit-test--args', + args: { slot: 'some content' }, + source: '
some content
', + }); }); }); diff --git a/code/renderers/web-components/src/docs/sourceDecorator.ts b/code/renderers/web-components/src/docs/sourceDecorator.ts index ad9da01a9f8a..674fde3e48e2 100644 --- a/code/renderers/web-components/src/docs/sourceDecorator.ts +++ b/code/renderers/web-components/src/docs/sourceDecorator.ts @@ -43,7 +43,8 @@ export function sourceDecorator( let source: string; useEffect(() => { - if (source) addons.getChannel().emit(SNIPPET_RENDERED, context.id, source); + const { id, args } = context; + if (source) addons.getChannel().emit(SNIPPET_RENDERED, { id, source, args }); }); if (!skipSourceRender(context)) { const container = window.document.createElement('div'); diff --git a/code/ui/blocks/package.json b/code/ui/blocks/package.json index 34a43baa71e8..28af81b5c980 100644 --- a/code/ui/blocks/package.json +++ b/code/ui/blocks/package.json @@ -62,6 +62,7 @@ "memoizerific": "^1.11.3", "polished": "^4.2.2", "react-colorful": "^5.1.2", + "telejson": "^7.0.3", "ts-dedent": "^2.0.0", "util-deprecate": "^1.0.2" }, diff --git a/code/ui/blocks/src/blocks/Controls.tsx b/code/ui/blocks/src/blocks/Controls.tsx index 390b2f2e3d66..8a70a4a29520 100644 --- a/code/ui/blocks/src/blocks/Controls.tsx +++ b/code/ui/blocks/src/blocks/Controls.tsx @@ -1,20 +1,16 @@ /* eslint-disable react/destructuring-assignment */ -import type { Args, Globals, Renderer } from '@storybook/csf'; -import type { DocsContextProps, ModuleExports, PreparedStory } from '@storybook/types'; +import type { Renderer } from '@storybook/csf'; +import type { ModuleExports } from '@storybook/types'; import type { FC } from 'react'; -import React, { useCallback, useEffect, useState, useContext } from 'react'; -import type { PropDescriptor } from '@storybook/preview-api'; +import React, { useContext } from 'react'; import { filterArgTypes } from '@storybook/preview-api'; -import { - STORY_ARGS_UPDATED, - UPDATE_STORY_ARGS, - RESET_STORY_ARGS, - GLOBALS_UPDATED, -} from '@storybook/core-events'; +import type { PropDescriptor } from '@storybook/preview-api'; import type { SortType } from '../components'; import { ArgsTable as PureArgsTable } from '../components'; import { DocsContext } from './DocsContext'; +import { useGlobals } from './useGlobals'; +import { useArgs } from './useArgs'; type ControlsParameters = { include?: PropDescriptor; @@ -26,49 +22,6 @@ type ControlsProps = ControlsParameters & { of?: Renderer['component'] | ModuleExports; }; -const useArgs = ( - story: PreparedStory, - context: DocsContextProps -): [Args, (args: Args) => void, (argNames?: string[]) => void] => { - const storyContext = context.getStoryContext(story); - const { id: storyId } = story; - - const [args, setArgs] = useState(storyContext.args); - useEffect(() => { - const onArgsUpdated = (changed: { storyId: string; args: Args }) => { - if (changed.storyId === storyId) { - setArgs(changed.args); - } - }; - context.channel.on(STORY_ARGS_UPDATED, onArgsUpdated); - return () => context.channel.off(STORY_ARGS_UPDATED, onArgsUpdated); - }, [storyId, context.channel]); - const updateArgs = useCallback( - (updatedArgs) => context.channel.emit(UPDATE_STORY_ARGS, { storyId, updatedArgs }), - [storyId, context.channel] - ); - const resetArgs = useCallback( - (argNames?: string[]) => context.channel.emit(RESET_STORY_ARGS, { storyId, argNames }), - [storyId, context.channel] - ); - return [args, updateArgs, resetArgs]; -}; - -const useGlobals = (story: PreparedStory, context: DocsContextProps): [Globals] => { - const storyContext = context.getStoryContext(story); - - const [globals, setGlobals] = useState(storyContext.globals); - useEffect(() => { - const onGlobalsUpdated = (changed: { globals: Globals }) => { - setGlobals(changed.globals); - }; - context.channel.on(GLOBALS_UPDATED, onGlobalsUpdated); - return () => context.channel.off(GLOBALS_UPDATED, onGlobalsUpdated); - }, [context.channel]); - - return [globals]; -}; - export const Controls: FC = (props) => { const { of } = props; const context = useContext(DocsContext); diff --git a/code/ui/blocks/src/blocks/DocsStory.tsx b/code/ui/blocks/src/blocks/DocsStory.tsx index 70960447402f..b648e67881e3 100644 --- a/code/ui/blocks/src/blocks/DocsStory.tsx +++ b/code/ui/blocks/src/blocks/DocsStory.tsx @@ -27,7 +27,12 @@ export const DocsStory: FC = ({ )} - + ); }; diff --git a/code/ui/blocks/src/blocks/Source.stories.tsx b/code/ui/blocks/src/blocks/Source.stories.tsx index d9bd1d747b52..ad70eafedf80 100644 --- a/code/ui/blocks/src/blocks/Source.stories.tsx +++ b/code/ui/blocks/src/blocks/Source.stories.tsx @@ -4,18 +4,22 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Source } from './Source'; import * as ParametersStories from '../examples/SourceParameters.stories'; -import { SourceContext } from './SourceContainer'; +import { argsHash, SourceContext } from './SourceContainer'; const meta: Meta = { component: Source, parameters: { relativeCsfPaths: ['../examples/SourceParameters.stories'], snippets: { - 'storybook-blocks-example-sourceparameters--no-parameters': { - code: `const emitted = 'source';`, + 'storybook-blocks-examples-stories-for-the-source-block--no-parameters': { + [argsHash({})]: { + code: `const emitted = 'source';`, + }, }, - 'storybook-blocks-example-sourceparameters--type-dynamic': { - code: `const emitted = 'source';`, + 'storybook-blocks-examples-stories-for-the-source-block--type-dynamic': { + [argsHash({})]: { + code: `const emitted = 'source';`, + }, }, }, docsStyles: true, diff --git a/code/ui/blocks/src/blocks/Source.tsx b/code/ui/blocks/src/blocks/Source.tsx index 2c6e05f98ca5..335c338dc611 100644 --- a/code/ui/blocks/src/blocks/Source.tsx +++ b/code/ui/blocks/src/blocks/Source.tsx @@ -1,6 +1,6 @@ import type { ComponentProps, FC } from 'react'; import React, { useContext } from 'react'; -import type { StoryId, PreparedStory, ModuleExport } from '@storybook/types'; +import type { StoryId, PreparedStory, ModuleExport, Args } from '@storybook/types'; import { SourceType } from '@storybook/docs-tools'; import { deprecate } from '@storybook/client-logger'; @@ -10,9 +10,10 @@ import { Source as PureSource, SourceError } from '../components/Source'; import type { DocsContextProps } from './DocsContext'; import { DocsContext } from './DocsContext'; import type { SourceContextProps, SourceItem } from './SourceContainer'; -import { SourceContext } from './SourceContainer'; +import { UNKNOWN_ARGS_HASH, argsHash, SourceContext } from './SourceContainer'; import { useStories } from './useStory'; +import { useArgsList } from './useArgs'; export enum SourceState { OPEN = 'open', @@ -53,6 +54,11 @@ export type SourceProps = Omit { @@ -62,11 +68,22 @@ const getSourceState = (stories: PreparedStory[]) => { return states[0]; }; -const getStorySource = (storyId: StoryId, sourceContext: SourceContextProps): SourceItem => { +const getStorySource = ( + storyId: StoryId, + args: Args, + sourceContext: SourceContextProps +): SourceItem => { const { sources } = sourceContext; + + const sourceMap = sources?.[storyId]; + // If the source decorator hasn't provided args, we fallback to the "unknown args" + // version of the source (which means if you render a story >1 time with different args + // you'll get the same source value both times). + const source = sourceMap?.[argsHash(args)] || sourceMap?.[UNKNOWN_ARGS_HASH]; + // source rendering is async so source is unavailable at the start of the render cycle, // so we fail gracefully here without warning - return sources?.[storyId] || { code: '' }; + return source || { code: '' }; }; const getSnippet = ( @@ -106,9 +123,6 @@ export const useSourceProps = ( ): PureSourceProps & SourceStateProps => { const storyIds = props.ids || (props.id ? [props.id] : []); const storiesFromIds = useStories(storyIds, docsContext); - if (!storiesFromIds.every(Boolean)) { - return { error: SourceError.SOURCE_UNAVAILABLE, state: SourceState.NONE }; - } // The check didn't actually change the type. let stories: PreparedStory[] = storiesFromIds as PreparedStory[]; @@ -123,6 +137,11 @@ export const useSourceProps = ( // You are allowed to use and unattached. } } + const argsFromStories = useArgsList(stories, docsContext); + + if (!storiesFromIds.every(Boolean)) { + return { error: SourceError.SOURCE_UNAVAILABLE, state: SourceState.NONE }; + } const sourceParameters = (stories[0]?.parameters?.docs?.source || {}) as SourceParameters; let { code } = props; // We will fall back to `sourceParameters.code`, but per story below @@ -133,7 +152,18 @@ export const useSourceProps = ( if (!code) { code = stories .map((story, index) => { - const source = getStorySource(story.id, sourceContext); + // In theory you can use a storyId from a different CSF file that hasn't loaded yet. + if (!story) return ''; + + // NOTE: args *does* have to be defined here due to the null check on story above + const [args] = argsFromStories[index] || []; + + // eslint-disable-next-line no-underscore-dangle + const argsForSource = props.__forceInitialArgs + ? docsContext.getStoryContext(story).initialArgs + : args; + + const source = getStorySource(story.id, argsForSource, sourceContext); if (index === 0) { // Take the format from the first story format = source.format ?? story.parameters.docs?.source?.format ?? false; diff --git a/code/ui/blocks/src/blocks/SourceContainer.tsx b/code/ui/blocks/src/blocks/SourceContainer.tsx index ffc78191de28..d12e74ec36f1 100644 --- a/code/ui/blocks/src/blocks/SourceContainer.tsx +++ b/code/ui/blocks/src/blocks/SourceContainer.tsx @@ -6,13 +6,21 @@ import type { Channel } from '@storybook/channels'; import { SNIPPET_RENDERED } from '@storybook/docs-tools'; import type { SyntaxHighlighterFormatTypes } from '@storybook/components'; -import type { StoryId } from '@storybook/types'; +import type { StoryId, Args } from '@storybook/types'; + +import { stringify } from 'telejson'; + +type ArgsHash = string; +export function argsHash(args: Args): ArgsHash { + return stringify(args); +} export interface SourceItem { code: string; format?: SyntaxHighlighterFormatTypes; } -export type StorySources = Record; + +export type StorySources = Record>; export interface SourceContextProps { sources: StorySources; @@ -21,24 +29,51 @@ export interface SourceContextProps { export const SourceContext: Context = createContext({ sources: {} }); +type SnippetRenderedEvent = { + id: StoryId; + source: string; + args?: Args; + format?: SyntaxHighlighterFormatTypes; +}; + +export const UNKNOWN_ARGS_HASH = '--unknown--'; + export const SourceContainer: FC<{ channel: Channel }> = ({ children, channel }) => { const [sources, setSources] = useState({}); useEffect(() => { const handleSnippetRendered = ( - id: StoryId, - newSource: string, - format: SyntaxHighlighterFormatTypes = false + idOrEvent: StoryId | SnippetRenderedEvent, + inputSource: string = null, + inputFormat: SyntaxHighlighterFormatTypes = false ) => { + const { + id, + args = undefined, + source, + format, + } = typeof idOrEvent === 'string' + ? { + id: idOrEvent, + source: inputSource, + format: inputFormat, + } + : idOrEvent; + + const hash = args ? argsHash(args) : UNKNOWN_ARGS_HASH; + // optimization: if the source is the same, ignore the incoming event - if (sources[id] && sources[id].code === newSource) { + if (sources[id] && sources[id][hash] && sources[id][hash].code === source) { return; } setSources((current) => { const newSources = { ...current, - [id]: { code: newSource, format }, + [id]: { + ...current[id], + [hash]: { code: source, format }, + }, }; if (!deepEqual(current, newSources)) { diff --git a/code/ui/blocks/src/blocks/useArgs.ts b/code/ui/blocks/src/blocks/useArgs.ts new file mode 100644 index 000000000000..77859f20f027 --- /dev/null +++ b/code/ui/blocks/src/blocks/useArgs.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { Args, DocsContextProps, PreparedStory, StoryId, Renderer } from '@storybook/types'; +import { STORY_ARGS_UPDATED, UPDATE_STORY_ARGS, RESET_STORY_ARGS } from '@storybook/core-events'; +import { useStories } from './useStory'; + +export const useArgs = ( + story: PreparedStory, + context: DocsContextProps +): [Args, (args: Args) => void, (argNames?: string[]) => void] => { + const result = useArgsIfDefined(story, context); + if (!result) throw new Error('No result when story was defined'); + return result; +}; + +export const useArgsIfDefined = ( + story: PreparedStory | void, + context: DocsContextProps +): [Args, (args: Args) => void, (argNames?: string[]) => void] | void => { + const storyContext = story ? context.getStoryContext(story) : { args: {} }; + const { id: storyId } = story || { id: 'none' }; + + const [args, setArgs] = useState(storyContext.args); + useEffect(() => { + const onArgsUpdated = (changed: { storyId: string; args: Args }) => { + if (changed.storyId === storyId) { + setArgs(changed.args); + } + }; + context.channel.on(STORY_ARGS_UPDATED, onArgsUpdated); + return () => context.channel.off(STORY_ARGS_UPDATED, onArgsUpdated); + }, [storyId, context.channel]); + const updateArgs = useCallback( + (updatedArgs) => context.channel.emit(UPDATE_STORY_ARGS, { storyId, updatedArgs }), + [storyId, context.channel] + ); + const resetArgs = useCallback( + (argNames?: string[]) => context.channel.emit(RESET_STORY_ARGS, { storyId, argNames }), + [storyId, context.channel] + ); + return story && [args, updateArgs, resetArgs]; +}; + +export function useArgsList( + stories: (PreparedStory | void)[], + context: DocsContextProps +) { + return stories.map((story) => useArgsIfDefined(story, context)); +} diff --git a/code/ui/blocks/src/blocks/useGlobals.ts b/code/ui/blocks/src/blocks/useGlobals.ts new file mode 100644 index 000000000000..7c0552705d5d --- /dev/null +++ b/code/ui/blocks/src/blocks/useGlobals.ts @@ -0,0 +1,19 @@ +import type { Globals } from '@storybook/csf'; +import type { DocsContextProps, PreparedStory } from '@storybook/types'; +import { useEffect, useState } from 'react'; +import { GLOBALS_UPDATED } from '@storybook/core-events'; + +export const useGlobals = (story: PreparedStory, context: DocsContextProps): [Globals] => { + const storyContext = context.getStoryContext(story); + + const [globals, setGlobals] = useState(storyContext.globals); + useEffect(() => { + const onGlobalsUpdated = (changed: { globals: Globals }) => { + setGlobals(changed.globals); + }; + context.channel.on(GLOBALS_UPDATED, onGlobalsUpdated); + return () => context.channel.off(GLOBALS_UPDATED, onGlobalsUpdated); + }, [context.channel]); + + return [globals]; +}; diff --git a/code/yarn.lock b/code/yarn.lock index 92626af85812..438a77dbb817 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -5724,6 +5724,7 @@ __metadata: memoizerific: ^1.11.3 polished: ^4.2.2 react-colorful: ^5.1.2 + telejson: ^7.0.3 ts-dedent: ^2.0.0 util-deprecate: ^1.0.2 peerDependencies: