From 0f330e2c82897cd8ada87950cd34af3f73ffb4c8 Mon Sep 17 00:00:00 2001 From: Hypnosphi Date: Sat, 17 Aug 2019 16:16:16 +0200 Subject: [PATCH 1/4] Preview hooks: trigger effects after story render --- .../stories/button.stories.js | 9 +++++++++ lib/client-api/src/hooks.test.js | 20 ++++++++++++++++++- lib/client-api/src/hooks.ts | 7 ++++--- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/examples/html-kitchen-sink/stories/button.stories.js b/examples/html-kitchen-sink/stories/button.stories.js index 02ed6aae869b..72627d513e5f 100644 --- a/examples/html-kitchen-sink/stories/button.stories.js +++ b/examples/html-kitchen-sink/stories/button.stories.js @@ -1,5 +1,6 @@ import { document } from 'global'; import { action } from '@storybook/addon-actions'; +import { useEffect } from '@storybook/client-api'; export default { title: 'Demo', @@ -15,3 +16,11 @@ export const button = () => { btn.addEventListener('click', action('Click')); return btn; }; + +export const effect = () => { + useEffect(() => { + document.getElementById('button').style.backgroundColor = 'yellow'; + }); + + return ''; +}; diff --git a/lib/client-api/src/hooks.test.js b/lib/client-api/src/hooks.test.js index 2705f56523e0..0410752f1d24 100644 --- a/lib/client-api/src/hooks.test.js +++ b/lib/client-api/src/hooks.test.js @@ -1,4 +1,4 @@ -import { FORCE_RE_RENDER } from '@storybook/addon-contexts/src/shared/constants'; +import { FORCE_RE_RENDER, STORY_RENDERED } from '@storybook/core-events'; import { defaultDecorateStory } from './client_api'; import { applyHooks, @@ -22,6 +22,11 @@ beforeEach(() => { mockChannel = { emit: jest.fn(), on: jest.fn(), + once: (event, callback) => { + if (event === STORY_RENDERED) { + callback(); + } + }, removeListener: jest.fn(), }; }); @@ -359,6 +364,19 @@ describe('Preview hooks', () => { expect(storyFn).toHaveBeenCalledTimes(2); expect(state).toBe('bar'); }); + it('triggers only the last effect when updating state synchronously', () => { + const effects = [jest.fn(), jest.fn()]; + const storyFn = jest.fn(() => { + const [effectIndex, setEffectIndex] = useState(0); + useEffect(effects[effectIndex], [effectIndex]); + if (effectIndex === 0) { + setEffectIndex(1); + } + }); + run(storyFn); + expect(effects[0]).not.toHaveBeenCalled(); + expect(effects[1]).toHaveBeenCalledTimes(1); + }); it('performs synchronous state updates with updater function', () => { let state; let setState; diff --git a/lib/client-api/src/hooks.ts b/lib/client-api/src/hooks.ts index c2ddad320823..958841ce8a55 100644 --- a/lib/client-api/src/hooks.ts +++ b/lib/client-api/src/hooks.ts @@ -1,6 +1,6 @@ import { logger } from '@storybook/client-logger'; import addons, { StoryGetter, StoryContext } from '@storybook/addons'; -import { FORCE_RE_RENDER } from '@storybook/core-events'; +import { FORCE_RE_RENDER, STORY_RENDERED } from '@storybook/core-events'; interface StoryStore { fromId: ( @@ -112,6 +112,7 @@ export const applyHooks = ( numberOfRenders = 1; while (hasUpdates) { hasUpdates = false; + currentEffects = []; result = decorated(context); numberOfRenders += 1; if (numberOfRenders > RENDER_LIMIT) { @@ -120,7 +121,7 @@ export const applyHooks = ( ); } } - triggerEffects(); + addons.getChannel().once(STORY_RENDERED, triggerEffects); currentContext = null; return result; }; @@ -271,7 +272,7 @@ export function useReducer( /* Triggers a side effect, see https://reactjs.org/docs/hooks-reference.html#usestate - Effects are triggered synchronously after calling the decorated story function + Effects are triggered synchronously after rendering the story */ export function useEffect(create: () => (() => void) | void, deps?: any[]): void { const effect = useMemoLike('useEffect', () => ({ create }), deps); From 149cebaf43753ccd0a32ec606a63a73a2e0ebde0 Mon Sep 17 00:00:00 2001 From: Hypnosphi Date: Sat, 17 Aug 2019 17:24:53 +0200 Subject: [PATCH 2/4] App React: Ensure that STORY_RENDERED is emitted only after calling storyFn --- app/react/src/client/preview/render.tsx | 18 ++++++++++-------- lib/core/src/client/preview/start.js | 6 ++++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/react/src/client/preview/render.tsx b/app/react/src/client/preview/render.tsx index c3d5eff30059..36068d6c09bf 100644 --- a/app/react/src/client/preview/render.tsx +++ b/app/react/src/client/preview/render.tsx @@ -7,14 +7,16 @@ import { RenderMainArgs } from './types'; const rootEl = document ? document.getElementById('root') : null; -function render(node: React.ReactElement, el: Element) { - ReactDOM.render( - process.env.STORYBOOK_EXAMPLE_APP ? {node} : node, - el - ); -} +const render = (node: React.ReactElement, el: Element) => + new Promise(resolve => { + ReactDOM.render( + process.env.STORYBOOK_EXAMPLE_APP ? {node} : node, + el, + resolve + ); + }); -export default function renderMain({ +export default async function renderMain({ storyFn: StoryFn, selectedKind, selectedStory, @@ -55,6 +57,6 @@ export default function renderMain({ ReactDOM.unmountComponentAtNode(rootEl); } - render(element, rootEl); + await render(element, rootEl); showMain(); } diff --git a/lib/core/src/client/preview/start.js b/lib/core/src/client/preview/start.js index 36d0bfe9b584..20fb190b7b1a 100644 --- a/lib/core/src/client/preview/start.js +++ b/lib/core/src/client/preview/start.js @@ -226,8 +226,10 @@ export default function start(render, { decorateStory } = {}) { case 'story': default: { if (getDecorated) { - render(renderContext); - addons.getChannel().emit(Events.STORY_RENDERED, id); + (async () => { + await render(renderContext); + addons.getChannel().emit(Events.STORY_RENDERED, id); + })(); } else { showNopreview(); addons.getChannel().emit(Events.STORY_MISSING, id); From d8da326655054f8c9fd957276d83c9c95729c313 Mon Sep 17 00:00:00 2001 From: Hypnosphi Date: Sat, 17 Aug 2019 17:35:52 +0200 Subject: [PATCH 3/4] Support slightly async decorators (#7407#issuecomment-514103397) --- lib/client-api/src/hooks.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/client-api/src/hooks.ts b/lib/client-api/src/hooks.ts index 958841ce8a55..fbeafb7c52b2 100644 --- a/lib/client-api/src/hooks.ts +++ b/lib/client-api/src/hooks.ts @@ -32,6 +32,7 @@ type AbstractFunction = (...args: any[]) => any; const hookListsMap = new WeakMap(); let mountedDecorators = new Set(); +let prevMountedDecorators = mountedDecorators; let currentHooks: Hook[] = []; let nextHookIndex = 0; @@ -72,7 +73,7 @@ const hookify = (fn: AbstractFunction) => (...args: any[]) => { const prevDecoratorName = currentDecoratorName; currentDecoratorName = fn.name; - if (mountedDecorators.has(fn)) { + if (prevMountedDecorators.has(fn)) { currentPhase = 'UPDATE'; currentHooks = hookListsMap.get(fn) || []; } else { @@ -105,10 +106,11 @@ export const applyHooks = ( ) => (getStory: StoryGetter, decorators: Decorator[]) => { const decorated = applyDecorators(hookify(getStory), decorators.map(hookify)); return (context: StoryContext) => { + prevMountedDecorators = mountedDecorators; + mountedDecorators = new Set([getStory, ...decorators]); currentContext = context; hasUpdates = false; let result = decorated(context); - mountedDecorators = new Set([getStory, ...decorators]); numberOfRenders = 1; while (hasUpdates) { hasUpdates = false; @@ -121,8 +123,10 @@ export const applyHooks = ( ); } } - addons.getChannel().once(STORY_RENDERED, triggerEffects); - currentContext = null; + addons.getChannel().once(STORY_RENDERED, () => { + triggerEffects(); + currentContext = null; + }); return result; }; }; From f0f68b47175d96e0f049121289be56aaf8c8054b Mon Sep 17 00:00:00 2001 From: Hypnosphi Date: Sat, 17 Aug 2019 22:21:24 +0200 Subject: [PATCH 4/4] Fix tests --- app/angular/src/client/preview/angular/decorators.test.ts | 3 +++ .../tests/__snapshots__/htmlshots.test.js.snap | 8 ++++++++ lib/client-api/src/client_api.test.ts | 3 ++- lib/client-api/src/hooks.ts | 1 + 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/angular/src/client/preview/angular/decorators.test.ts b/app/angular/src/client/preview/angular/decorators.test.ts index 2fa5b8d7ac06..7c9eb7bbbe2d 100644 --- a/app/angular/src/client/preview/angular/decorators.test.ts +++ b/app/angular/src/client/preview/angular/decorators.test.ts @@ -1,3 +1,5 @@ +import addons, { mockChannel } from '@storybook/addons'; + import { moduleMetadata } from './decorators'; import { addDecorator, storiesOf, clearDecorators, getStorybook } from '..'; @@ -78,6 +80,7 @@ describe('moduleMetadata', () => { imports: [MockModule], }; + addons.setChannel(mockChannel()); addDecorator(moduleMetadata(metadata)); storiesOf('Test', module).add('Default', () => ({ diff --git a/examples/html-kitchen-sink/tests/__snapshots__/htmlshots.test.js.snap b/examples/html-kitchen-sink/tests/__snapshots__/htmlshots.test.js.snap index 75754d906d0f..f4a8859c0f4e 100644 --- a/examples/html-kitchen-sink/tests/__snapshots__/htmlshots.test.js.snap +++ b/examples/html-kitchen-sink/tests/__snapshots__/htmlshots.test.js.snap @@ -215,6 +215,14 @@ exports[`Storyshots Demo button 1`] = ` `; +exports[`Storyshots Demo effect 1`] = ` + +`; + exports[`Storyshots Demo heading 1`] = `

Hello World diff --git a/lib/client-api/src/client_api.test.ts b/lib/client-api/src/client_api.test.ts index 95c9e2c58585..42e4ae7a7f76 100644 --- a/lib/client-api/src/client_api.test.ts +++ b/lib/client-api/src/client_api.test.ts @@ -1,12 +1,13 @@ /* eslint-disable no-underscore-dangle */ import { logger } from '@storybook/client-logger'; -import { mockChannel } from '@storybook/addons'; +import addons, { mockChannel } from '@storybook/addons'; import ClientApi from './client_api'; import ConfigApi from './config_api'; import StoryStore from './story_store'; export const getContext = (() => decorateStory => { const channel = mockChannel(); + addons.setChannel(channel); const storyStore = new StoryStore({ channel }); const clientApi = new ClientApi({ storyStore, decorateStory }); const { clearDecorators } = clientApi; diff --git a/lib/client-api/src/hooks.ts b/lib/client-api/src/hooks.ts index fbeafb7c52b2..295503d10c64 100644 --- a/lib/client-api/src/hooks.ts +++ b/lib/client-api/src/hooks.ts @@ -115,6 +115,7 @@ export const applyHooks = ( while (hasUpdates) { hasUpdates = false; currentEffects = []; + prevMountedDecorators = mountedDecorators; result = decorated(context); numberOfRenders += 1; if (numberOfRenders > RENDER_LIMIT) {