mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-09 00:19:13 +08:00
435 lines
13 KiB
TypeScript
435 lines
13 KiB
TypeScript
import window from 'global';
|
|
import { logger } from '@storybook/client-logger';
|
|
import {
|
|
FORCE_RE_RENDER,
|
|
STORY_RENDERED,
|
|
DOCS_RENDERED,
|
|
UPDATE_STORY_ARGS,
|
|
RESET_STORY_ARGS,
|
|
UPDATE_GLOBALS,
|
|
} from '@storybook/core-events';
|
|
import { addons } from './index';
|
|
import { StoryGetter, StoryContext, Args } from './types';
|
|
|
|
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 RenderEvents = [STORY_RENDERED, DOCS_RENDERED];
|
|
|
|
export class HooksContext {
|
|
hookListsMap: WeakMap<AbstractFunction, Hook[]>;
|
|
|
|
mountedDecorators: Set<AbstractFunction>;
|
|
|
|
prevMountedDecorators: Set<AbstractFunction>;
|
|
|
|
currentHooks: Hook[];
|
|
|
|
nextHookIndex: number;
|
|
|
|
currentPhase: 'MOUNT' | 'UPDATE' | 'NONE';
|
|
|
|
currentEffects: Effect[];
|
|
|
|
prevEffects: Effect[];
|
|
|
|
currentDecoratorName: string | null;
|
|
|
|
hasUpdates: boolean;
|
|
|
|
currentContext: StoryContext | null;
|
|
|
|
renderListener = () => {
|
|
this.triggerEffects();
|
|
this.currentContext = null;
|
|
this.removeRenderListeners();
|
|
};
|
|
|
|
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();
|
|
this.removeRenderListeners();
|
|
}
|
|
|
|
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 = [];
|
|
}
|
|
|
|
addRenderListeners() {
|
|
this.removeRenderListeners();
|
|
const channel = addons.getChannel();
|
|
RenderEvents.forEach((e) => channel.on(e, this.renderListener));
|
|
}
|
|
|
|
removeRenderListeners() {
|
|
const channel = addons.getChannel();
|
|
RenderEvents.forEach((e) => channel.removeListener(e, this.renderListener));
|
|
}
|
|
}
|
|
|
|
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.prevMountedDecorators.add(fn);
|
|
}
|
|
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 = [];
|
|
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.'
|
|
);
|
|
}
|
|
}
|
|
hooks.addRenderListeners();
|
|
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<T>(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<T>(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<T>(callback: T, deps?: any[]): T {
|
|
return useMemoLike('useCallback', () => callback, deps);
|
|
}
|
|
|
|
function useRefLike<T>(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<T>(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<S>(
|
|
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<S>(
|
|
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<S, A>(
|
|
reducer: (state: S, action: A) => S,
|
|
initialState: S
|
|
): [S, (action: A) => void];
|
|
export function useReducer<S, I, A>(
|
|
reducer: (state: S, action: A) => S,
|
|
initialArg: I,
|
|
init: (initialArg: I) => S
|
|
): [S, (action: A) => void];
|
|
export function useReducer<S, A>(
|
|
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);
|
|
if (!hooks.currentEffects.includes(effect)) {
|
|
hooks.currentEffects.push(effect);
|
|
}
|
|
}
|
|
|
|
export interface Listener {
|
|
(...args: any[]): void;
|
|
}
|
|
|
|
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 useCallback(channel.emit.bind(channel), [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<S>(parameterKey: string, defaultValue?: S): S | undefined {
|
|
const { parameters } = useStoryContext();
|
|
if (parameterKey) {
|
|
return parameters[parameterKey] || (defaultValue as S);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/* Returns current value of story args */
|
|
export function useArgs(): [Args, (newArgs: Args) => void, (argNames?: [string]) => void] {
|
|
const channel = addons.getChannel();
|
|
const { id: storyId, args } = useStoryContext();
|
|
|
|
const updateArgs = useCallback(
|
|
(updatedArgs: Args) => channel.emit(UPDATE_STORY_ARGS, { storyId, updatedArgs }),
|
|
[channel, storyId]
|
|
);
|
|
|
|
const resetArgs = useCallback(
|
|
(argNames?: [string]) => channel.emit(RESET_STORY_ARGS, { storyId, argNames }),
|
|
[channel, storyId]
|
|
);
|
|
|
|
return [args, updateArgs, resetArgs];
|
|
}
|
|
|
|
/* Returns current value of global args */
|
|
export function useGlobals(): [Args, (newGlobals: Args) => void] {
|
|
const channel = addons.getChannel();
|
|
const { globals } = useStoryContext();
|
|
|
|
const updateGlobals = useCallback(
|
|
(newGlobals: Args) => channel.emit(UPDATE_GLOBALS, { globals: newGlobals }),
|
|
[channel]
|
|
);
|
|
|
|
return [globals, updateGlobals];
|
|
}
|