diff --git a/.circleci/config.yml b/.circleci/config.yml index 41926b33775f..faefe40405e5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,11 +3,11 @@ version: 2.1 executors: sb_node: parameters: - class: - description: The Resource class - type: enum - enum: ["small", "medium", "large", "xlarge"] - default: "medium" + class: + description: The Resource class + type: enum + enum: ['small', 'medium', 'large', 'xlarge'] + default: 'medium' working_directory: /tmp/storybook docker: - image: circleci/node:10-browsers @@ -360,7 +360,6 @@ jobs: name: Upload coverage command: yarn coverage - workflows: test: jobs: diff --git a/MIGRATION.md b/MIGRATION.md index f9e4a8c3adc7..d4c1cc4bce53 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2,6 +2,7 @@ - [From version 6.0.x to 6.1.0](#from-version-60x-to-610) - [6.1 deprecations](#61-deprecations) + - [Deprecated storyFn](#deprecated-storyfn) - [Deprecated onBeforeRender](#deprecated-onbeforerender) - [Deprecated grid parameter](#deprecated-grid-parameter) - [Deprecated package-composition disabled parameter](#deprecated-package-composition-disabled-parameter) @@ -120,8 +121,8 @@ - [Addon story parameters](#addon-story-parameters) - [From version 3.3.x to 3.4.x](#from-version-33x-to-34x) - [From version 3.2.x to 3.3.x](#from-version-32x-to-33x) - - [`babel-core` is now a peer dependency (#2494)](#babel-core-is-now-a-peer-dependency-2494) - - [Base webpack config now contains vital plugins (#1775)](#base-webpack-config-now-contains-vital-plugins-1775) + - [`babel-core` is now a peer dependency #2494](#babel-core-is-now-a-peer-dependency-2494) + - [Base webpack config now contains vital plugins #1775](#base-webpack-config-now-contains-vital-plugins-1775) - [Refactored Knobs](#refactored-knobs) - [From version 3.1.x to 3.2.x](#from-version-31x-to-32x) - [Moved TypeScript addons definitions](#moved-typescript-addons-definitions) @@ -138,6 +139,27 @@ ### 6.1 deprecations +#### Deprecated storyFn + +Each item in the story store contains a field called `storyFn`, which is a fully decorated story that's applied to the denormalized story parameters. Starting in 6.0 we've stopped using this API internally, and have replaced it with a new field called `unboundStoryFn` which, unlike `storyFn`, must passed a story context, typically produced by `applyLoaders`; + +Before: + +```js +const { storyFn } = store.fromId('some--id'); +console.log(storyFn()); +``` + +After: + +```js +const { unboundStoryFn, applyLoaders } = store.fromId('some--id'); +const context = await applyLoaders(); +console.log(unboundStoryFn(context)); +``` + +If you're not using loaders, `storyFn` will work as before. If you are, you'll need to use the new approach. + #### Deprecated onBeforeRender The `@storybook/addon-docs` previously accepted a `jsx` option called `onBeforeRender`, which was unfortunately named as it was called after the render. @@ -1717,7 +1739,7 @@ There are no expected breaking changes in the 3.4.x release, but 3.4 contains a It wasn't expected that there would be any breaking changes in this release, but unfortunately it turned out that there are some. We're revisiting our [release strategy](https://github.com/storybookjs/storybook/blob/master/RELEASES.md) to follow semver more strictly. Also read on if you're using `addon-knobs`: we advise an update to your code for efficiency's sake. -### `babel-core` is now a peer dependency ([#2494](https://github.com/storybookjs/storybook/pull/2494)) +### `babel-core` is now a peer dependency #2494 This affects you if you don't use babel in your project. You may need to add `babel-core` as dev dependency: @@ -1727,7 +1749,7 @@ yarn add babel-core --dev This was done to support different major versions of babel. -### Base webpack config now contains vital plugins ([#1775](https://github.com/storybookjs/storybook/pull/1775)) +### Base webpack config now contains vital plugins #1775 This affects you if you use custom webpack config in [Full Control Mode](https://storybook.js.org/docs/react/configure/webpack#full-control-mode) while not preserving the plugins from `storybookBaseConfig`. Before `3.3`, preserving them was a recommendation, but now it [became](https://github.com/storybookjs/storybook/pull/2578) a requirement. diff --git a/addons/docs/src/mdx/__testfixtures__/loaders.mdx b/addons/docs/src/mdx/__testfixtures__/loaders.mdx new file mode 100644 index 000000000000..3497a9bf25b6 --- /dev/null +++ b/addons/docs/src/mdx/__testfixtures__/loaders.mdx @@ -0,0 +1,10 @@ +import { Button } from '@storybook/react/demo'; +import { Story, Meta } from '@storybook/addon-docs/blocks'; + + ({ foo: 1 })]} /> + +# Story with loader + + ({ bar: 2 })]}> + + diff --git a/addons/docs/src/mdx/__testfixtures__/loaders.output.snapshot b/addons/docs/src/mdx/__testfixtures__/loaders.output.snapshot new file mode 100644 index 000000000000..4afb60896767 --- /dev/null +++ b/addons/docs/src/mdx/__testfixtures__/loaders.output.snapshot @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`docs-mdx-compiler-plugin loaders.mdx 1`] = ` +"/* @jsx mdx */ +import { assertIsFn, AddContext } from '@storybook/addon-docs/blocks'; + +import { Button } from '@storybook/react/demo'; +import { Story, Meta } from '@storybook/addon-docs/blocks'; + +const makeShortcode = (name) => + function MDXDefaultShortcode(props) { + console.warn( + 'Component ' + + name + + ' was not imported, exported, or provided by MDXProvider as global scope' + ); + return
; + }; + +const layoutProps = {}; +const MDXLayout = 'wrapper'; +function MDXContent({ components, ...props }) { + return ( + + ({ + foo: 1, + }), + ]} + mdxType=\\"Meta\\" + /> +

{\`Story with loader\`}

+ ({ + bar: 2, + }), + ]} + mdxType=\\"Story\\" + > + + +
+ ); +} + +MDXContent.isMDXComponent = true; + +export const one = () => ; +one.storyName = 'one'; +one.parameters = { storySource: { source: '' } }; +one.loaders = [ + async () => ({ + bar: 2, + }), +]; + +const componentMeta = { + title: 'Button', + loaders: [ + async () => ({ + foo: 1, + }), + ], + includeStories: ['one'], +}; + +const mdxStoryNameToKey = { one: 'one' }; + +componentMeta.parameters = componentMeta.parameters || {}; +componentMeta.parameters.docs = { + ...(componentMeta.parameters.docs || {}), + page: () => ( + + + + ), +}; + +export default componentMeta; +" +`; diff --git a/addons/docs/src/mdx/mdx-compiler-plugin.js b/addons/docs/src/mdx/mdx-compiler-plugin.js index c8ac5b77dbb3..58de1a0a5d8e 100644 --- a/addons/docs/src/mdx/mdx-compiler-plugin.js +++ b/addons/docs/src/mdx/mdx-compiler-plugin.js @@ -193,6 +193,13 @@ function genStoryExport(ast, context) { statements.push(`${storyKey}.decorators = ${decos};`); } + let loaders = getAttr(ast.openingElement, 'loaders'); + loaders = loaders && loaders.expression; + if (loaders) { + const { code: loaderCode } = generate(loaders, {}); + statements.push(`${storyKey}.loaders = ${loaderCode};`); + } + // eslint-disable-next-line no-param-reassign context.storyNameToKey[storyName] = storyKey; @@ -242,6 +249,7 @@ function genMeta(ast, options) { id = id && `'${id.value}'`; const parameters = genAttribute('parameters', ast.openingElement); const decorators = genAttribute('decorators', ast.openingElement); + const loaders = genAttribute('loaders', ast.openingElement); const component = genAttribute('component', ast.openingElement); const subcomponents = genAttribute('subcomponents', ast.openingElement); const args = genAttribute('args', ast.openingElement); @@ -252,6 +260,7 @@ function genMeta(ast, options) { id, parameters, decorators, + loaders, component, subcomponents, args, diff --git a/addons/storyshots/storyshots-core/src/api/index.ts b/addons/storyshots/storyshots-core/src/api/index.ts index b40273c8fa7b..601697ff89d4 100644 --- a/addons/storyshots/storyshots-core/src/api/index.ts +++ b/addons/storyshots/storyshots-core/src/api/index.ts @@ -48,39 +48,37 @@ function testStorySnapshots(options: StoryshotsOptions = {}) { stories2snapsConverter, }; - const data = storybook - .raw() - .reduce( - (acc, item) => { - if (storyNameRegex && !item.name.match(storyNameRegex)) { - return acc; - } + const data = storybook.raw().reduce( + (acc, item) => { + if (storyNameRegex && !item.name.match(storyNameRegex)) { + return acc; + } - if (storyKindRegex && !item.kind.match(storyKindRegex)) { - return acc; - } + if (storyKindRegex && !item.kind.match(storyKindRegex)) { + return acc; + } - const { kind, storyFn: render, parameters } = item; - const existing = acc.find((i: any) => i.kind === kind); - const { fileName } = item.parameters; + const { kind, storyFn: render, parameters } = item; + const existing = acc.find((i: any) => i.kind === kind); + const { fileName } = item.parameters; - if (!isDisabled(parameters.storyshots)) { - if (existing) { - existing.children.push({ ...item, render, fileName }); - } else { - acc.push({ - kind, - children: [{ ...item, render, fileName }], - }); - } + if (!isDisabled(parameters.storyshots)) { + if (existing) { + existing.children.push({ ...item, render, fileName }); + } else { + acc.push({ + kind, + children: [{ ...item, render, fileName }], + }); } - return acc; - }, - [] as { - kind: string; - children: any[]; - }[] - ); + } + return acc; + }, + [] as { + kind: string; + children: any[]; + }[] + ); if (data.length) { callTestMethodGlobals(testMethod); diff --git a/app/html/src/client/preview/render.ts b/app/html/src/client/preview/render.ts index bf1ce2782703..28aa86ad927c 100644 --- a/app/html/src/client/preview/render.ts +++ b/app/html/src/client/preview/render.ts @@ -14,7 +14,6 @@ export default function renderMain({ forceRender, }: RenderContext) { const element = storyFn(); - showMain(); if (typeof element === 'string') { rootElement.innerHTML = element; diff --git a/docs/snippets/common/component-story-custom-source.js.mdx b/docs/snippets/common/component-story-custom-source.js.mdx index 107466a8ca41..317c8d5a3d66 100644 --- a/docs/snippets/common/component-story-custom-source.js.mdx +++ b/docs/snippets/common/component-story-custom-source.js.mdx @@ -4,10 +4,10 @@ export const CustomSource = () => Template.bind({}); CustomSource.parameters = { - docs: { - source: { - code: 'Some custom string here' - } + docs: { + source: { + code: 'Some custom string here', }, + }, }; ``` diff --git a/docs/snippets/react/loader-story.js.mdx b/docs/snippets/react/loader-story.js.mdx new file mode 100644 index 000000000000..784d236364d3 --- /dev/null +++ b/docs/snippets/react/loader-story.js.mdx @@ -0,0 +1,14 @@ +```js +// TodoItem.stories.js + +import React from 'react'; +import fetch from 'node-fetch'; +import { TodoItem } from './TodoItem'; + +export const Primary = (args, { loaded: { todo } }) => ; +Primary.loaders = [ + async () => ({ + todo: (await fetch('https://jsonplaceholder.typicode.com/todos/1')).json(), + }), +]; +``` diff --git a/docs/snippets/react/storybook-preview-global-loader.js.mdx b/docs/snippets/react/storybook-preview-global-loader.js.mdx new file mode 100644 index 000000000000..50a293ffac81 --- /dev/null +++ b/docs/snippets/react/storybook-preview-global-loader.js.mdx @@ -0,0 +1,12 @@ +```js +// .storybook/preview.js + +import React from 'react'; +import fetch from 'node-fetch'; + +export const loaders = [ + async () => ({ + currentUser: (await fetch('https://jsonplaceholder.typicode.com/users/1')).json(), + }), +]; +``` diff --git a/docs/toc.js b/docs/toc.js index 65b69f6a926e..edfdef628376 100644 --- a/docs/toc.js +++ b/docs/toc.js @@ -73,6 +73,11 @@ module.exports = { title: 'Decorators', type: 'link', }, + { + pathSegment: 'loaders', + title: 'Loaders', + type: 'link', + }, { pathSegment: 'naming-components-and-hierarchy', title: 'Naming components and hierarchy', diff --git a/docs/writing-stories/loaders.md b/docs/writing-stories/loaders.md new file mode 100644 index 000000000000..84c31a6cfd70 --- /dev/null +++ b/docs/writing-stories/loaders.md @@ -0,0 +1,63 @@ +--- +title: 'Loaders' +--- + +Loaders are asynchronous functions that load data for a story and its [decorators](./decorators.md). A story's loaders run before the story renders, and the loaded data is passed into the story via its render context. + +Loaders can be used to load any asset (e.g. lazy-loaded components), but they are are typically used to fetch remote API data to be used in a story. + +> NOTE: [Args](./args.md) are the recommended way to manage story data, and we're building up an ecosystem of tools and techniques around them. Loaders are an advanced feature ("escape hatch") and we only recommend using them if you have a specific need that can't be fulfilled by other means. + +## Fetching API data + +Stories are isolated component examples that render internal data that's defined as part of the story or alongside the story as [args](./args.md). + +Loaders are useful when you need to load story data externally, e.g. from a remote API. Consider the following example that fetches a todo item for display in a todo list: + + + + + + + +The loaded data is combined into a `loaded` field on the story context, which is the second argument to a story function. In this example we spread the story's args in first, so they take priority over the static data provided by the loader. + +## Global loaders + +We can also set a loader for **all stories** via the `loaders` export of your [`.storybook/preview.js`](../configure/overview.md#configure-story-rendering) file (this is the file where you configure all stories): + + + + + + + +In this example, we load a "current user" that is available as `loaded.currentUser` for all stories. + +## Loader inheritance + +Like parameters, loaders can be defined globally, at the component level and for a single story (as we’ve seen). + +All loaders, defined at all levels that apply to a story, run before the story is rendered. + +- All loaders run in parallel +- All results are the `loaded` field in the story context +- If there are keys that overlap, "later" loaders take precedence (from lowest to highest): + - Global loaders, in the order they are defined + - Component loaders, in the order they are defined + - Story loaders, in the order they are defined + +## Known limitations + +Loaders have the following known limitations: + +- They are not yet compatible with the storyshots addon ([#12703](https://github.com/storybookjs/storybook/issues/12703)). +- They are not yet compatible with inline-rendered stories in Storybook Docs ([#12726](https://github.com/storybookjs/storybook/issues/12726)). diff --git a/examples/html-kitchen-sink/stories/__snapshots__/loaders.stories.storyshot b/examples/html-kitchen-sink/stories/__snapshots__/loaders.stories.storyshot new file mode 100644 index 000000000000..91af9532caf8 --- /dev/null +++ b/examples/html-kitchen-sink/stories/__snapshots__/loaders.stories.storyshot @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Core/Loaders Story 1`] = ` +
+ Loaded Value is undefined +
+`; diff --git a/examples/html-kitchen-sink/stories/loaders.stories.js b/examples/html-kitchen-sink/stories/loaders.stories.js new file mode 100644 index 000000000000..ae8b4616195d --- /dev/null +++ b/examples/html-kitchen-sink/stories/loaders.stories.js @@ -0,0 +1,7 @@ +export default { + title: 'Core/Loaders', + loaders: [async () => new Promise((r) => setTimeout(() => r({ kindValue: 7 }), 3000))], +}; + +export const Story = (args, { loaded }) => + `
Loaded Value is ${JSON.stringify(loaded, null, 2)}
`; diff --git a/examples/official-storybook/preview.js b/examples/official-storybook/preview.js index 4e9859c28e00..4ba622b5dbdc 100644 --- a/examples/official-storybook/preview.js +++ b/examples/official-storybook/preview.js @@ -195,3 +195,5 @@ export const globalTypes = { }, }, }; + +export const loaders = [async () => ({ globalValue: 1 })]; diff --git a/examples/official-storybook/stories/core/loaders.stories.js b/examples/official-storybook/stories/core/loaders.stories.js new file mode 100644 index 000000000000..bea9cab24134 --- /dev/null +++ b/examples/official-storybook/stories/core/loaders.stories.js @@ -0,0 +1,11 @@ +import React from 'react'; + +export default { + title: 'Core/Loaders', + loaders: [async () => new Promise((r) => setTimeout(() => r({ kindValue: 7 }), 3000))], +}; + +export const Story = (args, { loaded }) => ( +
Loaded Value is {JSON.stringify(loaded, null, 2)}
+); +Story.loaders = [async () => ({ storyValue: 3 })]; diff --git a/lib/addons/src/types.ts b/lib/addons/src/types.ts index f773b6e05c8d..2a8e3a33588c 100644 --- a/lib/addons/src/types.ts +++ b/lib/addons/src/types.ts @@ -129,6 +129,7 @@ export interface StoryApi { parameters?: Parameters ) => StoryApi; addDecorator: (decorator: DecoratorFunction) => StoryApi; + addLoader: (decorator: LoaderFunction) => StoryApi; addParameters: (parameters: Parameters) => StoryApi; [k: string]: string | ClientApiReturnFn; } @@ -138,6 +139,8 @@ export type DecoratorFunction = ( c: StoryContext ) => ReturnType>; +export type LoaderFunction = (c: StoryContext) => Promise>; + export type DecorateStoryFunction = ( storyFn: StoryFn, decorators: DecoratorFunction[] diff --git a/lib/cli/versions.json b/lib/cli/versions.json index fe8a83121c83..d458828f7f86 100644 --- a/lib/cli/versions.json +++ b/lib/cli/versions.json @@ -52,4 +52,4 @@ "@storybook/ui": "6.1.0-alpha.22", "@storybook/vue": "6.1.0-alpha.22", "@storybook/web-components": "6.1.0-alpha.22" -} \ No newline at end of file +} diff --git a/lib/client-api/package.json b/lib/client-api/package.json index b43f0782d8de..a9f82c5c8772 100644 --- a/lib/client-api/package.json +++ b/lib/client-api/package.json @@ -43,6 +43,7 @@ "qs": "^6.6.0", "react": "^16.8.3", "react-dom": "^16.8.3", + "regenerator-runtime": "^0.13.3", "stable": "^0.1.8", "store2": "^2.7.1", "ts-dedent": "^1.1.1", diff --git a/lib/client-api/src/client_api.ts b/lib/client-api/src/client_api.ts index 9f3a33e109fa..0c198ee67e34 100644 --- a/lib/client-api/src/client_api.ts +++ b/lib/client-api/src/client_api.ts @@ -2,7 +2,7 @@ import deprecate from 'util-deprecate'; import dedent from 'ts-dedent'; import { logger } from '@storybook/client-logger'; -import { StoryFn, Parameters, DecorateStoryFunction } from '@storybook/addons'; +import { StoryFn, Parameters, LoaderFunction, DecorateStoryFunction } from '@storybook/addons'; import { toId } from '@storybook/csf'; import { @@ -24,7 +24,7 @@ const addDecoratorDeprecationWarning = deprecate( () => {}, `\`addDecorator\` is deprecated, and will be removed in Storybook 7.0. Instead, use \`export const decorators = [];\` in your \`preview.js\`. -Read more at https://github.com/storybookjs/storybook/MIGRATION.md#deprecated-addparameters-and-adddecorator).` +Read more at https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-addparameters-and-adddecorator).` ); export const addDecorator = (decorator: DecoratorFunction, deprecationWarning = true) => { if (!singleton) @@ -39,7 +39,7 @@ const addParametersDeprecationWarning = deprecate( () => {}, `\`addParameters\` is deprecated, and will be removed in Storybook 7.0. Instead, use \`export const parameters = {};\` in your \`preview.js\`. -Read more at https://github.com/storybookjs/storybook/MIGRATION.md#deprecated-addparameters-and-adddecorator).` +Read more at https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-addparameters-and-adddecorator).` ); export const addParameters = (parameters: Parameters, deprecationWarning = true) => { if (!singleton) @@ -50,6 +50,21 @@ export const addParameters = (parameters: Parameters, deprecationWarning = true) singleton.addParameters(parameters); }; +const addLoaderDeprecationWarning = deprecate( + () => {}, + `\`addLoader\` is deprecated, and will be removed in Storybook 7.0. +Instead, use \`export const loaders = [];\` in your \`preview.js\`. +Read more at https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-addparameters-and-adddecorator).` +); +export const addLoader = (loader: LoaderFunction, deprecationWarning = true) => { + if (!singleton) + throw new Error(`Singleton client API not yet initialized, cannot call addParameters`); + + if (deprecationWarning) addLoaderDeprecationWarning(); + + singleton.addLoader(loader); +}; + export const addArgTypesEnhancer = (enhancer: ArgTypesEnhancer) => { if (!singleton) throw new Error(`Singleton client API not yet initialized, cannot call addArgTypesEnhancer`); @@ -99,7 +114,7 @@ export default class ClientApi { ); addDecorator = (decorator: DecoratorFunction) => { - this._storyStore.addGlobalMetadata({ decorators: [decorator], parameters: {} }); + this._storyStore.addGlobalMetadata({ decorators: [decorator] }); }; clearDecorators = deprecate( @@ -114,7 +129,11 @@ export default class ClientApi { ); addParameters = (parameters: Parameters) => { - this._storyStore.addGlobalMetadata({ decorators: [], parameters }); + this._storyStore.addGlobalMetadata({ parameters }); + }; + + addLoader = (loader: LoaderFunction) => { + this._storyStore.addGlobalMetadata({ loaders: [loader] }); }; addArgTypesEnhancer = (enhancer: ArgTypesEnhancer) => { @@ -161,6 +180,7 @@ export default class ClientApi { kind: kind.toString(), add: () => api, addDecorator: () => api, + addLoader: () => api, addParameters: () => api, }; @@ -196,7 +216,7 @@ export default class ClientApi { const fileName = m && m.id ? `${m.id}` : undefined; - const { decorators, ...storyParameters } = parameters; + const { decorators, loaders, ...storyParameters } = parameters; this._storyStore.addStory( { id, @@ -205,6 +225,7 @@ export default class ClientApi { storyFn, parameters: { fileName, ...storyParameters }, decorators, + loaders, }, { applyDecorators: applyHooks(this._decorateStory), @@ -218,7 +239,14 @@ export default class ClientApi { throw new Error(`You cannot add a decorator after the first story for a kind. Read more here: https://github.com/storybookjs/storybook/blob/master/MIGRATION.md#can-no-longer-add-decorators-parameters-after-stories`); - this._storyStore.addKindMetadata(kind, { decorators: [decorator], parameters: [] }); + this._storyStore.addKindMetadata(kind, { decorators: [decorator] }); + return api; + }; + + api.addLoader = (loader: LoaderFunction) => { + if (hasAdded) throw new Error(`You cannot add a loader after the first story for a kind.`); + + this._storyStore.addKindMetadata(kind, { loaders: [loader] }); return api; }; @@ -227,7 +255,7 @@ Read more here: https://github.com/storybookjs/storybook/blob/master/MIGRATION.m throw new Error(`You cannot add parameters after the first story for a kind. Read more here: https://github.com/storybookjs/storybook/blob/master/MIGRATION.md#can-no-longer-add-decorators-parameters-after-stories`); - this._storyStore.addKindMetadata(kind, { decorators: [], parameters }); + this._storyStore.addKindMetadata(kind, { parameters }); return api; }; diff --git a/lib/client-api/src/decorators.ts b/lib/client-api/src/decorators.ts index 5a4d6339ed09..8e51b7d580d0 100644 --- a/lib/client-api/src/decorators.ts +++ b/lib/client-api/src/decorators.ts @@ -15,14 +15,25 @@ const defaultContext: StoryContext = { globals: {}, }; +/** + * When you call the story function inside a decorator, e.g.: + * + * ```jsx + *
{storyFn({ foo: 'bar' })}
+ * ``` + * + * This will override the `foo` property on the `innerContext`, which gets + * merged in with the default context + */ +export const decorateStory = (storyFn: StoryFn, decorator: DecoratorFunction) => { + return (context: StoryContext = defaultContext) => + decorator( + // You cannot override the parameters key, it is fixed + ({ parameters, ...innerContext }: StoryContextUpdate = {}) => + storyFn({ ...context, ...innerContext }), + context + ); +}; + export const defaultDecorateStory = (storyFn: StoryFn, decorators: DecoratorFunction[]) => - decorators.reduce( - (decorated, decorator) => (context: StoryContext = defaultContext) => - decorator( - // You cannot override the parameters key, it is fixed - ({ parameters, ...innerContext }: StoryContextUpdate = {}) => - decorated({ ...context, ...innerContext }), - context - ), - storyFn - ); + decorators.reduce(decorateStory, storyFn); diff --git a/lib/client-api/src/index.ts b/lib/client-api/src/index.ts index 85d44c94530e..0f3fcb36891f 100644 --- a/lib/client-api/src/index.ts +++ b/lib/client-api/src/index.ts @@ -1,4 +1,9 @@ -import ClientApi, { addDecorator, addParameters, addArgTypesEnhancer } from './client_api'; +import ClientApi, { + addDecorator, + addParameters, + addLoader, + addArgTypesEnhancer, +} from './client_api'; import { defaultDecorateStory } from './decorators'; import { combineParameters } from './parameters'; import StoryStore from './story_store'; @@ -19,6 +24,7 @@ export { ClientApi, addDecorator, addParameters, + addLoader, addArgTypesEnhancer, combineParameters, StoryStore, diff --git a/lib/client-api/src/story_store.ts b/lib/client-api/src/story_store.ts index 57db5acb1051..3387ea5958a2 100644 --- a/lib/client-api/src/story_store.ts +++ b/lib/client-api/src/story_store.ts @@ -5,6 +5,7 @@ import stable from 'stable'; import mapValues from 'lodash/mapValues'; import pick from 'lodash/pick'; import store from 'store2'; +import deprecate from 'util-deprecate'; import { Channel } from '@storybook/channels'; import Events from '@storybook/core-events'; @@ -127,7 +128,7 @@ export default class StoryStore { // We store global args in session storage. Note that when we finish // configuring below we will ensure we only use values here that make sense this._globals = store.session.get(STORAGE_KEY)?.globals || {}; - this._globalMetadata = { parameters: {}, decorators: [] }; + this._globalMetadata = { parameters: {}, decorators: [], loaders: [] }; this._kinds = {}; this._stories = {}; this._argTypesEnhancers = [ensureArgTypes]; @@ -235,7 +236,7 @@ export default class StoryStore { this.pushToManager(); } - addGlobalMetadata({ parameters, decorators }: StoryMetadata) { + addGlobalMetadata({ parameters = {}, decorators = [], loaders = [] }: StoryMetadata) { if (parameters) { const { args, argTypes } = parameters; if (args || argTypes) @@ -248,13 +249,18 @@ export default class StoryStore { this._globalMetadata.parameters = combineParameters(globalParameters, parameters); - decorators.forEach((decorator) => { - if (this._globalMetadata.decorators.includes(decorator)) { - logger.warn('You tried to add a duplicate decorator, this is not expected', decorator); - } else { - this._globalMetadata.decorators.push(decorator); - } - }); + function _safeAdd(items: any[], collection: any[], caption: string) { + items.forEach((item) => { + if (collection.includes(item)) { + logger.warn(`You tried to add a duplicate ${caption}, this is not expected`, item); + } else { + collection.push(item); + } + }); + } + + _safeAdd(decorators, this._globalMetadata.decorators, 'decorator'); + _safeAdd(loaders, this._globalMetadata.loaders, 'loader'); } clearGlobalDecorators() { @@ -267,11 +273,12 @@ export default class StoryStore { order: Object.keys(this._kinds).length, parameters: {}, decorators: [], + loaders: [], }; } } - addKindMetadata(kind: string, { parameters, decorators }: StoryMetadata) { + addKindMetadata(kind: string, { parameters = {}, decorators = [], loaders = [] }: StoryMetadata) { this.ensureKind(kind); if (parameters) { checkGlobals(parameters); @@ -280,6 +287,7 @@ export default class StoryStore { this._kinds[kind].parameters = combineParameters(this._kinds[kind].parameters, parameters); this._kinds[kind].decorators.push(...decorators); + this._kinds[kind].loaders.push(...loaders); } addArgTypesEnhancer(argTypesEnhancer: ArgTypesEnhancer) { @@ -306,6 +314,7 @@ export default class StoryStore { storyFn: original, parameters: storyParameters = {}, decorators: storyDecorators = [], + loaders: storyLoaders = [], }: AddStoryArgs, { applyDecorators, @@ -352,6 +361,7 @@ export default class StoryStore { ...kindMetadata.decorators, ...this._globalMetadata.decorators, ]; + const loaders = [...this._globalMetadata.loaders, ...kindMetadata.loaders, ...storyLoaders]; const finalStoryFn = (context: StoryContext) => { const { passArgsFirst = true } = context.parameters; @@ -396,10 +406,31 @@ export default class StoryStore { const storyParametersWithArgTypes = { ...storyParameters, argTypes, __isArgsStory }; - const storyFn: LegacyStoryFn = (runtimeContext: StoryContext) => - getDecorated()({ + const storyFn: LegacyStoryFn = deprecate( + (runtimeContext: StoryContext) => + getDecorated()({ + ...identification, + ...runtimeContext, + // Calculate "combined" parameters at render time (NOTE: for perf we could just use combinedParameters from above?) + parameters: this.combineStoryParameters(storyParametersWithArgTypes, kind), + hooks, + args: _stories[id].args, + argTypes, + globals: this._globals, + viewMode: this._selection?.viewMode, + }), + dedent` + \`storyFn\` is deprecated and will be removed in Storybook 7.0. + + https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-storyfn + ` + ); + + const unboundStoryFn: LegacyStoryFn = (context: StoryContext) => getDecorated()(context); + + const applyLoaders = async () => { + const context = { ...identification, - ...runtimeContext, // Calculate "combined" parameters at render time (NOTE: for perf we could just use combinedParameters from above?) parameters: this.combineStoryParameters(storyParametersWithArgTypes, kind), hooks, @@ -407,7 +438,11 @@ export default class StoryStore { argTypes, globals: this._globals, viewMode: this._selection?.viewMode, - }); + }; + const loadResults = await Promise.all(loaders.map((loader) => loader(context))); + const loaded = Object.assign({}, ...loadResults); + return { ...context, loaded }; + }; // Pull out parameters.args.$ || .argTypes.$.defaultValue into initialArgs const passedArgs: Args = combinedParameters.args; @@ -425,7 +460,9 @@ export default class StoryStore { hooks, getDecorated, getOriginal, + applyLoaders, storyFn, + unboundStoryFn, parameters: storyParametersWithArgTypes, args: initialArgs, diff --git a/lib/client-api/src/types.ts b/lib/client-api/src/types.ts index 927b746e45ef..2bcb37669566 100644 --- a/lib/client-api/src/types.ts +++ b/lib/client-api/src/types.ts @@ -11,6 +11,7 @@ import { ArgTypes, StoryApi, DecoratorFunction, + LoaderFunction, DecorateStoryFunction, StoryContext, } from '@storybook/addons'; @@ -24,8 +25,9 @@ export interface ErrorLike { // Metadata about a story that can be set at various levels: global, for a kind, or for a single story. export interface StoryMetadata { - parameters: Parameters; - decorators: DecoratorFunction[]; + parameters?: Parameters; + decorators?: DecoratorFunction[]; + loaders?: LoaderFunction[]; } export type ArgTypesEnhancer = (context: StoryContext) => ArgTypes; @@ -45,13 +47,16 @@ export type AddStoryArgs = StoryIdentifier & { storyFn: StoryFn; parameters?: Parameters; decorators?: DecoratorFunction[]; + loaders?: LoaderFunction[]; }; export type StoreItem = StoryIdentifier & { parameters: Parameters; getDecorated: () => StoryFn; getOriginal: () => StoryFn; + applyLoaders: () => Promise; storyFn: StoryFn; + unboundStoryFn: StoryFn; hooks: HooksContext; args: Args; initialArgs: Args; diff --git a/lib/core/src/client/preview/StoryRenderer.test.ts b/lib/core/src/client/preview/StoryRenderer.test.ts index 6bb7a9c35506..0aa5b368cbd5 100644 --- a/lib/core/src/client/preview/StoryRenderer.test.ts +++ b/lib/core/src/client/preview/StoryRenderer.test.ts @@ -13,7 +13,13 @@ import { STORY_RENDERED, } from '@storybook/core-events'; import { toId } from '@storybook/csf'; -import addons, { StoryKind, StoryName, Parameters } from '@storybook/addons'; +import addons, { + StoryFn, + StoryKind, + StoryName, + Parameters, + LoaderFunction, +} from '@storybook/addons'; import ReactDOM from 'react-dom'; import { StoryRenderer } from './StoryRenderer'; @@ -29,10 +35,10 @@ jest.mock('@storybook/client-logger', () => ({ })); function prepareRenderer() { - const render = jest.fn(); + const render = jest.fn(({ storyFn }) => storyFn()); const channel = createChannel({ page: 'preview' }); addons.setChannel(channel); - const storyStore = new StoryStore({ channel }); + const storyStore = new StoryStore({ channel: null }); const renderer = new StoryRenderer({ render, channel, storyStore }); // mock out all the dom-touching functions @@ -50,11 +56,13 @@ function addStory( storyStore: StoryStore, kind: StoryKind, name: StoryName, - parameters: Parameters = {} + parameters: Parameters = {}, + loaders: LoaderFunction[] = [], + storyFn: StoryFn = jest.fn() ) { const id = toId(kind, name); storyStore.addStory( - { id, kind, name, storyFn: jest.fn(), parameters }, + { id, kind, name, storyFn, parameters, loaders }, { applyDecorators: defaultDecorateStory, } @@ -66,10 +74,13 @@ function addAndSelectStory( storyStore: StoryStore, kind: StoryKind, name: StoryName, - parameters: Parameters = {} + parameters: Parameters = {}, + loaders: LoaderFunction[] = undefined ) { - const id = addStory(storyStore, kind, name, parameters); + const storyFn = jest.fn(); + const id = addStory(storyStore, kind, name, parameters, loaders, storyFn); storyStore.setSelection({ storyId: id, viewMode: 'story' }); + return storyFn; } describe('core.preview.StoryRenderer', () => { @@ -80,6 +91,8 @@ describe('core.preview.StoryRenderer', () => { channel.on(STORY_RENDERED, onStoryRendered); addAndSelectStory(storyStore, 'a', '1', { p: 'q' }); + + await renderer.renderCurrentStory(false); expect(render).toHaveBeenCalledWith( expect.objectContaining({ id: 'a--1', @@ -95,7 +108,7 @@ describe('core.preview.StoryRenderer', () => { ); render.mockClear(); - renderer.renderCurrentStory(true); + await renderer.renderCurrentStory(true); expect(render).toHaveBeenCalledWith( expect.objectContaining({ forceRender: true, @@ -108,18 +121,91 @@ describe('core.preview.StoryRenderer', () => { expect(onStoryRendered).toHaveBeenCalledWith('a--1'); }); + describe('loaders', () => { + it('loads data asynchronously and passes to stories', async () => { + const { channel, storyStore, renderer } = prepareRenderer(); + + const onStoryRendered = jest.fn(); + channel.on(STORY_RENDERED, onStoryRendered); + + const loaders = [async () => new Promise((r) => setTimeout(() => r({ foo: 7 }), 100))]; + const storyFn = addAndSelectStory(storyStore, 'a', '1', {}, loaders); + + await renderer.renderCurrentStory(false); + expect(storyFn).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + id: 'a--1', + kind: 'a', + name: '1', + loaded: { foo: 7 }, + }) + ); + + expect(onStoryRendered).toHaveBeenCalledWith('a--1'); + }); + it('later loaders override earlier loaders', async () => { + const { channel, storyStore, renderer } = prepareRenderer(); + + const onStoryRendered = jest.fn(); + channel.on(STORY_RENDERED, onStoryRendered); + + const loaders = [ + async () => new Promise((r) => setTimeout(() => r({ foo: 7 }), 100)), + async () => new Promise((r) => setTimeout(() => r({ foo: 3 }), 50)), + ]; + const storyFn = addAndSelectStory(storyStore, 'a', '1', {}, loaders); + + await renderer.renderCurrentStory(false); + expect(storyFn).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + id: 'a--1', + kind: 'a', + name: '1', + loaded: { foo: 3 }, + }) + ); + + expect(onStoryRendered).toHaveBeenCalledWith('a--1'); + }); + it('more specific loaders override more generic loaders', async () => { + const { channel, storyStore, renderer } = prepareRenderer(); + + const onStoryRendered = jest.fn(); + channel.on(STORY_RENDERED, onStoryRendered); + + storyStore.addGlobalMetadata({ loaders: [async () => ({ foo: 1, bar: 1, baz: 1 })] }); + storyStore.addKindMetadata('a', { loaders: [async () => ({ foo: 3, bar: 3 })] }); + const storyFn = addAndSelectStory(storyStore, 'a', '1', {}, [async () => ({ foo: 5 })]); + + await renderer.renderCurrentStory(false); + expect(storyFn).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + id: 'a--1', + kind: 'a', + name: '1', + loaded: { foo: 5, bar: 3, baz: 1 }, + }) + ); + + expect(onStoryRendered).toHaveBeenCalledWith('a--1'); + }); + }); describe('errors', () => { - it('renders an error if a config error is set on the store', () => { + it('renders an error if a config error is set on the store', async () => { const { render, storyStore, renderer } = prepareRenderer(); const err = { message: 'message', stack: 'stack' }; storyStore.setError(err); storyStore.finishConfiguring(); + await renderer.renderCurrentStory(false); expect(render).not.toHaveBeenCalled(); expect(renderer.showErrorDisplay).toHaveBeenCalledWith(err); }); - it('renders an error if the story calls renderError', () => { + it('renders an error if the story calls renderError', async () => { const { render, channel, storyStore, renderer } = prepareRenderer(); const onStoryErrored = jest.fn(); @@ -129,6 +215,7 @@ describe('core.preview.StoryRenderer', () => { render.mockImplementation(({ showError }) => showError(err)); addAndSelectStory(storyStore, 'a', '1'); + await renderer.renderCurrentStory(false); expect(renderer.showErrorDisplay).toHaveBeenCalledWith({ message: 'title', @@ -137,7 +224,7 @@ describe('core.preview.StoryRenderer', () => { expect(onStoryErrored).toHaveBeenCalledWith(err); }); - it('renders an exception if the story calls renderException', () => { + it('renders an exception if the story calls renderException', async () => { const { render, channel, storyStore, renderer } = prepareRenderer(); const onStoryThrewException = jest.fn(); @@ -147,12 +234,13 @@ describe('core.preview.StoryRenderer', () => { render.mockImplementation(({ showException }) => showException(err)); addAndSelectStory(storyStore, 'a', '1'); + await renderer.renderCurrentStory(false); expect(renderer.showErrorDisplay).toHaveBeenCalledWith(err); expect(onStoryThrewException).toHaveBeenCalledWith(err); }); - it('renders an exception if the render function throws', () => { + it('renders an exception if the render function throws', async () => { const { render, channel, storyStore, renderer } = prepareRenderer(); const onStoryThrewException = jest.fn(); @@ -164,12 +252,13 @@ describe('core.preview.StoryRenderer', () => { }); addAndSelectStory(storyStore, 'a', '1'); + await renderer.renderCurrentStory(false); expect(renderer.showErrorDisplay).toHaveBeenCalledWith(err); expect(onStoryThrewException).toHaveBeenCalledWith(err); }); - it('renders an error if the story is missing', () => { + it('renders an error if the story is missing', async () => { const { render, channel, storyStore, renderer } = prepareRenderer(); const onStoryMissing = jest.fn(); @@ -177,6 +266,7 @@ describe('core.preview.StoryRenderer', () => { addStory(storyStore, 'a', '1'); storyStore.setSelection({ storyId: 'b--2', viewMode: 'story' }); + await renderer.renderCurrentStory(false); expect(render).not.toHaveBeenCalled(); @@ -186,8 +276,8 @@ describe('core.preview.StoryRenderer', () => { }); describe('docs mode', () => { - it('renders docs and emits when rendering a docs story', () => { - const { render, channel, storyStore } = prepareRenderer(); + it('renders docs and emits when rendering a docs story', async () => { + const { render, channel, storyStore, renderer } = prepareRenderer(); const onDocsRendered = jest.fn(); channel.on(DOCS_RENDERED, onDocsRendered); @@ -197,6 +287,7 @@ describe('core.preview.StoryRenderer', () => { addStory(storyStore, 'a', '1'); storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); + await renderer.renderCurrentStory(false); // Although the docs React component may ultimately render the story we are mocking out // `react-dom` and just check that *something* is being rendered by react at this point @@ -204,10 +295,11 @@ describe('core.preview.StoryRenderer', () => { expect(onDocsRendered).toHaveBeenCalledWith('a'); }); - it('hides the root and shows the docs root as well as main when rendering a docs story', () => { + it('hides the root and shows the docs root as well as main when rendering a docs story', async () => { const { storyStore, renderer } = prepareRenderer(); addStory(storyStore, 'a', '1'); storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); + await renderer.renderCurrentStory(false); expect(renderer.showDocs).toHaveBeenCalled(); expect(renderer.showMain).toHaveBeenCalled(); @@ -236,15 +328,16 @@ describe('core.preview.StoryRenderer', () => { }); describe('re-rendering behaviour', () => { - it('does not re-render if nothing changed', () => { + it('does not re-render if nothing changed', async () => { const { render, channel, storyStore, renderer } = prepareRenderer(); addAndSelectStory(storyStore, 'a', '1'); + await renderer.renderCurrentStory(false); const onStoryUnchanged = jest.fn(); channel.on(STORY_UNCHANGED, onStoryUnchanged); render.mockClear(); - renderer.renderCurrentStory(false); + await renderer.renderCurrentStory(false); expect(render).not.toHaveBeenCalled(); // Not sure why STORY_UNCHANGED is called with all this stuff expect(onStoryUnchanged).toHaveBeenCalledWith({ @@ -255,36 +348,40 @@ describe('core.preview.StoryRenderer', () => { getDecorated: expect.any(Function), }); }); - it('does re-render the current story if it has not changed if forceRender is true', () => { + it('does re-render the current story if it has not changed if forceRender is true', async () => { const { render, channel, storyStore, renderer } = prepareRenderer(); addAndSelectStory(storyStore, 'a', '1'); + await renderer.renderCurrentStory(false); const onStoryChanged = jest.fn(); channel.on(STORY_CHANGED, onStoryChanged); render.mockClear(); - renderer.renderCurrentStory(true); + await renderer.renderCurrentStory(true); expect(render).toHaveBeenCalled(); expect(onStoryChanged).not.toHaveBeenCalled(); }); - it('does re-render if the selected story changes', () => { - const { render, channel, storyStore } = prepareRenderer(); + it('does re-render if the selected story changes', async () => { + const { render, channel, storyStore, renderer } = prepareRenderer(); addStory(storyStore, 'a', '1'); addAndSelectStory(storyStore, 'a', '2'); + await renderer.renderCurrentStory(false); const onStoryChanged = jest.fn(); channel.on(STORY_CHANGED, onStoryChanged); render.mockClear(); storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' }); + await renderer.renderCurrentStory(false); expect(render).toHaveBeenCalled(); expect(onStoryChanged).toHaveBeenCalledWith('a--1'); }); - it('does re-render if the story implementation changes', () => { - const { render, channel, storyStore } = prepareRenderer(); + it('does re-render if the story implementation changes', async () => { + const { render, channel, storyStore, renderer } = prepareRenderer(); addAndSelectStory(storyStore, 'a', '1'); + await renderer.renderCurrentStory(false); const onStoryChanged = jest.fn(); channel.on(STORY_CHANGED, onStoryChanged); @@ -292,80 +389,93 @@ describe('core.preview.StoryRenderer', () => { render.mockClear(); storyStore.removeStoryKind('a'); addAndSelectStory(storyStore, 'a', '1'); - expect(render).toHaveBeenCalled(); + await renderer.renderCurrentStory(false); + expect(render).toHaveBeenCalled(); expect(onStoryChanged).not.toHaveBeenCalled(); }); - it('does re-render if the view mode changes', () => { - const { render, channel, storyStore } = prepareRenderer(); + it('does re-render if the view mode changes', async () => { + const { render, channel, storyStore, renderer } = prepareRenderer(); addAndSelectStory(storyStore, 'a', '1'); storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); + await renderer.renderCurrentStory(false); const onStoryChanged = jest.fn(); channel.on(STORY_CHANGED, onStoryChanged); render.mockClear(); storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' }); - expect(render).toHaveBeenCalled(); + await renderer.renderCurrentStory(false); + expect(render).toHaveBeenCalled(); expect(onStoryChanged).toHaveBeenCalledWith('a--1'); }); }); describe('hooks', () => { - it('cleans up kind hooks when changing kind in docs mode', () => { - const { storyStore } = prepareRenderer(); + it('cleans up kind hooks when changing kind in docs mode', async () => { + const { storyStore, renderer } = prepareRenderer(); addAndSelectStory(storyStore, 'a', '1'); addAndSelectStory(storyStore, 'b', '1'); storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); + await renderer.renderCurrentStory(false); storyStore.cleanHooksForKind = jest.fn(); storyStore.setSelection({ storyId: 'b--1', viewMode: 'docs' }); + await renderer.renderCurrentStory(false); expect(storyStore.cleanHooksForKind).toHaveBeenCalledWith('a'); }); - it('does not clean up hooks when changing story but not kind in docs mode', () => { - const { storyStore } = prepareRenderer(); + it('does not clean up hooks when changing story but not kind in docs mode', async () => { + const { storyStore, renderer } = prepareRenderer(); addAndSelectStory(storyStore, 'a', '1'); addAndSelectStory(storyStore, 'a', '2'); storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); + await renderer.renderCurrentStory(false); storyStore.cleanHooksForKind = jest.fn(); storyStore.setSelection({ storyId: 'a--2', viewMode: 'docs' }); + await renderer.renderCurrentStory(false); expect(storyStore.cleanHooksForKind).not.toHaveBeenCalled(); }); - it('cleans up kind hooks when changing view mode from docs', () => { - const { storyStore } = prepareRenderer(); + it('cleans up kind hooks when changing view mode from docs', async () => { + const { storyStore, renderer } = prepareRenderer(); addAndSelectStory(storyStore, 'a', '1'); storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); + await renderer.renderCurrentStory(false); storyStore.cleanHooksForKind = jest.fn(); storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' }); + await renderer.renderCurrentStory(false); expect(storyStore.cleanHooksForKind).toHaveBeenCalledWith('a'); }); - it('cleans up story hooks when changing story in story mode', () => { - const { storyStore } = prepareRenderer(); + it('cleans up story hooks when changing story in story mode', async () => { + const { storyStore, renderer } = prepareRenderer(); addStory(storyStore, 'a', '1'); addAndSelectStory(storyStore, 'a', '2'); + await renderer.renderCurrentStory(false); storyStore.cleanHooks = jest.fn(); storyStore.setSelection({ storyId: 'a--1', viewMode: 'story' }); + await renderer.renderCurrentStory(false); expect(storyStore.cleanHooks).toHaveBeenCalledWith('a--2'); }); - it('cleans up story hooks when changing view mode from story', () => { - const { storyStore } = prepareRenderer(); + it('cleans up story hooks when changing view mode from story', async () => { + const { storyStore, renderer } = prepareRenderer(); addAndSelectStory(storyStore, 'a', '1'); + await renderer.renderCurrentStory(false); storyStore.cleanHooks = jest.fn(); storyStore.setSelection({ storyId: 'a--1', viewMode: 'docs' }); + await renderer.renderCurrentStory(false); expect(storyStore.cleanHooks).toHaveBeenCalledWith('a--1'); }); diff --git a/lib/core/src/client/preview/StoryRenderer.tsx b/lib/core/src/client/preview/StoryRenderer.tsx index 7f1ae7d38d47..dfd2c4477b37 100644 --- a/lib/core/src/client/preview/StoryRenderer.tsx +++ b/lib/core/src/client/preview/StoryRenderer.tsx @@ -106,7 +106,7 @@ export class StoryRenderer { this.renderCurrentStory(true); } - renderCurrentStory(forceRender: boolean) { + async renderCurrentStory(forceRender: boolean) { const { storyStore } = this; const loadError = storyStore.getError(); @@ -140,10 +140,10 @@ export class StoryRenderer { showException: (err: Error) => this.renderException(err), }; - this.renderStoryIfChanged({ metadata, context }); + await this.renderStoryIfChanged({ metadata, context }); } - renderStoryIfChanged({ + async renderStoryIfChanged({ metadata, context, }: { @@ -217,7 +217,7 @@ export class StoryRenderer { } case 'story': default: { - this.renderStory({ context }); + await this.renderStory({ context }); break; } } @@ -273,16 +273,17 @@ export class StoryRenderer { document.getElementById('root').removeAttribute('hidden'); } - renderStory({ context, context: { id, getDecorated } }: { context: RenderContext }) { + async renderStory({ context, context: { id, getDecorated } }: { context: RenderContext }) { if (getDecorated) { - (async () => { - try { - await this.render(context); - this.channel.emit(Events.STORY_RENDERED, id); - } catch (err) { - this.renderException(err); - } - })(); + try { + const { applyLoaders, unboundStoryFn } = context; + const storyContext = await applyLoaders(); + const storyFn = () => unboundStoryFn(storyContext); + await this.render({ ...context, storyFn }); + this.channel.emit(Events.STORY_RENDERED, id); + } catch (err) { + this.renderException(err); + } } else { this.showNoPreview(); this.channel.emit(Events.STORY_MISSING, id); diff --git a/lib/core/src/client/preview/loadCsf.test.ts b/lib/core/src/client/preview/loadCsf.test.ts index a5bddf5caa58..ed1ec9b46e00 100644 --- a/lib/core/src/client/preview/loadCsf.test.ts +++ b/lib/core/src/client/preview/loadCsf.test.ts @@ -82,7 +82,7 @@ describe('core.preview.loadCsf', () => { const mockedStoriesOf = clientApi.storiesOf as jest.Mock; expect(mockedStoriesOf).toHaveBeenCalledWith('a', true); const aApi = mockedStoriesOf.mock.results[0].value; - const extras: any = { decorators: [], args: {}, argTypes: {} }; + const extras: any = { decorators: [], args: {}, argTypes: {}, loaders: [] }; expect(aApi.add).toHaveBeenCalledWith('1', input.a[1], { __id: 'a--1', ...extras }); expect(aApi.add).toHaveBeenCalledWith('2', input.a[2], { __id: 'a--2', ...extras }); @@ -181,6 +181,7 @@ describe('core.preview.loadCsf', () => { decorators: [], args: {}, argTypes: {}, + loaders: [], }); }); @@ -264,6 +265,7 @@ describe('core.preview.loadCsf', () => { __id: 'a--x', args: { b: 1 }, argTypes: { b: 'string' }, + loaders: [], }); expect(logger.debug).toHaveBeenCalled(); }); @@ -295,6 +297,7 @@ describe('core.preview.loadCsf', () => { __id: 'a--x', args: { b: 1 }, argTypes: { b: 'string' }, + loaders: [], }); expect(logger.debug).not.toHaveBeenCalled(); }); @@ -334,6 +337,7 @@ describe('core.preview.loadCsf', () => { __id: 'a--x', args: { b: 1, c: 2 }, argTypes: { b: 'string', c: 'number' }, + loaders: [], }); expect(logger.debug).toHaveBeenCalled(); }); diff --git a/lib/core/src/client/preview/loadCsf.ts b/lib/core/src/client/preview/loadCsf.ts index 0b7509f520cb..6d54ec2f5193 100644 --- a/lib/core/src/client/preview/loadCsf.ts +++ b/lib/core/src/client/preview/loadCsf.ts @@ -115,6 +115,7 @@ const loadStories = ( id: componentId, parameters: kindParameters, decorators: kindDecorators, + loaders: kindLoaders = [], component, subcomponents, args: kindArgs, @@ -146,6 +147,10 @@ const loadStories = ( kind.addDecorator(decorator); }); + kindLoaders.forEach((loader: any) => { + kind.addLoader(loader); + }); + const storyExports = Object.keys(exports); if (storyExports.length === 0) { logger.warn( @@ -167,6 +172,7 @@ const loadStories = ( // storyFn.x taking precedence in the merge const parameters = { ...story?.parameters, ...storyFn.parameters }; const decorators = [...(storyFn.decorators || []), ...(story?.decorators || [])]; + const loaders = [...(storyFn.loaders || []), ...(story?.loaders || [])]; const args = { ...story?.args, ...storyFn.args }; const argTypes = { ...story?.argTypes, ...storyFn.argTypes }; @@ -180,6 +186,7 @@ const loadStories = ( ...parameters, __id: toId(componentId || kindName, exportName), decorators, + loaders, args, argTypes, }; diff --git a/lib/core/src/client/preview/start.test.ts b/lib/core/src/client/preview/start.test.ts index 3f7e9e424304..8daaa45b6bd4 100644 --- a/lib/core/src/client/preview/start.test.ts +++ b/lib/core/src/client/preview/start.test.ts @@ -45,7 +45,6 @@ it('returns apis', () => { }); it('reuses the current client api when the lib is reloaded', () => { - jest.useFakeTimers(); const render = jest.fn(); const { clientApi } = start(render); @@ -58,8 +57,12 @@ it('reuses the current client api when the lib is reloaded', () => { expect(clientApi).toEqual(valueOfClientApi); }); -it('calls render when you add a story', () => { - jest.useFakeTimers(); +// With async rendering we need to wait for various promises to resolve. +// Sleeping for 0 ms allows all the async (but instantaneous) calls to run +// through the event loop. +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +it('calls render when you add a story', async () => { const render = jest.fn(); const { clientApi, configApi } = start(render); @@ -68,11 +71,11 @@ it('calls render when you add a story', () => { clientApi.storiesOf('kind', {} as NodeModule).add('story', () => {}); }, {} as NodeModule); + await sleep(0); expect(render).toHaveBeenCalledWith(expect.objectContaining({ kind: 'kind', name: 'story' })); }); -it('emits an exception and shows error when your story throws', () => { - jest.useFakeTimers(); +it('emits an exception and shows error when your story throws', async () => { const render = jest.fn().mockImplementation(() => { throw new Error('Some exception'); }); @@ -83,12 +86,12 @@ it('emits an exception and shows error when your story throws', () => { clientApi.storiesOf('kind', {} as NodeModule).add('story1', () => {}); }, {} as NodeModule); + await sleep(0); expect(render).toHaveBeenCalled(); expect(document.body.classList.add).toHaveBeenCalledWith('sb-show-errordisplay'); }); -it('emits an error and shows error when your framework calls showError', () => { - jest.useFakeTimers(); +it('emits an error and shows error when your framework calls showError', async () => { const error = { title: 'Some error', description: 'description', @@ -103,6 +106,7 @@ it('emits an error and shows error when your framework calls showError', () => { clientApi.storiesOf('kind', {} as NodeModule).add('story', () => {}); }, {} as NodeModule); + await sleep(0); expect(render).toHaveBeenCalled(); expect(document.body.classList.add).toHaveBeenCalledWith('sb-show-errordisplay'); }); diff --git a/lib/core/src/server/preview/virtualModuleEntry.template.js b/lib/core/src/server/preview/virtualModuleEntry.template.js index a596a009065e..fafdca135c63 100644 --- a/lib/core/src/server/preview/virtualModuleEntry.template.js +++ b/lib/core/src/server/preview/virtualModuleEntry.template.js @@ -1,9 +1,10 @@ /* eslint-disable import/no-unresolved */ -import { addDecorator, addParameters, addArgTypesEnhancer } from '{{clientApi}}'; +import { addDecorator, addParameters, addLoader, addArgTypesEnhancer } from '{{clientApi}}'; import { logger } from '{{clientLogger}}'; import { decorators, parameters, + loaders, argTypesEnhancers, globals, globalTypes, @@ -17,6 +18,9 @@ if (args || argTypes) { if (decorators) { decorators.forEach((decorator) => addDecorator(decorator, false)); } +if (loaders) { + loaders.forEach((loader) => addLoader(loader, false)); +} if (parameters || globals || globalTypes) { addParameters({ ...parameters, globals, globalTypes }, false); } diff --git a/scripts/build-frontpage.js b/scripts/build-frontpage.js index 536def70c4a1..118f697da039 100755 --- a/scripts/build-frontpage.js +++ b/scripts/build-frontpage.js @@ -10,7 +10,7 @@ const branch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); const branchToHook = { master: FRONTPAGE_WEBHOOK, - 'next': FRONTPAGE_WEBHOOK_NEXT, + next: FRONTPAGE_WEBHOOK_NEXT, }; console.log('build-frontpage');