diff --git a/addons/docs/src/blocks/DocsPage.tsx b/addons/docs/src/blocks/DocsPage.tsx index 0dd243f394fd..ae8c84f85a80 100644 --- a/addons/docs/src/blocks/DocsPage.tsx +++ b/addons/docs/src/blocks/DocsPage.tsx @@ -110,9 +110,7 @@ export const DocsPage: React.FunctionComponent = ({ const propsTableProps = propsSlot(context); const { selectedKind, storyStore } = context; - const componentStories = (storyStore.raw() as StoryData[]).filter( - s => s.kind === selectedKind - ); + const componentStories = storyStore.getStoriesForKind(selectedKind); const primary = primarySlot(componentStories, context); const stories = storiesSlot(componentStories, context); diff --git a/examples/official-storybook/stories/hooks.stories.js b/examples/official-storybook/stories/hooks.stories.js new file mode 100644 index 000000000000..7f98f4e00c90 --- /dev/null +++ b/examples/official-storybook/stories/hooks.stories.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { useState } from '@storybook/client-api'; + +export default { + title: 'Hooks', +}; + +export const Checkbox = () => { + const [on, setOn] = useState(false); + return ( + + ); +}; + +export const Input = () => { + const [text, setText] = useState('foo'); + return setText(e.target.value)} />; +}; diff --git a/examples/vue-kitchen-sink/src/stories/custom-decorators.stories.js b/examples/vue-kitchen-sink/src/stories/custom-decorators.stories.js index c5bc0a2d2d17..208c02fda237 100644 --- a/examples/vue-kitchen-sink/src/stories/custom-decorators.stories.js +++ b/examples/vue-kitchen-sink/src/stories/custom-decorators.stories.js @@ -30,7 +30,7 @@ export const template = () => ({ template: 'MyButton with template', }); -export const withData = ({ parameters, ...rest }) => ({ +export const withData = ({ parameters, hooks, ...rest }) => ({ template: `
${JSON.stringify({ ...rest, parameters }, null, 2)}
`, }); diff --git a/lib/addons/package.json b/lib/addons/package.json index 107849a0515e..a2d4cfc75d06 100644 --- a/lib/addons/package.json +++ b/lib/addons/package.json @@ -24,6 +24,7 @@ "@storybook/api": "5.2.0-beta.40", "@storybook/channels": "5.2.0-beta.40", "@storybook/client-logger": "5.2.0-beta.40", + "@storybook/core-events": "5.2.0-beta.40", "core-js": "^3.0.1", "global": "^4.3.2", "util-deprecate": "^1.0.2" diff --git a/lib/addons/src/hooks.ts b/lib/addons/src/hooks.ts new file mode 100644 index 000000000000..05c32e715845 --- /dev/null +++ b/lib/addons/src/hooks.ts @@ -0,0 +1,390 @@ +import { window } from 'global'; +import { logger } from '@storybook/client-logger'; +import { FORCE_RE_RENDER, STORY_RENDERED } from '@storybook/core-events'; +import addons, { StoryGetter, StoryContext } from './public_api'; + +interface StoryStore { + fromId: ( + id: string + ) => { + parameters: { + [parameterKey: string]: any; + }; + }; + getSelection: () => { + storyId: string; + viewMode: string; + }; +} + +interface Hook { + name: string; + memoizedState?: any; + deps?: any[] | undefined; +} + +interface Effect { + create: () => (() => void) | void; + destroy?: (() => void) | void; +} + +type Decorator = (getStory: StoryGetter, context: StoryContext) => any; +type AbstractFunction = (...args: any[]) => any; + +export class HooksContext { + hookListsMap: WeakMap; + + mountedDecorators: Set; + + prevMountedDecorators: Set; + + currentHooks: Hook[]; + + nextHookIndex: number; + + currentPhase: 'MOUNT' | 'UPDATE' | 'NONE'; + + currentEffects: Effect[]; + + prevEffects: Effect[]; + + currentDecoratorName: string | null; + + hasUpdates: boolean; + + currentContext: StoryContext | null; + + constructor() { + this.init(); + } + + init() { + this.hookListsMap = new WeakMap(); + this.mountedDecorators = new Set(); + this.prevMountedDecorators = this.mountedDecorators; + this.currentHooks = []; + this.nextHookIndex = 0; + this.currentPhase = 'NONE'; + this.currentEffects = []; + this.prevEffects = []; + this.currentDecoratorName = null; + this.hasUpdates = false; + this.currentContext = null; + } + + clean() { + this.prevEffects.forEach(effect => { + if (effect.destroy) { + effect.destroy(); + } + }); + this.init(); + } + + getNextHook() { + const hook = this.currentHooks[this.nextHookIndex]; + this.nextHookIndex += 1; + return hook; + } + + triggerEffects() { + // destroy removed effects + this.prevEffects.forEach(effect => { + if (!this.currentEffects.includes(effect) && effect.destroy) { + effect.destroy(); + } + }); + // trigger added effects + this.currentEffects.forEach(effect => { + if (!this.prevEffects.includes(effect)) { + // eslint-disable-next-line no-param-reassign + effect.destroy = effect.create(); + } + }); + this.prevEffects = this.currentEffects; + this.currentEffects = []; + } +} + +const hookify = (fn: AbstractFunction) => (...args: any[]) => { + const { hooks }: StoryContext = typeof args[0] === 'function' ? args[1] : args[0]; + + const prevPhase = hooks.currentPhase; + const prevHooks = hooks.currentHooks; + const prevNextHookIndex = hooks.nextHookIndex; + const prevDecoratorName = hooks.currentDecoratorName; + + hooks.currentDecoratorName = fn.name; + if (hooks.prevMountedDecorators.has(fn)) { + hooks.currentPhase = 'UPDATE'; + hooks.currentHooks = hooks.hookListsMap.get(fn) || []; + } else { + hooks.currentPhase = 'MOUNT'; + hooks.currentHooks = []; + hooks.hookListsMap.set(fn, hooks.currentHooks); + } + hooks.nextHookIndex = 0; + + const prevContext = window.STORYBOOK_HOOKS_CONTEXT; + window.STORYBOOK_HOOKS_CONTEXT = hooks; + const result = fn(...args); + window.STORYBOOK_HOOKS_CONTEXT = prevContext; + + if (hooks.currentPhase === 'UPDATE' && hooks.getNextHook() != null) { + throw new Error( + 'Rendered fewer hooks than expected. This may be caused by an accidental early return statement.' + ); + } + + hooks.currentPhase = prevPhase; + hooks.currentHooks = prevHooks; + hooks.nextHookIndex = prevNextHookIndex; + hooks.currentDecoratorName = prevDecoratorName; + return result; +}; + +// Counter to prevent infinite loops. +let numberOfRenders = 0; +const RENDER_LIMIT = 25; +export const applyHooks = ( + applyDecorators: (getStory: StoryGetter, decorators: Decorator[]) => StoryGetter +) => (getStory: StoryGetter, decorators: Decorator[]) => { + const decorated = applyDecorators(hookify(getStory), decorators.map(hookify)); + return (context: StoryContext) => { + const { hooks } = context; + hooks.prevMountedDecorators = hooks.mountedDecorators; + hooks.mountedDecorators = new Set([getStory, ...decorators]); + hooks.currentContext = context; + hooks.hasUpdates = false; + let result = decorated(context); + numberOfRenders = 1; + while (hooks.hasUpdates) { + hooks.hasUpdates = false; + hooks.currentEffects = []; + hooks.prevMountedDecorators = hooks.mountedDecorators; + result = decorated(context); + numberOfRenders += 1; + if (numberOfRenders > RENDER_LIMIT) { + throw new Error( + 'Too many re-renders. Storybook limits the number of renders to prevent an infinite loop.' + ); + } + } + addons.getChannel().once(STORY_RENDERED, () => { + hooks.triggerEffects(); + hooks.currentContext = null; + }); + return result; + }; +}; + +const areDepsEqual = (deps: any[], nextDeps: any[]) => + deps.length === nextDeps.length && deps.every((dep, i) => dep === nextDeps[i]); + +const invalidHooksError = () => + new Error('Storybook preview hooks can only be called inside decorators and story functions.'); + +function getHooksContextOrNull(): HooksContext | null { + return window.STORYBOOK_HOOKS_CONTEXT || null; +} + +function getHooksContextOrThrow(): HooksContext { + const hooks = getHooksContextOrNull(); + if (hooks == null) { + throw invalidHooksError(); + } + return hooks; +} + +function useHook(name: string, callback: (hook: Hook) => void, deps?: any[] | undefined): Hook { + const hooks = getHooksContextOrThrow(); + if (hooks.currentPhase === 'MOUNT') { + if (deps != null && !Array.isArray(deps)) { + logger.warn( + `${name} received a final argument that is not an array (instead, received ${deps}). When specified, the final argument must be an array.` + ); + } + const hook: Hook = { name, deps }; + hooks.currentHooks.push(hook); + callback(hook); + return hook; + } + + if (hooks.currentPhase === 'UPDATE') { + const hook = hooks.getNextHook(); + if (hook == null) { + throw new Error('Rendered more hooks than during the previous render.'); + } + + if (hook.name !== name) { + logger.warn( + `Storybook has detected a change in the order of Hooks${ + hooks.currentDecoratorName ? ` called by ${hooks.currentDecoratorName}` : '' + }. This will lead to bugs and errors if not fixed.` + ); + } + + if (deps != null && hook.deps == null) { + logger.warn( + `${name} received a final argument during this render, but not during the previous render. Even though the final argument is optional, its type cannot change between renders.` + ); + } + + if (deps != null && hook.deps != null && deps.length !== hook.deps.length) { + logger.warn(`The final argument passed to ${name} changed size between renders. The order and size of this array must remain constant. +Previous: ${hook.deps} +Incoming: ${deps}`); + } + + if (deps == null || hook.deps == null || !areDepsEqual(deps, hook.deps)) { + callback(hook); + hook.deps = deps; + } + return hook; + } + + throw invalidHooksError(); +} + +function useMemoLike(name: string, nextCreate: () => T, deps: any[] | undefined): T { + const { memoizedState } = useHook( + name, + hook => { + // eslint-disable-next-line no-param-reassign + hook.memoizedState = nextCreate(); + }, + deps + ); + return memoizedState; +} + +/* Returns a memoized value, see https://reactjs.org/docs/hooks-reference.html#usememo */ +export function useMemo(nextCreate: () => T, deps?: any[]): T { + return useMemoLike('useMemo', nextCreate, deps); +} + +/* Returns a memoized callback, see https://reactjs.org/docs/hooks-reference.html#usecallback */ +export function useCallback(callback: T, deps?: any[]): T { + return useMemoLike('useCallback', () => callback, deps); +} + +function useRefLike(name: string, initialValue: T): { current: T } { + return useMemoLike(name, () => ({ current: initialValue }), []); +} + +/* Returns a mutable ref object, see https://reactjs.org/docs/hooks-reference.html#useref */ +export function useRef(initialValue: T): { current: T } { + return useRefLike('useRef', initialValue); +} + +function triggerUpdate() { + const hooks = getHooksContextOrNull(); + // Rerun getStory if updates were triggered synchronously, force rerender otherwise + if (hooks != null && hooks.currentPhase !== 'NONE') { + hooks.hasUpdates = true; + } else { + try { + addons.getChannel().emit(FORCE_RE_RENDER); + } catch (e) { + logger.warn('State updates of Storybook preview hooks work only in browser'); + } + } +} + +function useStateLike( + name: string, + initialState: (() => S) | S +): [S, (update: ((prevState: S) => S) | S) => void] { + const stateRef = useRefLike( + name, + // @ts-ignore S type should never be function, but there's no way to tell that to TypeScript + typeof initialState === 'function' ? initialState() : initialState + ); + const setState = (update: ((prevState: S) => S) | S) => { + // @ts-ignore S type should never be function, but there's no way to tell that to TypeScript + stateRef.current = typeof update === 'function' ? update(stateRef.current) : update; + triggerUpdate(); + }; + return [stateRef.current, setState]; +} + +/* Returns a stateful value, and a function to update it, see https://reactjs.org/docs/hooks-reference.html#usestate */ +export function useState( + initialState: (() => S) | S +): [S, (update: ((prevState: S) => S) | S) => void] { + return useStateLike('useState', initialState); +} + +/* A redux-like alternative to useState, see https://reactjs.org/docs/hooks-reference.html#usereducer */ +export function useReducer( + reducer: (state: S, action: A) => S, + initialState: S +): [S, (action: A) => void]; +export function useReducer( + reducer: (state: S, action: A) => S, + initialArg: I, + init: (initialArg: I) => S +): [S, (action: A) => void]; +export function useReducer( + reducer: (state: S, action: A) => S, + initialArg: any, + init?: any +): [S, (action: A) => void] { + const initialState: (() => S) | S = init != null ? () => init(initialArg) : initialArg; + const [state, setState] = useStateLike('useReducer', initialState); + const dispatch = (action: A) => setState(prevState => reducer(prevState, action)); + return [state, dispatch]; +} + +/* + Triggers a side effect, see https://reactjs.org/docs/hooks-reference.html#usestate + Effects are triggered synchronously after rendering the story +*/ +export function useEffect(create: () => (() => void) | void, deps?: any[]): void { + const hooks = getHooksContextOrThrow(); + const effect = useMemoLike('useEffect', () => ({ create }), deps); + hooks.currentEffects.push(effect); +} + +export interface Listener { + (...args: any[]): void; + ignorePeer?: boolean; +} + +export interface EventMap { + [eventId: string]: Listener; +} + +/* Accepts a map of Storybook channel event listeners, returns an emit function */ +export function useChannel(eventMap: EventMap, deps: any[] = []) { + const channel = addons.getChannel(); + useEffect(() => { + Object.entries(eventMap).forEach(([type, listener]) => channel.on(type, listener)); + return () => { + Object.entries(eventMap).forEach(([type, listener]) => + channel.removeListener(type, listener) + ); + }; + }, [...Object.keys(eventMap), ...deps]); + + return channel.emit.bind(channel); +} + +/* Returns current story context */ +export function useStoryContext(): StoryContext { + const { currentContext } = getHooksContextOrThrow(); + if (currentContext == null) { + throw invalidHooksError(); + } + + return currentContext; +} + +/* Returns current value of a story parameter */ +export function useParameter(parameterKey: string, defaultValue?: S): S | undefined { + const { parameters } = useStoryContext(); + if (parameterKey) { + return parameters[parameterKey] || (defaultValue as S); + } + return undefined; +} diff --git a/lib/addons/src/public_api.ts b/lib/addons/src/public_api.ts index 8a3b7347b610..7639be2ff7b8 100644 --- a/lib/addons/src/public_api.ts +++ b/lib/addons/src/public_api.ts @@ -9,4 +9,6 @@ export * from './make-decorator'; export * from './index'; export * from './types'; export * from './storybook-channel-mock'; +export * from './hooks'; + export default addons; diff --git a/lib/addons/src/types.ts b/lib/addons/src/types.ts index 2b9cddea8181..35b7b2ba6981 100644 --- a/lib/addons/src/types.ts +++ b/lib/addons/src/types.ts @@ -1,3 +1,4 @@ +import { HooksContext } from './hooks'; import { Addon } from './index'; export enum types { @@ -26,6 +27,7 @@ export interface StoryContext { kind: string; [key: string]: any; parameters: Parameters; + hooks?: HooksContext; } export interface WrapperSettings { diff --git a/lib/client-api/src/hooks.test.js b/lib/client-api/src/hooks.test.js index 0410752f1d24..9a073616bbea 100644 --- a/lib/client-api/src/hooks.test.js +++ b/lib/client-api/src/hooks.test.js @@ -1,4 +1,5 @@ import { FORCE_RE_RENDER, STORY_RENDERED } from '@storybook/core-events'; +import addons from '@storybook/addons'; import { defaultDecorateStory } from './client_api'; import { applyHooks, @@ -11,6 +12,7 @@ import { useChannel, useParameter, useStoryContext, + HooksContext, } from './hooks'; jest.mock('@storybook/client-logger', () => ({ @@ -18,6 +20,7 @@ jest.mock('@storybook/client-logger', () => ({ })); let mockChannel; +let hooks; beforeEach(() => { mockChannel = { emit: jest.fn(), @@ -29,15 +32,14 @@ beforeEach(() => { }, removeListener: jest.fn(), }; + hooks = new HooksContext(); + addons.setChannel(mockChannel); }); -jest.mock('@storybook/addons', () => ({ - getChannel: () => mockChannel, -})); - const decorateStory = applyHooks(defaultDecorateStory); -const run = (storyFn, decorators = [], context) => decorateStory(storyFn, decorators)(context); +const run = (storyFn, decorators = [], context) => + decorateStory(storyFn, decorators)({ ...context, hooks }); describe('Preview hooks', () => { describe('useEffect', () => { @@ -193,7 +195,7 @@ describe('Preview hooks', () => { const context = {}; run( () => { - expect(useStoryContext()).toBe(context); + expect(useStoryContext()).toEqual({ ...context, hooks }); }, [], context diff --git a/lib/client-api/src/hooks.ts b/lib/client-api/src/hooks.ts index 295503d10c64..3017db5d2af4 100644 --- a/lib/client-api/src/hooks.ts +++ b/lib/client-api/src/hooks.ts @@ -1,329 +1,27 @@ -import { logger } from '@storybook/client-logger'; -import addons, { StoryGetter, StoryContext } from '@storybook/addons'; -import { FORCE_RE_RENDER, STORY_RENDERED } from '@storybook/core-events'; - -interface StoryStore { - fromId: ( - id: string - ) => { - parameters: { - [parameterKey: string]: any; - }; - }; - getSelection: () => { - storyId: string; - viewMode: string; - }; -} - -interface Hook { - name: string; - memoizedState?: any; - deps?: any[] | undefined; -} - -interface Effect { - create: () => (() => void) | void; - destroy?: (() => void) | void; -} - -type Decorator = (getStory: StoryGetter, context: StoryContext) => any; -type AbstractFunction = (...args: any[]) => any; - -const hookListsMap = new WeakMap(); -let mountedDecorators = new Set(); -let prevMountedDecorators = mountedDecorators; - -let currentHooks: Hook[] = []; -let nextHookIndex = 0; -const getNextHook = () => { - const hook = currentHooks[nextHookIndex]; - nextHookIndex += 1; - return hook; +import { + HooksContext, + applyHooks, + useMemo, + useCallback, + useRef, + useState, + useReducer, + useEffect, + useChannel, + useStoryContext, + useParameter, +} from '@storybook/addons'; + +export { + HooksContext, + applyHooks, + useMemo, + useCallback, + useRef, + useState, + useReducer, + useEffect, + useChannel, + useStoryContext, + useParameter, }; -let currentPhase: 'MOUNT' | 'UPDATE' | 'NONE' = 'NONE'; -let currentEffects: Effect[] = []; -let prevEffects: Effect[] = []; -let currentDecoratorName: string | null = null; -let hasUpdates = false; -let currentContext: StoryContext | null = null; - -const triggerEffects = () => { - // destroy removed effects - prevEffects.forEach(effect => { - if (!currentEffects.includes(effect) && effect.destroy) { - effect.destroy(); - } - }); - // trigger added effects - currentEffects.forEach(effect => { - if (!prevEffects.includes(effect)) { - // eslint-disable-next-line no-param-reassign - effect.destroy = effect.create(); - } - }); - prevEffects = currentEffects; - currentEffects = []; -}; - -const hookify = (fn: AbstractFunction) => (...args: any[]) => { - const prevPhase = currentPhase; - const prevHooks = currentHooks; - const prevNextHookIndex = nextHookIndex; - const prevDecoratorName = currentDecoratorName; - - currentDecoratorName = fn.name; - if (prevMountedDecorators.has(fn)) { - currentPhase = 'UPDATE'; - currentHooks = hookListsMap.get(fn) || []; - } else { - currentPhase = 'MOUNT'; - currentHooks = []; - hookListsMap.set(fn, currentHooks); - } - nextHookIndex = 0; - - const result = fn(...args); - - if (currentPhase === 'UPDATE' && getNextHook() != null) { - throw new Error( - 'Rendered fewer hooks than expected. This may be caused by an accidental early return statement.' - ); - } - - currentPhase = prevPhase; - currentHooks = prevHooks; - nextHookIndex = prevNextHookIndex; - currentDecoratorName = prevDecoratorName; - return result; -}; - -// Counter to prevent infinite loops. -let numberOfRenders = 0; -const RENDER_LIMIT = 25; -export const applyHooks = ( - applyDecorators: (getStory: StoryGetter, decorators: Decorator[]) => StoryGetter -) => (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); - numberOfRenders = 1; - while (hasUpdates) { - hasUpdates = false; - currentEffects = []; - prevMountedDecorators = mountedDecorators; - result = decorated(context); - numberOfRenders += 1; - if (numberOfRenders > RENDER_LIMIT) { - throw new Error( - 'Too many re-renders. Storybook limits the number of renders to prevent an infinite loop.' - ); - } - } - addons.getChannel().once(STORY_RENDERED, () => { - triggerEffects(); - currentContext = null; - }); - return result; - }; -}; - -const areDepsEqual = (deps: any[], nextDeps: any[]) => - deps.length === nextDeps.length && deps.every((dep, i) => dep === nextDeps[i]); - -function useHook(name: string, callback: (hook: Hook) => void, deps?: any[] | undefined): Hook { - if (currentPhase === 'MOUNT') { - if (deps != null && !Array.isArray(deps)) { - logger.warn( - `${name} received a final argument that is not an array (instead, received ${deps}). When specified, the final argument must be an array.` - ); - } - const hook: Hook = { name, deps }; - currentHooks.push(hook); - callback(hook); - return hook; - } - - if (currentPhase === 'UPDATE') { - const hook = getNextHook(); - if (hook == null) { - throw new Error('Rendered more hooks than during the previous render.'); - } - - if (hook.name !== name) { - logger.warn( - `Storybook has detected a change in the order of Hooks${ - currentDecoratorName ? ` called by ${currentDecoratorName}` : '' - }. This will lead to bugs and errors if not fixed.` - ); - } - - if (deps != null && hook.deps == null) { - logger.warn( - `${name} received a final argument during this render, but not during the previous render. Even though the final argument is optional, its type cannot change between renders.` - ); - } - - if (deps != null && hook.deps != null && deps.length !== hook.deps.length) { - logger.warn(`The final argument passed to ${name} changed size between renders. The order and size of this array must remain constant. -Previous: ${hook.deps} -Incoming: ${deps}`); - } - - if (deps == null || hook.deps == null || !areDepsEqual(deps, hook.deps)) { - callback(hook); - hook.deps = deps; - } - return hook; - } - - throw new Error( - 'Storybook preview hooks can only be called inside decorators and story functions.' - ); -} - -function useMemoLike(name: string, nextCreate: () => T, deps: any[] | undefined): T { - const { memoizedState } = useHook( - name, - hook => { - // eslint-disable-next-line no-param-reassign - hook.memoizedState = nextCreate(); - }, - deps - ); - return memoizedState; -} - -/* Returns a memoized value, see https://reactjs.org/docs/hooks-reference.html#usememo */ -export function useMemo(nextCreate: () => T, deps?: any[]): T { - return useMemoLike('useMemo', nextCreate, deps); -} - -/* Returns a memoized callback, see https://reactjs.org/docs/hooks-reference.html#usecallback */ -export function useCallback(callback: T, deps?: any[]): T { - return useMemoLike('useCallback', () => callback, deps); -} - -function useRefLike(name: string, initialValue: T): { current: T } { - return useMemoLike(name, () => ({ current: initialValue }), []); -} - -/* Returns a mutable ref object, see https://reactjs.org/docs/hooks-reference.html#useref */ -export function useRef(initialValue: T): { current: T } { - return useRefLike('useRef', initialValue); -} - -function triggerUpdate() { - // Rerun getStory if updates were triggered synchronously, force rerender otherwise - if (currentPhase !== 'NONE') { - hasUpdates = true; - } else { - try { - addons.getChannel().emit(FORCE_RE_RENDER); - } catch (e) { - logger.warn('State updates of Storybook preview hooks work only in browser'); - } - } -} - -function useStateLike( - name: string, - initialState: (() => S) | S -): [S, (update: ((prevState: S) => S) | S) => void] { - const stateRef = useRefLike( - name, - // @ts-ignore S type should never be function, but there's no way to tell that to TypeScript - typeof initialState === 'function' ? initialState() : initialState - ); - const setState = (update: ((prevState: S) => S) | S) => { - // @ts-ignore S type should never be function, but there's no way to tell that to TypeScript - stateRef.current = typeof update === 'function' ? update(stateRef.current) : update; - triggerUpdate(); - }; - return [stateRef.current, setState]; -} - -/* Returns a stateful value, and a function to update it, see https://reactjs.org/docs/hooks-reference.html#usestate */ -export function useState( - initialState: (() => S) | S -): [S, (update: ((prevState: S) => S) | S) => void] { - return useStateLike('useState', initialState); -} - -/* A redux-like alternative to useState, see https://reactjs.org/docs/hooks-reference.html#usereducer */ -export function useReducer( - reducer: (state: S, action: A) => S, - initialState: S -): [S, (action: A) => void]; -export function useReducer( - reducer: (state: S, action: A) => S, - initialArg: I, - init: (initialArg: I) => S -): [S, (action: A) => void]; -export function useReducer( - reducer: (state: S, action: A) => S, - initialArg: any, - init?: any -): [S, (action: A) => void] { - const initialState: (() => S) | S = init != null ? () => init(initialArg) : initialArg; - const [state, setState] = useStateLike('useReducer', initialState); - const dispatch = (action: A) => setState(prevState => reducer(prevState, action)); - return [state, dispatch]; -} - -/* - Triggers a side effect, see https://reactjs.org/docs/hooks-reference.html#usestate - Effects are triggered synchronously after rendering the story -*/ -export function useEffect(create: () => (() => void) | void, deps?: any[]): void { - const effect = useMemoLike('useEffect', () => ({ create }), deps); - currentEffects.push(effect); -} - -export interface Listener { - (...args: any[]): void; - ignorePeer?: boolean; -} - -export interface EventMap { - [eventId: string]: Listener; -} - -/* Accepts a map of Storybook channel event listeners, returns an emit function */ -export function useChannel(eventMap: EventMap, deps: any[] = []) { - const channel = addons.getChannel(); - useEffect(() => { - Object.entries(eventMap).forEach(([type, listener]) => channel.on(type, listener)); - return () => { - Object.entries(eventMap).forEach(([type, listener]) => - channel.removeListener(type, listener) - ); - }; - }, [...Object.keys(eventMap), ...deps]); - - return channel.emit.bind(channel); -} - -/* Returns current story context */ -export function useStoryContext(): StoryContext { - if (currentContext == null) { - throw new Error( - 'Storybook preview hooks can only be called inside decorators and story functions.' - ); - } - - return currentContext; -} - -/* Returns current value of a story parameter */ -export function useParameter(parameterKey: string, defaultValue?: S): S | undefined { - const { parameters } = useStoryContext(); - if (parameterKey) { - return parameters[parameterKey] || (defaultValue as S); - } - return undefined; -} diff --git a/lib/client-api/src/story_store.test.ts b/lib/client-api/src/story_store.test.ts index 118acee1d9d9..51b913b8e7db 100644 --- a/lib/client-api/src/story_store.test.ts +++ b/lib/client-api/src/story_store.test.ts @@ -158,12 +158,14 @@ describe('preview.story_store', () => { const storyWithContext = store.getStoryWithContext('kind', 'name'); storyWithContext(); + const { hooks } = store.fromId(toId('kind', 'name')); expect(storyFn).toHaveBeenCalledWith({ id: 'kind--name', name: 'name', kind: 'kind', story: 'name', parameters, + hooks, }); }); }); diff --git a/lib/client-api/src/story_store.ts b/lib/client-api/src/story_store.ts index b00994d0ab37..80aaa37d5aea 100644 --- a/lib/client-api/src/story_store.ts +++ b/lib/client-api/src/story_store.ts @@ -17,6 +17,7 @@ import { StoreItem, ErrorLike, } from './types'; +import { HooksContext } from './hooks'; // TODO: these are copies from components/nav/lib // refactor to DRY @@ -205,16 +206,20 @@ export default class StoryStore extends EventEmitter { applyDecorators(getOriginal(), getDecorators()) ); + const hooks = new HooksContext(); + const storyFn: StoryFn = p => getDecorated()({ ...identification, ...p, + hooks, parameters: { ...parameters, ...(p && p.parameters) }, }); _data[id] = { ...identification, + hooks, getDecorated, getOriginal, storyFn, @@ -299,6 +304,10 @@ export default class StoryStore extends EventEmitter { .map(info => info.name); } + getStoriesForKind(kind: string) { + return this.raw().filter(story => story.kind === kind); + } + getStoryFileName(kind: string) { const key = toKey(kind); const storiesKind = this._legacydata[key as string]; @@ -383,4 +392,12 @@ export default class StoryStore extends EventEmitter { clean() { this.getStoryKinds().forEach(kind => delete this._legacydata[toKey(kind) as string]); } + + cleanHooks(id: string) { + this._data[id].hooks.clean(); + } + + cleanHooksForKind(kind: string) { + this.getStoriesForKind(kind).map(story => this.cleanHooks(story.id)); + } } diff --git a/lib/client-api/src/types.ts b/lib/client-api/src/types.ts index 06d405628c1d..d509750fb6c6 100644 --- a/lib/client-api/src/types.ts +++ b/lib/client-api/src/types.ts @@ -7,6 +7,7 @@ import { DecoratorFunction, } from '@storybook/addons'; import StoryStore from './story_store'; +import { HooksContext } from './hooks'; export interface ErrorLike { message: string; @@ -18,6 +19,7 @@ export interface StoreItem extends StoryContext { getOriginal: () => StoryFn; story: string; storyFn: StoryFn; + hooks: HooksContext; } export interface StoreData { diff --git a/lib/core/src/client/preview/start.js b/lib/core/src/client/preview/start.js index 5fa69e7df0f7..e07dc0fbe803 100644 --- a/lib/core/src/client/preview/start.js +++ b/lib/core/src/client/preview/start.js @@ -143,6 +143,7 @@ export default function start(render, { decorateStory } = {}) { let previousStory = ''; let previousRevision = -1; let previousViewMode = ''; + let previousId = null; const renderMain = forceRender => { const revision = storyStore.getRevision(); @@ -193,6 +194,19 @@ export default function start(render, { decorateStory } = {}) { addons.getChannel().emit(Events.STORY_CHANGED, id); } + switch (previousViewMode) { + case 'docs': + if (previousKind != null && (kind !== previousKind || viewMode !== previousViewMode)) { + storyStore.cleanHooksForKind(previousKind); + } + break; + case 'story': + default: + if (previousId != null && (id !== previousId || viewMode !== previousViewMode)) { + storyStore.cleanHooks(previousId); + } + } + // Docs view renders into a different root ID to avoid conflicts // with the user's view layer. Therefore we need to clean up whenever // we transition between view modes @@ -248,6 +262,7 @@ export default function start(render, { decorateStory } = {}) { previousKind = kind; previousStory = name; previousViewMode = viewMode; + previousId = id; if (!forceRender) { document.documentElement.scrollTop = 0;