diff --git a/addons/docs/src/blocks/DocsContext.ts b/addons/docs/src/blocks/DocsContext.ts index c902d8fb9b2f..86d483eabaae 100644 --- a/addons/docs/src/blocks/DocsContext.ts +++ b/addons/docs/src/blocks/DocsContext.ts @@ -13,10 +13,10 @@ export type { DocsContextProps }; // This was specifically a problem with the Vite builder. /* eslint-disable no-underscore-dangle */ if (globalWindow && globalWindow.__DOCS_CONTEXT__ === undefined) { - globalWindow.__DOCS_CONTEXT__ = createContext({}); + globalWindow.__DOCS_CONTEXT__ = createContext(null); globalWindow.__DOCS_CONTEXT__.displayName = 'DocsContext'; } export const DocsContext: Context> = globalWindow ? globalWindow.__DOCS_CONTEXT__ - : createContext({}); + : createContext(null); diff --git a/addons/docs/src/blocks/DocsRenderer.tsx b/addons/docs/src/blocks/DocsRenderer.tsx new file mode 100644 index 000000000000..b9a217a344a9 --- /dev/null +++ b/addons/docs/src/blocks/DocsRenderer.tsx @@ -0,0 +1,58 @@ +import React, { ComponentType } from 'react'; +import ReactDOM from 'react-dom'; +import { AnyFramework, Parameters } from '@storybook/csf'; +import { DocsRenderFunction } from '@storybook/preview-web'; + +import { DocsContainer } from './DocsContainer'; +import { DocsPage } from './DocsPage'; +import { DocsContext, DocsContextProps } from './DocsContext'; + +export class DocsRenderer { + public render: DocsRenderFunction; + + public unmount: (element: HTMLElement) => void; + + constructor() { + this.render = ( + docsContext: DocsContextProps, + docsParameters: Parameters, + element: HTMLElement, + callback: () => void + ): void => { + renderDocsAsync(docsContext, docsParameters, element).then(callback); + }; + + this.unmount = (element: HTMLElement) => { + ReactDOM.unmountComponentAtNode(element); + }; + } +} + +async function renderDocsAsync( + docsContext: DocsContextProps, + docsParameters: Parameters, + element: HTMLElement +) { + // FIXME -- use DocsContainer, make it work for modern + const SimpleContainer = ({ children }: any) => ( + {children} + ); + + const Container: ComponentType<{ context: DocsContextProps }> = + docsParameters.container || + (await docsParameters.getContainer?.()) || + (docsContext.legacy ? DocsContainer : SimpleContainer); + + const Page: ComponentType = docsParameters.page || (await docsParameters.getPage?.()) || DocsPage; + + // Use `title` as a key so that we force a re-render every time we switch components + const docsElement = ( + + + + ); + + await new Promise((resolve) => { + ReactDOM.render(docsElement, element, resolve); + }); +} diff --git a/addons/docs/src/blocks/Meta.tsx b/addons/docs/src/blocks/Meta.tsx index 87c375d0796f..ed906936f791 100644 --- a/addons/docs/src/blocks/Meta.tsx +++ b/addons/docs/src/blocks/Meta.tsx @@ -16,6 +16,9 @@ function getFirstStoryId(docsContext: DocsContextProps): string { function renderAnchor() { const context = useContext(DocsContext); + if (!context.legacy) { + return null; + } const anchorId = getFirstStoryId(context) || context.id; return ; diff --git a/addons/docs/src/blocks/Story.tsx b/addons/docs/src/blocks/Story.tsx index e61fcb321512..370d465d51a6 100644 --- a/addons/docs/src/blocks/Story.tsx +++ b/addons/docs/src/blocks/Story.tsx @@ -36,6 +36,7 @@ type StoryDefProps = { type StoryRefProps = { id?: string; + of?: any; }; type StoryImportProps = { @@ -55,7 +56,12 @@ export const lookupStoryId = ( ); export const getStoryId = (props: StoryProps, context: DocsContextProps): StoryId => { - const { id } = props as StoryRefProps; + const { id, of } = props as StoryRefProps; + + if (of) { + return context.storyIdByModuleExport(of); + } + const { name } = props as StoryDefProps; const inputId = id === CURRENT_SELECTION ? context.id : id; return inputId || lookupStoryId(name, context); diff --git a/addons/docs/src/blocks/index.ts b/addons/docs/src/blocks/index.ts index 7096967b0db3..11fc1de42df1 100644 --- a/addons/docs/src/blocks/index.ts +++ b/addons/docs/src/blocks/index.ts @@ -7,6 +7,7 @@ export * from './Description'; export * from './DocsContext'; export * from './DocsPage'; export * from './DocsContainer'; +export * from './DocsRenderer'; // For testing export * from './DocsStory'; export * from './Heading'; export * from './Meta'; diff --git a/addons/docs/src/blocks/useStory.ts b/addons/docs/src/blocks/useStory.ts index 6a58d0472262..a9caa893001d 100644 --- a/addons/docs/src/blocks/useStory.ts +++ b/addons/docs/src/blocks/useStory.ts @@ -16,12 +16,9 @@ export function useStories( storyIds: StoryId[], context: DocsContextProps ): (Story | void)[] { - const initialStoriesById = context.componentStories().reduce((acc, story) => { - acc[story.id] = story; - return acc; - }, {} as Record>); - - const [storiesById, setStories] = useState(initialStoriesById as typeof initialStoriesById); + // Legacy docs pages can reference any story by id. Those stories will need to be + // asyncronously loaded; we use the state for this + const [storiesById, setStories] = useState>>({}); useEffect(() => { Promise.all( @@ -40,5 +37,14 @@ export function useStories( ); }); - return storyIds.map((storyId) => storiesById[storyId]); + return storyIds.map((storyId) => { + if (storiesById[storyId]) return storiesById[storyId]; + + try { + // If we are allowed to load this story id synchonously, this will work + return context.storyById(storyId); + } catch (err) { + return null; + } + }); } diff --git a/addons/docs/src/preview.ts b/addons/docs/src/preview.ts index a6269505f285..1cb1b297f323 100644 --- a/addons/docs/src/preview.ts +++ b/addons/docs/src/preview.ts @@ -1,6 +1,8 @@ export const parameters = { docs: { - getContainer: async () => (await import('./blocks')).DocsContainer, - getPage: async () => (await import('./blocks')).DocsPage, + renderer: async () => { + const { DocsRenderer } = await import('./blocks/DocsRenderer'); + return new DocsRenderer(); + }, }, }; diff --git a/examples/react-ts/src/docs2/MetaOf.docs.mdx b/examples/react-ts/src/docs2/MetaOf.docs.mdx index b5451cafaea1..2f5a1c9d4277 100644 --- a/examples/react-ts/src/docs2/MetaOf.docs.mdx +++ b/examples/react-ts/src/docs2/MetaOf.docs.mdx @@ -1,7 +1,10 @@ -import meta from '../button.stories'; +import { Meta, Story } from '@storybook/addon-docs'; +import meta, { Basic } from '../button.stories'; # Docs with of hello docs + + diff --git a/lib/core-client/src/preview/start.test.ts b/lib/core-client/src/preview/start.test.ts index 7b6d60121091..631ce8c4f317 100644 --- a/lib/core-client/src/preview/start.test.ts +++ b/lib/core-client/src/preview/start.test.ts @@ -945,6 +945,7 @@ describe('start', () => { "v": 2, } `); + await waitForRender(); mockChannel.emit.mockClear(); disposeCallback(module.hot.data); @@ -1340,6 +1341,8 @@ describe('start', () => { "v": 2, } `); + + await waitForRender(); }); }); }); diff --git a/lib/preview-web/package.json b/lib/preview-web/package.json index 3c4e94b6c406..f294182e88db 100644 --- a/lib/preview-web/package.json +++ b/lib/preview-web/package.json @@ -57,10 +57,6 @@ "unfetch": "^4.2.0", "util-deprecate": "^1.0.2" }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, "publishConfig": { "access": "public" }, diff --git a/lib/preview-web/src/DocsRender.ts b/lib/preview-web/src/DocsRender.ts index d386611584e6..1f5c8132dce5 100644 --- a/lib/preview-web/src/DocsRender.ts +++ b/lib/preview-web/src/DocsRender.ts @@ -1,53 +1,78 @@ import global from 'global'; import { AnyFramework, StoryId, ViewMode, StoryContextForLoaders } from '@storybook/csf'; -import { Story, StoryStore, CSFFile } from '@storybook/store'; +import { Story, StoryStore, CSFFile, ModuleExports, IndexEntry } from '@storybook/store'; import { Channel } from '@storybook/addons'; import { DOCS_RENDERED } from '@storybook/core-events'; -import { Render, StoryRender } from './StoryRender'; -import type { DocsContextProps } from './types'; +import { Render, RenderType } from './StoryRender'; +import type { DocsContextProps, DocsRenderFunction } from './types'; export class DocsRender implements Render { + public type: RenderType = 'docs'; + + public id: StoryId; + + public legacy: boolean; + + public story?: Story; + + public exports?: ModuleExports; + + private csfFiles?: CSFFile[]; + + private preparing = false; + private canvasElement?: HTMLElement; - private context?: DocsContextProps; + private docsContext?: DocsContextProps; public disableKeyListeners = false; - static fromStoryRender(storyRender: StoryRender) { - const { channel, store, id, story } = storyRender; - return new DocsRender(channel, store, id, story); - } + public teardown: (options: { viewModeChanged?: boolean }) => Promise; - // eslint-disable-next-line no-useless-constructor constructor( private channel: Channel, private store: StoryStore, - public id: StoryId, - public story: Story - ) {} + public entry: IndexEntry + ) { + this.id = entry.id; + this.legacy = entry.type !== 'docs' || entry.legacy; + } - // DocsRender doesn't prepare, it is created *from* a prepared StoryRender - isPreparing() { - return false; + // The two story "renders" are equal and have both loaded the same story + isEqual(other?: Render) { + if (!other) return false; + return this.id === other.id && this.legacy + ? this.story && this.story === other.story + : other.type === 'docs' && this.entry === (other as DocsRender).entry; } - async renderToElement( - canvasElement: HTMLElement, - renderStoryToElement: DocsContextProps['renderStoryToElement'] - ) { - this.canvasElement = canvasElement; + async prepare() { + this.preparing = true; + if (this.legacy) { + this.story = await this.store.loadStory({ storyId: this.id }); + } else { + const { docsExports, csfFiles } = await this.store.loadDocsFileById(this.id); + this.exports = docsExports; + this.csfFiles = csfFiles; + } + this.preparing = false; + } + + isPreparing() { + return this.preparing; + } - const { id, title, name } = this.story; - const csfFile: CSFFile = await this.store.loadCSFFileByStoryId(this.id); + async getDocsContext( + renderStoryToElement: DocsContextProps['renderStoryToElement'] + ): Promise> { + const { id, title, name } = this.entry; - this.context = { + const base = { + legacy: this.legacy, id, title, name, - // NOTE: these two functions are *sync* so cannot access stories from other CSF files - storyById: (storyId: StoryId) => this.store.storyFromCSFFile({ storyId, csfFile }), - componentStories: () => this.store.componentStoriesFromCSFFile({ csfFile }), loadStory: (storyId: StoryId) => this.store.loadStory({ storyId }), renderStoryToElement, getStoryContext: (renderedStory: Story) => @@ -55,21 +80,84 @@ export class DocsRender implements Render), - // Put all the storyContext fields onto the docs context for back-compat - ...(!global.FEATURES?.breakingChangesV7 && this.store.getStoryContext(this.story)), }; + if (this.legacy) { + const csfFile: CSFFile = await this.store.loadCSFFileByStoryId(this.id); + const componentStories = () => this.store.componentStoriesFromCSFFile({ csfFile }); + return { + ...base, + + storyIdByModuleExport: () => { + // NOTE: we could implement this easily enough by checking all the component stories + throw new Error('`storyIdByModuleExport` not available for legacy docs files.'); + }, + storyById: (storyId: StoryId) => this.store.storyFromCSFFile({ storyId, csfFile }), + + componentStories, + }; + } + + return { + ...base, + storyIdByModuleExport: (moduleExport) => { + // eslint-disable-next-line no-restricted-syntax + for (const csfFile of this.csfFiles) { + // eslint-disable-next-line no-restricted-syntax + for (const annotation of Object.values(csfFile.stories)) { + if (annotation.moduleExport === moduleExport) { + return annotation.id; + } + } + } + + throw new Error(`No story found with that export: ${moduleExport}`); + }, + storyById: () => { + throw new Error('`storyById` not available for modern docs files.'); + }, + componentStories: () => { + throw new Error('You cannot render all the stories for a component in a docs.mdx file'); + }, + }; + } + + async renderToElement( + canvasElement: HTMLElement, + renderStoryToElement: DocsContextProps['renderStoryToElement'] + ) { + this.canvasElement = canvasElement; + this.docsContext = await this.getDocsContext(renderStoryToElement); + return this.render(); } async render() { - if (!this.story || !this.context || !this.canvasElement) + if (!(this.story || this.exports) || !this.docsContext || !this.canvasElement) throw new Error('DocsRender not ready to render'); - const renderer = await import('./renderDocs'); - renderer.renderDocs(this.story, this.context, this.canvasElement, () => - this.channel.emit(DOCS_RENDERED, this.id) + const { docs } = this.story?.parameters || this.store.projectAnnotations.parameters; + + if (!docs) { + throw new Error( + `Cannot render a story in viewMode=docs if \`@storybook/addon-docs\` is not installed` + ); + } + + const renderer = await docs.renderer(); + (renderer.render as DocsRenderFunction)( + this.docsContext, + { + ...docs, + ...(!this.legacy && { page: this.exports.default }), + }, + this.canvasElement, + () => this.channel.emit(DOCS_RENDERED, this.id) ); + this.teardown = async ({ viewModeChanged }: { viewModeChanged?: boolean } = {}) => { + if (!viewModeChanged || !this.canvasElement) return; + renderer.unmount(this.canvasElement); + }; } async rerender() { @@ -79,10 +167,4 @@ export class DocsRender implements Render ( -
-
-

No Docs

-

- Sorry, but there are no docs for the selected story. To add them, set the story's  - docs parameter. If you think this is an error: -

-
    -
  • Please check the story definition.
  • -
  • Please check the Storybook config.
  • -
  • Try reloading the page.
  • -
-

- If the problem persists, check the browser console, or the terminal you've run Storybook - from. -

-
-
-); diff --git a/lib/preview-web/src/PreviewWeb.integration.test.ts b/lib/preview-web/src/PreviewWeb.integration.test.ts index 2e452c167c45..b1f636beb08d 100644 --- a/lib/preview-web/src/PreviewWeb.integration.test.ts +++ b/lib/preview-web/src/PreviewWeb.integration.test.ts @@ -2,6 +2,7 @@ import React from 'react'; import global from 'global'; import { RenderContext } from '@storybook/store'; import addons, { mockChannel as createMockChannel } from '@storybook/addons'; +import { DocsRenderer } from '@storybook/addon-docs'; import { PreviewWeb } from './PreviewWeb'; import { @@ -51,6 +52,7 @@ beforeEach(() => { projectAnnotations.renderToDOM.mockReset(); projectAnnotations.render.mockClear(); projectAnnotations.decorators[0].mockClear(); + projectAnnotations.parameters.docs.renderer = () => new DocsRenderer() as any; addons.setChannel(mockChannel as any); addons.setServerChannel(createMockChannel()); diff --git a/lib/preview-web/src/PreviewWeb.mockdata.ts b/lib/preview-web/src/PreviewWeb.mockdata.ts index cb3d4dc928bc..9be71617b30d 100644 --- a/lib/preview-web/src/PreviewWeb.mockdata.ts +++ b/lib/preview-web/src/PreviewWeb.mockdata.ts @@ -1,7 +1,6 @@ import { EventEmitter } from 'events'; import Events from '@storybook/core-events'; import { StoryIndex } from '@storybook/store'; -import { RenderPhase } from './PreviewWeb'; export const componentOneExports = { default: { @@ -21,16 +20,34 @@ export const componentTwoExports = { default: { title: 'Component Two' }, c: { args: { foo: 'c' } }, }; -export const importFn = jest.fn(async (path) => { - return path === './src/ComponentOne.stories.js' ? componentOneExports : componentTwoExports; -}); +export const legacyDocsExports = { + default: { title: 'Introduction' }, + Docs: { parameters: { docs: { page: jest.fn() } } }, +}; +export const modernDocsExports = { + default: jest.fn(), +}; +export const importFn = jest.fn( + async (path) => + ({ + './src/ComponentOne.stories.js': componentOneExports, + './src/ComponentTwo.stories.js': componentTwoExports, + './src/Legacy.stories.mdx': legacyDocsExports, + './src/Introduction.docs.mdx': modernDocsExports, + }[path]) +); +export const docsRenderer = { + render: jest.fn().mockImplementation((context, parameters, element, cb) => cb()), + unmount: jest.fn(), +}; export const projectAnnotations = { globals: { a: 'b' }, globalTypes: {}, decorators: [jest.fn((s) => s())], render: jest.fn(), renderToDOM: jest.fn(), + parameters: { docs: { renderer: () => docsRenderer } }, }; export const getProjectAnnotations = () => projectAnnotations; @@ -38,23 +55,43 @@ export const storyIndex: StoryIndex = { v: 4, entries: { 'component-one--a': { + type: 'story', id: 'component-one--a', title: 'Component One', name: 'A', importPath: './src/ComponentOne.stories.js', }, 'component-one--b': { + type: 'story', id: 'component-one--b', title: 'Component One', name: 'B', importPath: './src/ComponentOne.stories.js', }, 'component-two--c': { + type: 'story', id: 'component-two--c', title: 'Component Two', name: 'C', importPath: './src/ComponentTwo.stories.js', }, + 'introduction--docs': { + type: 'docs', + id: 'introduction--docs', + title: 'Introduction', + name: 'Docs', + importPath: './src/Introduction.docs.mdx', + storiesImports: ['./src/ComponentTwo.stories.js'], + }, + 'legacy--docs': { + type: 'docs', + legacy: true, + id: 'legacy--docs', + title: 'Legacy', + name: 'Docs', + importPath: './src/Legacy.stories.mdx', + storiesImports: [], + }, }, }; export const getStoryIndex = () => storyIndex; @@ -106,7 +143,7 @@ export const waitForRender = () => Events.STORY_MISSING, ]); -export const waitForRenderPhase = (phase: RenderPhase) => +export const waitForRenderPhase = (phase) => waitForEvents([Events.STORY_RENDER_PHASE_CHANGED], ({ newPhase }) => newPhase === phase); // A little trick to ensure that we always call the real `setTimeout` even when timers are mocked diff --git a/lib/preview-web/src/PreviewWeb.test.ts b/lib/preview-web/src/PreviewWeb.test.ts index a2253ab6a3ad..5d7c76986d0f 100644 --- a/lib/preview-web/src/PreviewWeb.test.ts +++ b/lib/preview-web/src/PreviewWeb.test.ts @@ -1,5 +1,4 @@ import global from 'global'; -import * as ReactDOM from 'react-dom'; import merge from 'lodash/merge'; import Events, { IGNORED_EXCEPTION } from '@storybook/core-events'; import { logger } from '@storybook/client-logger'; @@ -21,6 +20,9 @@ import { waitForRender, waitForQuiescence, waitForRenderPhase, + docsRenderer, + legacyDocsExports, + modernDocsExports, } from './PreviewWeb.mockdata'; jest.mock('./WebView'); @@ -100,8 +102,7 @@ beforeEach(() => { projectAnnotations.renderToDOM.mockReset(); projectAnnotations.render.mockClear(); projectAnnotations.decorators[0].mockClear(); - // @ts-ignore - ReactDOM.render.mockReset().mockImplementation((_: any, _2: any, cb: () => any) => cb()); + docsRenderer.render.mockClear(); // @ts-ignore logger.warn.mockClear(); mockStoryIndex.mockReset().mockReturnValue(storyIndex); @@ -285,6 +286,7 @@ describe('PreviewWeb', () => { entries: { ...storyIndex.entries, 'component-one--d': { + type: 'story', id: 'component-one--d', title: 'Component One', name: 'D', @@ -335,6 +337,7 @@ describe('PreviewWeb', () => { entries: { ...storyIndex.entries, 'component-one--d': { + type: 'story', id: 'component-one--d', title: 'Component One', name: 'D', @@ -358,256 +361,307 @@ describe('PreviewWeb', () => { expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_MISSING); }); - describe('in story viewMode', () => { - it('calls view.prepareForStory', async () => { - document.location.search = '?id=component-one--a'; - - const preview = await createAndRenderPreview(); - - expect(preview.view.prepareForStory).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'component-one--a', - }) - ); - }); + describe('story entries', () => { + describe('in story viewMode', () => { + it('calls view.prepareForStory', async () => { + document.location.search = '?id=component-one--a'; - it('emits STORY_PREPARED', async () => { - document.location.search = '?id=component-one--a'; - await createAndRenderPreview(); + const preview = await createAndRenderPreview(); - expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_PREPARED, { - id: 'component-one--a', - parameters: { - __isArgsStory: false, - docs: { container: expect.any(Function) }, - fileName: './src/ComponentOne.stories.js', - }, - initialArgs: { foo: 'a' }, - argTypes: { foo: { name: 'foo', type: { name: 'string' } } }, - args: { foo: 'a' }, + expect(preview.view.prepareForStory).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'component-one--a', + }) + ); }); - }); - it('applies loaders with story context', async () => { - document.location.search = '?id=component-one--a'; - await createAndRenderPreview(); + it('emits STORY_PREPARED', async () => { + document.location.search = '?id=component-one--a'; + await createAndRenderPreview(); - expect(componentOneExports.default.loaders[0]).toHaveBeenCalledWith( - expect.objectContaining({ + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_PREPARED, { id: 'component-one--a', parameters: { __isArgsStory: false, - docs: { container: expect.any(Function) }, + docs: expect.any(Object), fileName: './src/ComponentOne.stories.js', }, initialArgs: { foo: 'a' }, argTypes: { foo: { name: 'foo', type: { name: 'string' } } }, args: { foo: 'a' }, - }) - ); - }); + }); + }); - it('passes loaded context to renderToDOM', async () => { - document.location.search = '?id=component-one--a'; - await createAndRenderPreview(); + it('applies loaders with story context', async () => { + document.location.search = '?id=component-one--a'; + await createAndRenderPreview(); - expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith( - expect.objectContaining({ - forceRemount: true, - storyContext: expect.objectContaining({ + expect(componentOneExports.default.loaders[0]).toHaveBeenCalledWith( + expect.objectContaining({ id: 'component-one--a', parameters: { __isArgsStory: false, - docs: { container: expect.any(Function) }, + docs: expect.any(Object), fileName: './src/ComponentOne.stories.js', }, - globals: { a: 'b' }, initialArgs: { foo: 'a' }, argTypes: { foo: { name: 'foo', type: { name: 'string' } } }, args: { foo: 'a' }, - loaded: { l: 7 }, + }) + ); + }); + + it('passes loaded context to renderToDOM', async () => { + document.location.search = '?id=component-one--a'; + await createAndRenderPreview(); + + expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith( + expect.objectContaining({ + forceRemount: true, + storyContext: expect.objectContaining({ + id: 'component-one--a', + parameters: { + __isArgsStory: false, + docs: expect.any(Object), + fileName: './src/ComponentOne.stories.js', + }, + globals: { a: 'b' }, + initialArgs: { foo: 'a' }, + argTypes: { foo: { name: 'foo', type: { name: 'string' } } }, + args: { foo: 'a' }, + loaded: { l: 7 }, + }), }), - }), - undefined // this is coming from view.prepareForStory, not super important - ); - }); + undefined // this is coming from view.prepareForStory, not super important + ); + }); - it('renders exception if a loader throws', async () => { - const error = new Error('error'); - componentOneExports.default.loaders[0].mockImplementationOnce(() => { - throw error; + it('renders exception if a loader throws', async () => { + const error = new Error('error'); + componentOneExports.default.loaders[0].mockImplementationOnce(() => { + throw error; + }); + + document.location.search = '?id=component-one--a'; + const preview = await createAndRenderPreview(); + + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_THREW_EXCEPTION, error); + expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error); }); - document.location.search = '?id=component-one--a'; - const preview = await createAndRenderPreview(); + it('renders exception if renderToDOM throws', async () => { + const error = new Error('error'); + projectAnnotations.renderToDOM.mockImplementationOnce(() => { + throw error; + }); - expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_THREW_EXCEPTION, error); - expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error); - }); + document.location.search = '?id=component-one--a'; + const preview = await createAndRenderPreview(); - it('renders exception if renderToDOM throws', async () => { - const error = new Error('error'); - projectAnnotations.renderToDOM.mockImplementationOnce(() => { - throw error; + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_THREW_EXCEPTION, error); + expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error); }); - document.location.search = '?id=component-one--a'; - const preview = await createAndRenderPreview(); + it('renders helpful message if renderToDOM is undefined', async () => { + const originalRenderToDOM = projectAnnotations.renderToDOM; + try { + projectAnnotations.renderToDOM = undefined; - expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_THREW_EXCEPTION, error); - expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error); - }); + document.location.search = '?id=component-one--a'; + const preview = new PreviewWeb(); + await expect(preview.initialize({ importFn, getProjectAnnotations })).rejects.toThrow(); + + expect(preview.view.showErrorDisplay).toHaveBeenCalled(); + expect((preview.view.showErrorDisplay as jest.Mock).mock.calls[0][0]) + .toMatchInlineSnapshot(` + [Error: Expected your framework's preset to export a \`renderToDOM\` field. - it('renders helpful message if renderToDOM is undefined', async () => { - const originalRenderToDOM = projectAnnotations.renderToDOM; - try { - projectAnnotations.renderToDOM = undefined; + Perhaps it needs to be upgraded for Storybook 6.4? + + More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field ] + `); + } finally { + projectAnnotations.renderToDOM = originalRenderToDOM; + } + }); + + it('renders exception if the play function throws', async () => { + const error = new Error('error'); + componentOneExports.a.play.mockImplementationOnce(() => { + throw error; + }); document.location.search = '?id=component-one--a'; - const preview = new PreviewWeb(); - await expect(preview.initialize({ importFn, getProjectAnnotations })).rejects.toThrow(); + const preview = await createAndRenderPreview(); - expect(preview.view.showErrorDisplay).toHaveBeenCalled(); - expect((preview.view.showErrorDisplay as jest.Mock).mock.calls[0][0]) - .toMatchInlineSnapshot(` - [Error: Expected your framework's preset to export a \`renderToDOM\` field. + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_THREW_EXCEPTION, error); + expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error); + }); - Perhaps it needs to be upgraded for Storybook 6.4? + it('renders error if the story calls showError', async () => { + const error = { title: 'title', description: 'description' }; + projectAnnotations.renderToDOM.mockImplementationOnce((context) => + context.showError(error) + ); - More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field ] - `); - } finally { - projectAnnotations.renderToDOM = originalRenderToDOM; - } - }); + document.location.search = '?id=component-one--a'; + const preview = await createAndRenderPreview(); - it('renders exception if the play function throws', async () => { - const error = new Error('error'); - componentOneExports.a.play.mockImplementationOnce(() => { - throw error; + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_ERRORED, error); + expect(preview.view.showErrorDisplay).toHaveBeenCalledWith({ + message: error.title, + stack: error.description, + }); }); - document.location.search = '?id=component-one--a'; - const preview = await createAndRenderPreview(); + it('renders exception if the story calls showException', async () => { + const error = new Error('error'); + projectAnnotations.renderToDOM.mockImplementationOnce((context) => + context.showException(error) + ); - expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_THREW_EXCEPTION, error); - expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error); - }); + document.location.search = '?id=component-one--a'; + const preview = await createAndRenderPreview(); - it('renders error if the story calls showError', async () => { - const error = { title: 'title', description: 'description' }; - projectAnnotations.renderToDOM.mockImplementationOnce((context) => - context.showError(error) - ); + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_THREW_EXCEPTION, error); + expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error); + }); - document.location.search = '?id=component-one--a'; - const preview = await createAndRenderPreview(); + it('executes playFunction', async () => { + document.location.search = '?id=component-one--a'; + await createAndRenderPreview(); - expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_ERRORED, error); - expect(preview.view.showErrorDisplay).toHaveBeenCalledWith({ - message: error.title, - stack: error.description, + expect(componentOneExports.a.play).toHaveBeenCalled(); }); - }); - it('renders exception if the story calls showException', async () => { - const error = new Error('error'); - projectAnnotations.renderToDOM.mockImplementationOnce((context) => - context.showException(error) - ); + it('emits STORY_RENDERED', async () => { + document.location.search = '?id=component-one--a'; + await createAndRenderPreview(); - document.location.search = '?id=component-one--a'; - const preview = await createAndRenderPreview(); + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_RENDERED, 'component-one--a'); + }); - expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_THREW_EXCEPTION, error); - expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error); - }); + it('does not show error display if the render function throws IGNORED_EXCEPTION', async () => { + document.location.search = '?id=component-one--a'; + projectAnnotations.renderToDOM.mockImplementationOnce(() => { + throw IGNORED_EXCEPTION; + }); - it('executes playFunction', async () => { - document.location.search = '?id=component-one--a'; - await createAndRenderPreview(); + const preview = new PreviewWeb(); + await preview.initialize({ importFn, getProjectAnnotations }); - expect(componentOneExports.a.play).toHaveBeenCalled(); + await waitForRender(); + + expect(mockChannel.emit).toHaveBeenCalledWith( + Events.STORY_THREW_EXCEPTION, + IGNORED_EXCEPTION + ); + expect(preview.view.showErrorDisplay).not.toHaveBeenCalled(); + }); }); - it('emits STORY_RENDERED', async () => { - document.location.search = '?id=component-one--a'; - await createAndRenderPreview(); + describe('in docs viewMode', () => { + it('calls view.prepareForDocs', async () => { + document.location.search = '?id=component-one--a&viewMode=docs'; + const preview = await createAndRenderPreview(); - expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_RENDERED, 'component-one--a'); - }); + expect(preview.view.prepareForDocs).toHaveBeenCalled(); + }); - it('does not show error display if the render function throws IGNORED_EXCEPTION', async () => { - document.location.search = '?id=component-one--a'; - projectAnnotations.renderToDOM.mockImplementationOnce(() => { - throw IGNORED_EXCEPTION; + it('emits STORY_PREPARED', async () => { + document.location.search = '?id=component-one--a&viewMode=docs'; + await createAndRenderPreview(); + + expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_PREPARED, { + id: 'component-one--a', + parameters: { + __isArgsStory: false, + docs: expect.any(Object), + fileName: './src/ComponentOne.stories.js', + }, + initialArgs: { foo: 'a' }, + argTypes: { foo: { name: 'foo', type: { name: 'string' } } }, + args: { foo: 'a' }, + }); }); - const preview = new PreviewWeb(); - await preview.initialize({ importFn, getProjectAnnotations }); + it('calls the docs renderer with the correct context, and parameters', async () => { + document.location.search = '?id=component-one--a&viewMode=docs'; - await waitForRender(); + await createAndRenderPreview(); - expect(mockChannel.emit).toHaveBeenCalledWith( - Events.STORY_THREW_EXCEPTION, - IGNORED_EXCEPTION - ); - expect(preview.view.showErrorDisplay).not.toHaveBeenCalled(); + expect(docsRenderer.render).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'component-one--a', + title: 'Component One', + name: 'A', + }), + expect.objectContaining({}), // docs parameters, nothing special + 'docs-element', + expect.any(Function) + ); + }); + + it('emits DOCS_RENDERED', async () => { + document.location.search = '?id=component-one--a&viewMode=docs'; + + await createAndRenderPreview(); + + expect(mockChannel.emit).toHaveBeenCalledWith(Events.DOCS_RENDERED, 'component-one--a'); + }); }); }); - describe('in docs viewMode', () => { - it('calls view.prepareForDocs', async () => { - document.location.search = '?id=component-one--a&viewMode=docs'; - const preview = await createAndRenderPreview(); + describe('legacy docs entries', () => { + it('always renders in docs viewMode', async () => { + document.location.search = '?id=legacy--docs'; + await createAndRenderPreview(); - expect(preview.view.prepareForDocs).toHaveBeenCalled(); + expect(mockChannel.emit).toHaveBeenCalledWith(Events.DOCS_RENDERED, 'legacy--docs'); }); - - it('emits STORY_PREPARED', async () => { - document.location.search = '?id=component-one--a&viewMode=docs'; + it('renders with the generated docs parameters', async () => { + document.location.search = '?id=legacy--docs&viewMode=docs'; await createAndRenderPreview(); - expect(mockChannel.emit).toHaveBeenCalledWith(Events.STORY_PREPARED, { - id: 'component-one--a', - parameters: { - __isArgsStory: false, - docs: { container: expect.any(Function) }, - fileName: './src/ComponentOne.stories.js', - }, - initialArgs: { foo: 'a' }, - argTypes: { foo: { name: 'foo', type: { name: 'string' } } }, - args: { foo: 'a' }, - }); + expect(docsRenderer.render).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + page: legacyDocsExports.Docs.parameters.docs.page, + renderer: projectAnnotations.parameters.docs.renderer, + }), + 'docs-element', + expect.any(Function) + ); }); + }); - it('render the docs container with the correct context', async () => { - document.location.search = '?id=component-one--a&viewMode=docs'; + describe('modern docs entries', () => { + it('always renders in docs viewMode', async () => { + document.location.search = '?id=introduction--docs'; + await createAndRenderPreview(); + expect(mockChannel.emit).toHaveBeenCalledWith(Events.DOCS_RENDERED, 'introduction--docs'); + }); + it('renders with the generated docs parameters', async () => { + document.location.search = '?id=introduction--docs&viewMode=docs'; await createAndRenderPreview(); - expect(ReactDOM.render).toHaveBeenCalledWith( + expect(docsRenderer.render).toHaveBeenCalledWith( + expect.any(Object), expect.objectContaining({ - type: componentOneExports.default.parameters.docs.container, - props: expect.objectContaining({ - context: expect.objectContaining({ - id: 'component-one--a', - title: 'Component One', - name: 'A', - }), - }), + page: modernDocsExports.default, + renderer: projectAnnotations.parameters.docs.renderer, }), 'docs-element', expect.any(Function) ); }); - it('emits DOCS_RENDERED', async () => { - document.location.search = '?id=component-one--a&viewMode=docs'; - + it('loads imports of the docs entry', async () => { + document.location.search = '?id=introduction--docs'; await createAndRenderPreview(); - expect(mockChannel.emit).toHaveBeenCalledWith(Events.DOCS_RENDERED, 'component-one--a'); + expect(importFn).toHaveBeenCalledWith('./src/ComponentTwo.stories.js'); }); }); }); @@ -676,7 +730,7 @@ describe('PreviewWeb', () => { emitter.emit(Events.UPDATE_GLOBALS, { globals: { foo: 'bar' } }); await waitForRender(); - expect(ReactDOM.render).toHaveBeenCalledTimes(2); + expect(docsRenderer.render).toHaveBeenCalledTimes(2); }); }); }); @@ -945,7 +999,7 @@ describe('PreviewWeb', () => { await createAndRenderPreview(); - (ReactDOM.render as jest.MockedFunction).mockClear(); + docsRenderer.render.mockClear(); mockChannel.emit.mockClear(); emitter.emit(Events.UPDATE_STORY_ARGS, { storyId: 'component-one--a', @@ -953,7 +1007,7 @@ describe('PreviewWeb', () => { }); await waitForRender(); - expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(docsRenderer.render).toHaveBeenCalledTimes(1); }); }); @@ -969,7 +1023,7 @@ describe('PreviewWeb', () => { await createAndRenderPreview(); - (ReactDOM.render as jest.MockedFunction).mockClear(); + docsRenderer.render.mockClear(); mockChannel.emit.mockClear(); emitter.emit(Events.UPDATE_STORY_ARGS, { storyId: 'component-one--a', @@ -977,7 +1031,7 @@ describe('PreviewWeb', () => { }); await waitForEvents([Events.STORY_ARGS_UPDATED]); - expect(ReactDOM.render).not.toHaveBeenCalled(); + expect(docsRenderer.render).not.toHaveBeenCalled(); }); describe('when renderStoryToElement was called', () => { @@ -1001,7 +1055,7 @@ describe('PreviewWeb', () => { 'story-element' ); - (ReactDOM.render as jest.MockedFunction).mockClear(); + docsRenderer.render.mockClear(); mockChannel.emit.mockClear(); emitter.emit(Events.UPDATE_STORY_ARGS, { storyId: 'component-one--a', @@ -1032,6 +1086,35 @@ describe('PreviewWeb', () => { await preview.onPreloadStories(['component-two--c']); expect(importFn).toHaveBeenCalledWith('./src/ComponentTwo.stories.js'); }); + + it('loads legacy docs entries', async () => { + document.location.search = '?id=component-one--a&viewMode=docs'; + const preview = await createAndRenderPreview(); + await waitForRender(); + + importFn.mockClear(); + await preview.onPreloadStories(['legacy--docs']); + expect(importFn).toHaveBeenCalledWith('./src/Legacy.stories.mdx'); + }); + + it('loads modern docs entries', async () => { + document.location.search = '?id=component-one--a&viewMode=docs'; + const preview = await createAndRenderPreview(); + await waitForRender(); + + importFn.mockClear(); + await preview.onPreloadStories(['introduction--docs']); + expect(importFn).toHaveBeenCalledWith('./src/Introduction.docs.mdx'); + }); + it('loads imports of modern docs entries', async () => { + document.location.search = '?id=component-one--a&viewMode=docs'; + const preview = await createAndRenderPreview(); + await waitForRender(); + + importFn.mockClear(); + await preview.onPreloadStories(['introduction--docs']); + expect(importFn).toHaveBeenCalledWith('./src/ComponentTwo.stories.js'); + }); }); describe('onResetArgs', () => { @@ -1440,7 +1523,7 @@ describe('PreviewWeb', () => { id: 'component-one--b', parameters: { __isArgsStory: false, - docs: { container: expect.any(Function) }, + docs: expect.any(Object), fileName: './src/ComponentOne.stories.js', }, initialArgs: { foo: 'b' }, @@ -1466,7 +1549,7 @@ describe('PreviewWeb', () => { id: 'component-one--b', parameters: { __isArgsStory: false, - docs: { container: expect.any(Function) }, + docs: expect.any(Object), fileName: './src/ComponentOne.stories.js', }, initialArgs: { foo: 'b' }, @@ -1495,7 +1578,7 @@ describe('PreviewWeb', () => { id: 'component-one--b', parameters: { __isArgsStory: false, - docs: { container: expect.any(Function) }, + docs: expect.any(Object), fileName: './src/ComponentOne.stories.js', }, globals: { a: 'b' }, @@ -1887,17 +1970,13 @@ describe('PreviewWeb', () => { await waitForSetCurrentStory(); await waitForRender(); - expect(ReactDOM.render).toHaveBeenCalledWith( + expect(docsRenderer.render).toHaveBeenCalledWith( expect.objectContaining({ - type: componentOneExports.default.parameters.docs.container, - props: expect.objectContaining({ - context: expect.objectContaining({ - id: 'component-one--a', - title: 'Component One', - name: 'A', - }), - }), + id: 'component-one--a', + title: 'Component One', + name: 'A', }), + expect.any(Object), 'docs-element', expect.any(Function) ); @@ -1949,7 +2028,7 @@ describe('PreviewWeb', () => { await waitForSetCurrentStory(); await waitForRender(); - expect(ReactDOM.unmountComponentAtNode).toHaveBeenCalled(); + expect(docsRenderer.unmount).toHaveBeenCalled(); }); // NOTE: I am not sure this entirely makes sense but this is the behaviour from 6.3 @@ -2003,7 +2082,7 @@ describe('PreviewWeb', () => { id: 'component-one--a', parameters: { __isArgsStory: false, - docs: { container: expect.any(Function) }, + docs: expect.any(Object), fileName: './src/ComponentOne.stories.js', }, initialArgs: { foo: 'a' }, @@ -2029,7 +2108,7 @@ describe('PreviewWeb', () => { id: 'component-one--a', parameters: { __isArgsStory: false, - docs: { container: expect.any(Function) }, + docs: expect.any(Object), fileName: './src/ComponentOne.stories.js', }, initialArgs: { foo: 'a' }, @@ -2058,7 +2137,7 @@ describe('PreviewWeb', () => { id: 'component-one--a', parameters: { __isArgsStory: false, - docs: { container: expect.any(Function) }, + docs: expect.any(Object), fileName: './src/ComponentOne.stories.js', }, globals: { a: 'b' }, @@ -2262,7 +2341,7 @@ describe('PreviewWeb', () => { id: 'component-one--a', parameters: { __isArgsStory: false, - docs: { container: expect.any(Function) }, + docs: expect.any(Object), fileName: './src/ComponentOne.stories.js', }, initialArgs: { foo: 'edited' }, @@ -2299,7 +2378,7 @@ describe('PreviewWeb', () => { id: 'component-one--a', parameters: { __isArgsStory: false, - docs: { container: expect.any(Function) }, + docs: expect.any(Object), fileName: './src/ComponentOne.stories.js', }, initialArgs: { foo: 'edited' }, @@ -2325,7 +2404,7 @@ describe('PreviewWeb', () => { id: 'component-one--a', parameters: { __isArgsStory: false, - docs: { container: expect.any(Function) }, + docs: expect.any(Object), fileName: './src/ComponentOne.stories.js', }, globals: { a: 'b' }, @@ -2941,6 +3020,7 @@ describe('PreviewWeb', () => { "__isArgsStory": false, "docs": Object { "container": [MockFunction], + "renderer": [Function], }, "fileName": "./src/ComponentOne.stories.js", }, @@ -2972,6 +3052,7 @@ describe('PreviewWeb', () => { "__isArgsStory": false, "docs": Object { "container": [MockFunction], + "renderer": [Function], }, "fileName": "./src/ComponentOne.stories.js", }, @@ -3001,6 +3082,9 @@ describe('PreviewWeb', () => { "name": "C", "parameters": Object { "__isArgsStory": false, + "docs": Object { + "renderer": [Function], + }, "fileName": "./src/ComponentTwo.stories.js", }, "playFunction": undefined, diff --git a/lib/preview-web/src/PreviewWeb.tsx b/lib/preview-web/src/PreviewWeb.tsx index 417730034bd3..131fbde32be8 100644 --- a/lib/preview-web/src/PreviewWeb.tsx +++ b/lib/preview-web/src/PreviewWeb.tsx @@ -39,7 +39,7 @@ export class PreviewWeb extends Preview; + currentRender: StoryRender | DocsRender; constructor() { super(); @@ -210,7 +210,7 @@ export class PreviewWeb extends Preview this.storyStore.loadStory({ storyId: id }))); + await Promise.all(ids.map((id) => this.storyStore.loadEntry(id))); } // RENDERING @@ -227,12 +227,23 @@ export class PreviewWeb extends Preview extends Preview( - this.channel, - this.storyStore, - (...args) => { - // At the start of renderToDOM we make the story visible (see note in WebView) - this.view.showStoryDuringRender(); - return this.renderToDOM(...args); - }, - this.mainStoryCallbacks(storyId), - storyId, - 'story' - ); + let render; + if (viewMode === 'story') { + render = new StoryRender( + this.channel, + this.storyStore, + (...args) => { + // At the start of renderToDOM we make the story visible (see note in WebView) + this.view.showStoryDuringRender(); + return this.renderToDOM(...args); + }, + this.mainStoryCallbacks(storyId), + storyId, + 'story' + ); + } else { + render = new DocsRender(this.channel, this.storyStore, entry); + } + // We need to store this right away, so if the story changes during // the async `.prepare()` below, we can (potentially) cancel it this.currentSelection = selection; - // Note this may be replaced by a docsRender after preparing - this.currentRender = storyRender; + this.currentRender = render; try { - await storyRender.prepare(); + await render.prepare(); } catch (err) { if (err !== PREPARE_ABORTED) { // We are about to render an error so make sure the previous story is @@ -281,11 +297,10 @@ export class PreviewWeb extends Preview extends Preview(storyRender); + if (viewMode === 'docs') { this.currentRender.renderToElement( this.view.prepareForDocs(), this.renderStoryToElement.bind(this) ); } else { - this.storyRenders.push(storyRender); - this.currentRender.renderToElement(this.view.prepareForStory(storyRender.story)); + this.storyRenders.push(render as StoryRender); + (this.currentRender as StoryRender).renderToElement( + this.view.prepareForStory(render.story) + ); } } diff --git a/lib/preview-web/src/StoryRender.ts b/lib/preview-web/src/StoryRender.ts index d0ede7cc1223..1f8a7b111f80 100644 --- a/lib/preview-web/src/StoryRender.ts +++ b/lib/preview-web/src/StoryRender.ts @@ -40,7 +40,9 @@ export type RenderContextCallbacks = Pick< export const PREPARE_ABORTED = new Error('prepareAborted'); +export type RenderType = 'story' | 'docs'; export interface Render { + type: RenderType; id: StoryId; story?: Story; isPreparing: () => boolean; @@ -50,6 +52,8 @@ export interface Render { } export class StoryRender implements Render { + public type: RenderType = 'story'; + public story?: Story; public phase?: RenderPhase; @@ -121,10 +125,6 @@ export class StoryRender implements Render implements Render implements Render; await this.runPhase(abortSignal, 'loading', async () => { loadedContext = await applyLoaders({ - ...this.context(), + ...this.storyContext(), viewMode: this.viewMode, } as StoryContextForLoaders); }); @@ -172,7 +176,7 @@ export class StoryRender implements Render( - story: Story, - docsContext: DocsContextProps, - element: HTMLElement, - callback: () => void -) { - return renderDocsAsync(story, docsContext, element).then(callback); -} - -async function renderDocsAsync( - story: Story, - docsContext: DocsContextProps, - element: HTMLElement -) { - const { docs } = story.parameters; - if ((docs?.getPage || docs?.page) && !(docs?.getContainer || docs?.container)) { - throw new Error('No `docs.container` set, did you run `addon-docs/preset`?'); - } - - const DocsContainer: ComponentType<{ context: DocsContextProps }> = - docs.container || - (await docs.getContainer?.()) || - (({ children }: { children: Element }) => <>{children}); - - const Page: ComponentType = docs.page || (await docs.getPage?.()) || NoDocs; - - // Use `componentId` as a key so that we force a re-render every time - // we switch components - const docsElement = ( - - - - ); - - await new Promise((resolve) => { - ReactDOM.render(docsElement, element, resolve); - }); -} - -export function unmountDocs(element: HTMLElement) { - ReactDOM.unmountComponentAtNode(element); -} diff --git a/lib/preview-web/src/types.ts b/lib/preview-web/src/types.ts index c068a57a8150..4fd7e83eac81 100644 --- a/lib/preview-web/src/types.ts +++ b/lib/preview-web/src/types.ts @@ -4,21 +4,26 @@ import type { AnyFramework, StoryContextForLoaders, ComponentTitle, - Args, - Globals, + Parameters, } from '@storybook/csf'; import type { Story } from '@storybook/store'; import { PreviewWeb } from './PreviewWeb'; export interface DocsContextProps { + legacy: boolean; + id: StoryId; title: ComponentTitle; name: StoryName; + + storyIdByModuleExport: (moduleExport: any) => StoryId; storyById: (id: StoryId) => Story; + getStoryContext: (story: Story) => StoryContextForLoaders; + componentStories: () => Story[]; + loadStory: (id: StoryId) => Promise>; renderStoryToElement: PreviewWeb['renderStoryToElement']; - getStoryContext: (story: Story) => StoryContextForLoaders; /** * mdxStoryNameToKey is an MDX-compiler-generated mapping of an MDX story's @@ -27,16 +32,11 @@ export interface DocsContextProps; mdxComponentAnnotations?: any; - - // These keys are deprecated and will be removed in v7 - /** @deprecated */ - kind?: ComponentTitle; - /** @deprecated */ - story?: StoryName; - /** @deprecated */ - args?: Args; - /** @deprecated */ - globals?: Globals; - /** @deprecated */ - parameters?: Globals; } + +export type DocsRenderFunction = ( + docsContext: DocsContextProps, + docsParameters: Parameters, + element: HTMLElement, + callback: () => void +) => void; diff --git a/lib/store/src/StoryIndexStore.test.ts b/lib/store/src/StoryIndexStore.test.ts index 601e08dde6fb..6aaa1b0e218a 100644 --- a/lib/store/src/StoryIndexStore.test.ts +++ b/lib/store/src/StoryIndexStore.test.ts @@ -7,16 +7,19 @@ const storyIndex: StoryIndex = { v: 4, entries: { 'component-one--a': { + id: 'component-one--a', title: 'Component One', name: 'A', importPath: './src/ComponentOne.stories.js', }, 'component-one--b': { + id: 'component-one--b', title: 'Component One', name: 'B', importPath: './src/ComponentOne.stories.js', }, 'component-two--c': { + id: 'component-one--c', title: 'Component Two', name: 'C', importPath: './src/ComponentTwo.stories.js', @@ -118,35 +121,41 @@ describe('StoryIndexStore', () => { expect(store.storyIdFromSpecifier('a--3')).toEqual('a--3'); }); }); - }); - describe('storyIdToEntry', () => { - it('works when the story exists', async () => { - const store = new StoryIndexStore(storyIndex); + describe('storyIdToEntry', () => { + it('works when the story exists', async () => { + const store = new StoryIndexStore(storyIndex); - expect(store.storyIdToEntry('component-one--a')).toEqual({ - name: 'A', - title: 'Component One', - importPath: './src/ComponentOne.stories.js', + expect(store.storyIdToEntry('component-one--a')).toEqual( + storyIndex.entries['component-one--a'] + ); + expect(store.storyIdToEntry('component-one--b')).toEqual( + storyIndex.entries['component-one--b'] + ); + expect(store.storyIdToEntry('component-two--c')).toEqual( + storyIndex.entries['component-two--c'] + ); }); - expect(store.storyIdToEntry('component-one--b')).toEqual({ - name: 'B', - title: 'Component One', - importPath: './src/ComponentOne.stories.js', - }); + it('throws when the story does not', async () => { + const store = new StoryIndexStore(storyIndex); - expect(store.storyIdToEntry('component-two--c')).toEqual({ - name: 'C', - title: 'Component Two', - importPath: './src/ComponentTwo.stories.js', + expect(() => store.storyIdToEntry('random')).toThrow( + /Couldn't find story matching 'random'/ + ); }); }); + }); - it('throws when the story does not', async () => { + describe('importPathToEntry', () => { + it('works', () => { const store = new StoryIndexStore(storyIndex); - - expect(() => store.storyIdToEntry('random')).toThrow(/Couldn't find story matching 'random'/); + expect(store.importPathToEntry('./src/ComponentOne.stories.js')).toEqual( + storyIndex.entries['component-one--a'] + ); + expect(store.importPathToEntry('./src/ComponentTwo.stories.js')).toEqual( + storyIndex.entries['component-two--c'] + ); }); }); }); diff --git a/lib/store/src/StoryIndexStore.ts b/lib/store/src/StoryIndexStore.ts index 0ee9b338f460..98292c2eca2b 100644 --- a/lib/store/src/StoryIndexStore.ts +++ b/lib/store/src/StoryIndexStore.ts @@ -1,8 +1,16 @@ import dedent from 'ts-dedent'; import { Channel } from '@storybook/addons'; import type { StoryId } from '@storybook/csf'; +import memoize from 'memoizerific'; -import type { StorySpecifier, StoryIndex, IndexEntry } from './types'; +import type { StorySpecifier, StoryIndex, IndexEntry, Path } from './types'; + +const getImportPathMap = memoize(1)((entries: StoryIndex['entries']) => + Object.values(entries).reduce((acc, entry) => { + acc[entry.importPath] = acc[entry.importPath] || entry; + return acc; + }, {} as Record) +); export class StoryIndexStore { channel: Channel; @@ -50,4 +58,8 @@ export class StoryIndexStore { return storyEntry; } + + importPathToEntry(importPath: Path): IndexEntry { + return getImportPathMap(this.entries)[importPath]; + } } diff --git a/lib/store/src/StoryStore.test.ts b/lib/store/src/StoryStore.test.ts index 2f066e976379..6c08fbdda6c3 100644 --- a/lib/store/src/StoryStore.test.ts +++ b/lib/store/src/StoryStore.test.ts @@ -46,18 +46,21 @@ const storyIndex: StoryIndex = { v: 4, entries: { 'component-one--a': { + type: 'story', id: 'component-one--a', title: 'Component One', name: 'A', importPath: './src/ComponentOne.stories.js', }, 'component-one--b': { + type: 'story', id: 'component-one--b', title: 'Component One', name: 'B', importPath: './src/ComponentOne.stories.js', }, 'component-two--c': { + type: 'story', id: 'component-two--c', title: 'Component Two', name: 'C', @@ -156,6 +159,8 @@ describe('StoryStore', () => { }); }); + describe('loadDocsFileById', () => {}); + describe('setProjectAnnotations', () => { it('busts the loadStory cache', async () => { const store = new StoryStore(); @@ -233,6 +238,7 @@ describe('StoryStore', () => { entries: { ...storyIndex.entries, 'new-component--story': { + type: 'story', id: 'new-component--story', title: 'New Component', name: 'Story', @@ -264,6 +270,7 @@ describe('StoryStore', () => { v: 4, entries: { 'component-one--a': { + type: 'story', id: 'component-one--a', title: 'Component One', name: 'A', @@ -294,6 +301,7 @@ describe('StoryStore', () => { v: 4, entries: { 'component-one--a': { + type: 'story', id: 'component-one--a', title: 'Component One', name: 'A', @@ -581,7 +589,7 @@ describe('StoryStore', () => { `); }); - it('does not include docs only stories by default', async () => { + it('does not include (legacy) docs only stories by default', async () => { const docsOnlyImportFn = jest.fn(async (path) => { return path === './src/ComponentOne.stories.js' ? { @@ -607,6 +615,43 @@ describe('StoryStore', () => { 'component-two--c', ]); }); + + it('does not include (modern) docs entries ever', async () => { + const docsOnlyStoryIndex: StoryIndex = { + v: 4, + entries: { + ...storyIndex.entries, + 'introduction--docs': { + type: 'docs', + id: 'introduction--docs', + title: 'Introduction', + name: 'Docs', + importPath: './introduction.docs.mdx', + storiesImports: [], + }, + }, + }; + const store = new StoryStore(); + store.setProjectAnnotations(projectAnnotations); + store.initialize({ + storyIndex: docsOnlyStoryIndex, + importFn, + cache: false, + }); + await store.cacheAllCSFFiles(); + + expect(Object.keys(store.extract())).toEqual([ + 'component-one--a', + 'component-one--b', + 'component-two--c', + ]); + + expect(Object.keys(store.extract({ includeDocsOnly: true }))).toEqual([ + 'component-one--a', + 'component-one--b', + 'component-two--c', + ]); + }); }); describe('raw', () => { @@ -641,6 +686,11 @@ describe('StoryStore', () => { "foo": "a", }, "kind": "Component One", + "moduleExport": Object { + "args": Object { + "foo": "a", + }, + }, "name": "A", "originalStoryFn": [MockFunction], "parameters": Object { @@ -678,6 +728,11 @@ describe('StoryStore', () => { "foo": "b", }, "kind": "Component One", + "moduleExport": Object { + "args": Object { + "foo": "b", + }, + }, "name": "B", "originalStoryFn": [MockFunction], "parameters": Object { @@ -715,6 +770,11 @@ describe('StoryStore', () => { "foo": "c", }, "kind": "Component Two", + "moduleExport": Object { + "args": Object { + "foo": "c", + }, + }, "name": "C", "originalStoryFn": [MockFunction], "parameters": Object { diff --git a/lib/store/src/StoryStore.ts b/lib/store/src/StoryStore.ts index 0f307a46bff6..1915c7139bcc 100644 --- a/lib/store/src/StoryStore.ts +++ b/lib/store/src/StoryStore.ts @@ -31,6 +31,7 @@ import type { StoryIndexEntry, V2CompatIndexEntry, StoryIndexV3, + ModuleExports, } from './types'; import { HooksContext } from './hooks'; @@ -120,6 +121,12 @@ export class StoryStore { if (this.cachedCSFFiles) await this.cacheAllCSFFiles(); } + // Get an entry from the index, waiting on initialization if necessary + async storyIdToEntry(storyId: StoryId) { + await this.initializationPromise; + return this.storyIndex.storyIdToEntry(storyId); + } + // To load a single CSF file to service a story we need to look up the importPath in the index loadCSFFileByStoryId(storyId: StoryId): PromiseLike> { const { importPath, title } = this.storyIndex.storyIdToEntry(storyId); @@ -197,6 +204,33 @@ export class StoryStore { .map((storyId: StoryId) => this.storyFromCSFFile({ storyId, csfFile })); } + async loadDocsFileById( + docsId: StoryId + ): Promise<{ docsExports: ModuleExports; csfFiles: CSFFile[] }> { + const entry = await this.storyIdToEntry(docsId); + if (entry.type !== 'docs') throw new Error(`Cannot load docs file for id ${docsId}`); + + const { importPath, storiesImports } = entry; + + const [docsExports, ...csfFiles] = (await Promise.all([ + this.importFn(importPath), + ...storiesImports.map((storyImportPath) => { + const firstStoryEntry = this.storyIndex.importPathToEntry(storyImportPath); + return this.loadCSFFileByStoryId(firstStoryEntry.id); + }), + ])) as [ModuleExports, ...CSFFile[]]; + + return { docsExports, csfFiles }; + } + + async loadEntry(id: StoryId) { + const entry = await this.storyIdToEntry(id); + if (entry.type === 'docs' && !entry.legacy) { + return this.loadDocsFileById(id); + } + return this.loadCSFFileByStoryId(id); + } + // A prepared story does not include args, globals or hooks. These are stored in the story store // and updated separtely to the (immutable) story. getStoryContext(story: Story): Omit, 'viewMode'> { @@ -219,28 +253,34 @@ export class StoryStore { throw new Error('Cannot call extract() unless you call cacheAllCSFFiles() first.'); } - return Object.entries(this.storyIndex.entries).reduce((acc, [storyId, { importPath }]) => { - const csfFile = this.cachedCSFFiles[importPath]; - const story = this.storyFromCSFFile({ storyId, csfFile }); - - if (!options.includeDocsOnly && story.parameters.docsOnly) { + return Object.entries(this.storyIndex.entries).reduce( + (acc, [storyId, { type, importPath }]) => { + if (type === 'docs') return acc; + + const csfFile = this.cachedCSFFiles[importPath]; + const story = this.storyFromCSFFile({ storyId, csfFile }); + + if (!options.includeDocsOnly && story.parameters.docsOnly) { + return acc; + } + + acc[storyId] = Object.entries(story).reduce( + (storyAcc, [key, value]) => { + if (key === 'moduleExport') return storyAcc; + if (typeof value === 'function') { + return storyAcc; + } + if (Array.isArray(value)) { + return Object.assign(storyAcc, { [key]: value.slice().sort() }); + } + return Object.assign(storyAcc, { [key]: value }); + }, + { args: story.initialArgs } + ); return acc; - } - - acc[storyId] = Object.entries(story).reduce( - (storyAcc, [key, value]) => { - if (typeof value === 'function') { - return storyAcc; - } - if (Array.isArray(value)) { - return Object.assign(storyAcc, { [key]: value.slice().sort() }); - } - return Object.assign(storyAcc, { [key]: value }); - }, - { args: story.initialArgs } - ); - return acc; - }, {} as Record); + }, + {} as Record + ); } getSetStoriesPayload() { diff --git a/lib/store/src/csf/normalizeStory.test.ts b/lib/store/src/csf/normalizeStory.test.ts index 6da2b1458102..3492178f87ff 100644 --- a/lib/store/src/csf/normalizeStory.test.ts +++ b/lib/store/src/csf/normalizeStory.test.ts @@ -50,6 +50,7 @@ describe('normalizeStory', () => { "decorators": Array [], "id": "title--story-export", "loaders": Array [], + "moduleExport": [Function], "name": "Story Export", "parameters": Object {}, "userStoryFn": [Function], @@ -117,10 +118,12 @@ describe('normalizeStory', () => { "decorators": Array [], "id": "title--story-export", "loaders": Array [], + "moduleExport": Object {}, "name": "Story Export", "parameters": Object {}, } `); + expect(normalized.moduleExport).toBe(storyObj); }); it('full annotations', () => { @@ -133,7 +136,7 @@ describe('normalizeStory', () => { argTypes: { storyArgType: { type: 'string' } }, }; const meta = { title: 'title' }; - const normalized = normalizeStory('storyExport', storyObj, meta); + const { moduleExport, ...normalized } = normalizeStory('storyExport', storyObj, meta); expect(normalized).toMatchInlineSnapshot(` Object { "argTypes": Object { @@ -160,6 +163,7 @@ describe('normalizeStory', () => { }, } `); + expect(moduleExport).toBe(storyObj); }); it('prefers new annotations to legacy, but combines', () => { @@ -179,7 +183,7 @@ describe('normalizeStory', () => { }, }; const meta = { title: 'title' }; - const normalized = normalizeStory('storyExport', storyObj, meta); + const { moduleExport, ...normalized } = normalizeStory('storyExport', storyObj, meta); expect(normalized).toMatchInlineSnapshot(` Object { "argTypes": Object { @@ -216,6 +220,7 @@ describe('normalizeStory', () => { }, } `); + expect(moduleExport).toBe(storyObj); }); }); }); diff --git a/lib/store/src/csf/normalizeStory.ts b/lib/store/src/csf/normalizeStory.ts index 37773d9094f9..a1635ab67daf 100644 --- a/lib/store/src/csf/normalizeStory.ts +++ b/lib/store/src/csf/normalizeStory.ts @@ -58,6 +58,7 @@ export function normalizeStory( // eslint-disable-next-line no-underscore-dangle const id = parameters.__id || toId(meta.id || meta.title, exportName); return { + moduleExport: storyAnnotations, id, name, decorators, diff --git a/lib/store/src/csf/prepareStory.test.ts b/lib/store/src/csf/prepareStory.test.ts index 3f4a035f9322..a5005d87a339 100644 --- a/lib/store/src/csf/prepareStory.test.ts +++ b/lib/store/src/csf/prepareStory.test.ts @@ -21,6 +21,7 @@ const id = 'id'; const name = 'name'; const title = 'title'; const render = (args: any) => {}; +const moduleExport = {}; const stringType: SBScalarType = { name: 'string' }; const numberType: SBScalarType = { name: 'number' }; @@ -48,7 +49,7 @@ describe('prepareStory', () => { describe('parameters', () => { it('are combined in the right order', () => { const { parameters } = prepareStory( - { id, name, parameters: { a: 'story', nested: { z: 'story' } } }, + { id, name, parameters: { a: 'story', nested: { z: 'story' } }, moduleExport }, { id, title, @@ -79,14 +80,14 @@ describe('prepareStory', () => { }); it('sets a value even if metas do not have parameters', () => { - const { parameters } = prepareStory({ id, name }, { id, title }, { render }); + const { parameters } = prepareStory({ id, name, moduleExport }, { id, title }, { render }); expect(parameters).toEqual({ __isArgsStory: true }); }); it('does not set `__isArgsStory` if `passArgsFirst` is disabled', () => { const { parameters } = prepareStory( - { id, name, parameters: { passArgsFirst: false } }, + { id, name, parameters: { passArgsFirst: false }, moduleExport }, { id, title }, { render } ); @@ -95,7 +96,11 @@ describe('prepareStory', () => { }); it('does not set `__isArgsStory` if `render` does not take args', () => { - const { parameters } = prepareStory({ id, name }, { id, title }, { render: () => {} }); + const { parameters } = prepareStory( + { id, name, moduleExport }, + { id, title }, + { render: () => {} } + ); expect(parameters).toEqual({ __isArgsStory: false }); }); @@ -104,7 +109,7 @@ describe('prepareStory', () => { describe('args/initialArgs', () => { it('are combined in the right order', () => { const { initialArgs } = prepareStory( - { id, name, args: { a: 'story', nested: { z: 'story' } } }, + { id, name, args: { a: 'story', nested: { z: 'story' } }, moduleExport }, { id, title, @@ -135,7 +140,7 @@ describe('prepareStory', () => { it('can be overriden by `undefined`', () => { const { initialArgs } = prepareStory( - { id, name, args: { a: undefined } }, + { id, name, args: { a: undefined }, moduleExport }, { id, title, args: { a: 'component' } }, { render } ); @@ -143,7 +148,7 @@ describe('prepareStory', () => { }); it('sets a value even if metas do not have args', () => { - const { initialArgs } = prepareStory({ id, name }, { id, title }, { render }); + const { initialArgs } = prepareStory({ id, name, moduleExport }, { id, title }, { render }); expect(initialArgs).toEqual({}); }); @@ -170,6 +175,7 @@ describe('prepareStory', () => { arg5: { name: 'arg5', type: stringType }, arg6: { name: 'arg6', type: numberType, defaultValue: 0 }, // See https://github.com/storybookjs/storybook/issues/12767 } }, + moduleExport, }, { id, title }, { render: () => {} } @@ -198,7 +204,7 @@ describe('prepareStory', () => { }; prepareStory( - { id, name }, + { id, name, moduleExport }, { id, title }, { render, argsEnhancers: [enhancerOne, enhancerTwo] } ); @@ -210,7 +216,7 @@ describe('prepareStory', () => { const enhancer = jest.fn(() => ({ c: 'd' })); const { initialArgs } = prepareStory( - { id, name, args: { a: 'b' } }, + { id, name, args: { a: 'b' }, moduleExport }, { id, title }, { render, argsEnhancers: [enhancer] } ); @@ -230,7 +236,7 @@ describe('prepareStory', () => { const enhancerThree = jest.fn(() => ({ c: 'C' })); const { initialArgs } = prepareStory( - { id, name, args: { a: 'A' } }, + { id, name, args: { a: 'A' }, moduleExport }, { id, title }, { render, argsEnhancers: [enhancerOne, enhancerTwo, enhancerThree] } ); @@ -263,6 +269,7 @@ describe('prepareStory', () => { a: { name: 'a-story', type: booleanType }, nested: { name: 'nested', type: booleanType, a: 'story' }, }, + moduleExport, }, { id, @@ -295,7 +302,7 @@ describe('prepareStory', () => { it('allows you to alter argTypes when stories are added', () => { const enhancer = jest.fn((context) => ({ ...context.argTypes, c: { name: 'd' } })); const { argTypes } = prepareStory( - { id, name, argTypes: { a: { name: 'b' } } }, + { id, name, argTypes: { a: { name: 'b' } }, moduleExport }, { id, title }, { render, argTypesEnhancers: [enhancer] } ); @@ -309,7 +316,7 @@ describe('prepareStory', () => { it('does not merge argType enhancer results', () => { const enhancer = jest.fn(() => ({ c: { name: 'd' } })); const { argTypes } = prepareStory( - { id, name, argTypes: { a: { name: 'b' } } }, + { id, name, argTypes: { a: { name: 'b' } }, moduleExport }, { id, title }, { render, argTypesEnhancers: [enhancer] } ); @@ -324,7 +331,7 @@ describe('prepareStory', () => { const firstEnhancer = jest.fn((context) => ({ ...context.argTypes, c: { name: 'd' } })); const secondEnhancer = jest.fn((context) => ({ ...context.argTypes, e: { name: 'f' } })); const { argTypes } = prepareStory( - { id, name, argTypes: { a: { name: 'b' } } }, + { id, name, argTypes: { a: { name: 'b' } }, moduleExport }, { id, title }, { render, argTypesEnhancers: [firstEnhancer, secondEnhancer] } ); @@ -344,7 +351,7 @@ describe('prepareStory', () => { it('awaits the result of a loader', async () => { const loader = jest.fn(async () => new Promise((r) => setTimeout(() => r({ foo: 7 }), 100))); const { applyLoaders } = prepareStory( - { id, name, loaders: [loader] }, + { id, name, loaders: [loader], moduleExport }, { id, title }, { render } ); @@ -365,7 +372,7 @@ describe('prepareStory', () => { const storyLoader = async () => ({ foo: 5 }); const { applyLoaders } = prepareStory( - { id, name, loaders: [storyLoader] }, + { id, name, loaders: [storyLoader], moduleExport }, { id, title, loaders: [componentLoader] }, { render, loaders: [globalLoader] } ); @@ -385,7 +392,11 @@ describe('prepareStory', () => { async () => new Promise((r) => setTimeout(() => r({ foo: 3 }), 50)), ]; - const { applyLoaders } = prepareStory({ id, name, loaders }, { id, title }, { render }); + const { applyLoaders } = prepareStory( + { id, name, loaders, moduleExport }, + { id, title }, + { render } + ); const storyContext = { context: 'value' } as any; const loadedContext = await applyLoaders(storyContext); @@ -409,6 +420,7 @@ describe('prepareStory', () => { two: { name: 'two', type: { name: 'string' }, mapping: { 1: 'no match' } }, }, args: { one: 1, two: 2, three: 3 }, + moduleExport, }, { id, title }, { render: renderMock } @@ -425,7 +437,7 @@ describe('prepareStory', () => { it('passes args as the first argument to the story if `parameters.passArgsFirst` is true', () => { const renderMock = jest.fn(); const firstStory = prepareStory( - { id, name, args: { a: 1 }, parameters: { passArgsFirst: true } }, + { id, name, args: { a: 1 }, parameters: { passArgsFirst: true }, moduleExport }, { id, title }, { render: renderMock } ); @@ -437,7 +449,7 @@ describe('prepareStory', () => { ); const secondStory = prepareStory( - { id, name, args: { a: 1 }, parameters: { passArgsFirst: false } }, + { id, name, args: { a: 1 }, parameters: { passArgsFirst: false }, moduleExport }, { id, title }, { render: renderMock } ); @@ -458,6 +470,7 @@ describe('prepareStory', () => { id, name, decorators: [storyDecorator], + moduleExport, }, { id, title, decorators: [componentDecorator] }, { render: renderMock, decorators: [globalDecorator] } @@ -488,6 +501,7 @@ describe('prepareStory', () => { name, args: { a: 1, b: 2 }, argTypes: { b: { name: 'b', target: 'foo' } }, + moduleExport, }, { id, title }, { render: renderMock } @@ -512,6 +526,7 @@ describe('prepareStory', () => { name, args: { a: 1, b: 2 }, argTypes: { b: { name: 'b', if: { arg: 'a', truthy: false } } }, + moduleExport, }, { id, title }, { render: renderMock } @@ -536,6 +551,7 @@ describe('prepareStory', () => { name, args: { a: 1, b: 2 }, argTypes: { b: { name: 'b', target: 'foo' } }, + moduleExport, }, { id, title }, { render: renderMock } @@ -560,6 +576,7 @@ describe('prepareStory', () => { name, args: { b: 2 }, argTypes: { b: { name: 'b', target: 'foo' } }, + moduleExport, }, { id, title }, { render: renderMock } @@ -582,6 +599,7 @@ describe('prepareStory', () => { { id, name, + moduleExport, }, { id, title }, { render: renderMock } @@ -604,10 +622,22 @@ describe('playFunction', () => { await new Promise((r) => setTimeout(r, 0)); // Ensure this puts an async boundary in inner(); }); - const { playFunction } = prepareStory({ id, name, play }, { id, title }, { render }); + const { playFunction } = prepareStory( + { id, name, play, moduleExport }, + { id, title }, + { render } + ); await playFunction({} as StoryContext); expect(play).toHaveBeenCalled(); expect(inner).toHaveBeenCalled(); }); }); + +describe('moduleExport', () => { + it('are carried through from the story annotations', () => { + const storyObj = {}; + const story = prepareStory({ id, name, moduleExport: storyObj }, { id, title }, { render }); + expect(story.moduleExport).toBe(storyObj); + }); +}); diff --git a/lib/store/src/csf/prepareStory.ts b/lib/store/src/csf/prepareStory.ts index b797320add0c..6e82d761441a 100644 --- a/lib/store/src/csf/prepareStory.ts +++ b/lib/store/src/csf/prepareStory.ts @@ -48,7 +48,7 @@ export function prepareStory( // anything at render time. The assumption is that as we don't load all the stories at once, this // will have a limited cost. If this proves misguided, we can refactor it. - const { id, name } = storyAnnotations; + const { moduleExport, id, name } = storyAnnotations; const { title } = componentAnnotations; const parameters: Parameters = combineParameters( @@ -198,6 +198,7 @@ export function prepareStory( return Object.freeze({ ...contextForEnhancers, + moduleExport, originalStoryFn: render, undecoratedStoryFn, unboundStoryFn, diff --git a/lib/store/src/types.ts b/lib/store/src/types.ts index f11a587c2216..e8afb7db1757 100644 --- a/lib/store/src/types.ts +++ b/lib/store/src/types.ts @@ -25,7 +25,8 @@ import type { export type { StoryId, Parameters }; export type Path = string; -export type ModuleExports = Record; +export type ModuleExport = any; +export type ModuleExports = Record; export type PromiseLike = Promise | SynchronousPromise; export type ModuleImportFn = (path: Path) => PromiseLike; @@ -51,6 +52,7 @@ export type NormalizedStoryAnnotations, 'storyName' | 'story' > & { + moduleExport: ModuleExport; // You cannot actually set id on story annotations, but we normalize it to be there for convience id: StoryId; argTypes?: StrictArgTypes; @@ -64,6 +66,7 @@ export type CSFFile = { export type Story = StoryContextForEnhancers & { + moduleExport: ModuleExport; originalStoryFn: StoryFn; undecoratedStoryFn: LegacyStoryFn; unboundStoryFn: LegacyStoryFn; @@ -101,6 +104,7 @@ export type StoryIndexEntry = BaseIndexEntry & { export type DocsIndexEntry = BaseIndexEntry & { storiesImports: Path[]; type: 'docs'; + legacy?: boolean; }; export type IndexEntry = StoryIndexEntry | DocsIndexEntry; diff --git a/scripts/jest.init.ts b/scripts/jest.init.ts index d7e36661cb69..d48d1a7bcb1a 100644 --- a/scripts/jest.init.ts +++ b/scripts/jest.init.ts @@ -44,6 +44,8 @@ const ignoreList = [ (error: any) => error.message.includes('react-async-component-lifecycle-hooks') && error.stack.includes('addons/knobs/src/components/__tests__/Options.js'), + // Storyshots blows up if your project includes a .docs.mdx file (react-ts does). + (error: any) => error.message.match(/Unexpected error while loading .*\.docs\.mdx/), ]; const throwMessage = (type: any, message: any) => { diff --git a/yarn.lock b/yarn.lock index 8c1cadb92efe..96fed7eac1f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8631,14 +8631,11 @@ __metadata: ts-dedent: ^2.0.0 unfetch: ^4.2.0 util-deprecate: ^1.0.2 - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 languageName: unknown linkType: soft "@storybook/preview-web@npm:6.5.0-beta.1": - version: 6.5.0-beta.1 + version: 0.0.0-use.local resolution: "@storybook/preview-web@npm:6.5.0-beta.1" dependencies: "@storybook/addons": 6.5.0-beta.1 @@ -8657,12 +8654,9 @@ __metadata: ts-dedent: ^2.0.0 unfetch: ^4.2.0 util-deprecate: ^1.0.2 - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 checksum: 0ea1e2d1b295b1f07c462b497dc878ac4f61c5eb810f16a0b6e923869f7b4e591af783df7edacd555178f1b4a41eebe789c3e259973299a81f0dda699c6e3581 - languageName: node - linkType: hard + languageName: unknown + linkType: soft "@storybook/react-docgen-typescript-plugin@npm:1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0": version: 1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0