From 2f8d6ae7bdccd2e7539abd0b9e24dd2e5daaba83 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 13 Nov 2020 10:54:03 +0100 Subject: [PATCH 01/11] Avoid rerendering App whenever SizeMe polls for a size change. --- lib/ui/src/app.tsx | 112 ++++++++++++++++++++++++--------------------- 1 file changed, 59 insertions(+), 53 deletions(-) diff --git a/lib/ui/src/app.tsx b/lib/ui/src/app.tsx index e017d011e9e..39a14e9ab8b 100644 --- a/lib/ui/src/app.tsx +++ b/lib/ui/src/app.tsx @@ -1,6 +1,6 @@ -import React, { FunctionComponent, useMemo } from 'react'; +import React, { ComponentProps, FunctionComponent, useMemo } from 'react'; import { Global, createGlobal, styled } from '@storybook/theming'; -import sizeMe from 'react-sizeme'; +import { SizeMe } from 'react-sizeme'; import { Route } from '@storybook/router'; @@ -26,65 +26,71 @@ export interface AppProps { docsOnly: boolean; layout: State['layout']; panelCount: number; - size: { - width: number; - height: number; - }; + width: number; + height: number; } -const App = React.memo( - ({ viewMode, docsOnly, layout, panelCount, size: { width, height } }) => { - let content; +const App = React.memo(({ viewMode, docsOnly, layout, panelCount, width, height }) => { + let content; - const props = useMemo( - () => ({ - Sidebar, - Preview, - Panel, - Notifications, - pages: [ - { - key: 'settings', - render: () => , - route: (({ children }) => ( - - {children} - - )) as FunctionComponent, - }, - ], - }), - [] - ); + const props = useMemo( + () => ({ + Sidebar, + Preview, + Panel, + Notifications, + pages: [ + { + key: 'settings', + render: () => , + route: (({ children }) => ( + + {children} + + )) as FunctionComponent, + }, + ], + }), + [] + ); - if (!width || !height) { - content =
; - } else if (width < 600) { - content = ; - } else { - content = ( - - ); - } - - return ( - - - {content} - + if (!width || !height) { + content =
; + } else if (width < 600) { + content = ; + } else { + content = ( + ); } -); -const SizedApp = sizeMe({ monitorHeight: true })(App); + return ( + + + {content} + + ); +}); App.displayName = 'App'; +const SizedApp: FunctionComponent, 'width' | 'height'>> = ( + props +) => ( + + {({ size }) => ( + // Don't pass size directly, because it's a new object each time. + + )} + +); + export default SizedApp; From 33fc7befaff32e33770455745a0382a3718d6be5 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 13 Nov 2020 16:14:12 +0100 Subject: [PATCH 02/11] Bit of a simplification. --- lib/api/src/modules/shortcuts.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/api/src/modules/shortcuts.ts b/lib/api/src/modules/shortcuts.ts index a8625fd2e3c..fb478354763 100644 --- a/lib/api/src/modules/shortcuts.ts +++ b/lib/api/src/modules/shortcuts.ts @@ -25,7 +25,7 @@ export interface SubAPI { restoreAllDefaultShortcuts(): Promise; restoreDefaultShortcut(action: Action): Promise; handleKeydownEvent(event: Event): void; - handleShortcutFeature(feature: Action, event?: KeyboardEvent): void; + handleShortcutFeature(feature: Action): void; } export type KeyCollection = string[]; @@ -122,11 +122,14 @@ export const init: ModuleFn = ({ store, fullAPI }) => { shortcutMatchesShortcut(shortcut, shortcuts[feature]) ); if (matchedFeature) { - api.handleShortcutFeature(matchedFeature, event); + // Event.prototype.preventDefault is missing when received from the MessageChannel. + if (event?.preventDefault) event.preventDefault(); + api.handleShortcutFeature(matchedFeature); } }, - handleShortcutFeature(feature, event) { + // warning: event might not have a full prototype chain because it may originate from the channel + handleShortcutFeature(feature) { const { layout: { isFullscreen, showNav, showPanel }, ui: { enableShortcuts }, @@ -205,13 +208,11 @@ export const init: ModuleFn = ({ store, fullAPI }) => { } case 'nextComponent': { - if (event) event.preventDefault(); fullAPI.jumpToComponent(1); break; } case 'prevComponent': { - if (event) event.preventDefault(); fullAPI.jumpToComponent(-1); break; } @@ -268,12 +269,10 @@ export const init: ModuleFn = ({ store, fullAPI }) => { break; } case 'collapseAll': { - if (event) event.preventDefault(); fullAPI.collapseAll(); break; } case 'expandAll': { - if (event) event.preventDefault(); fullAPI.expandAll(); break; } From 68333b6aa93656b14b2e9d8903d28f2c7d701dad Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 13 Nov 2020 16:18:02 +0100 Subject: [PATCH 03/11] Avoid returning a new object each time if no query params actually changed. --- lib/api/src/modules/url.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/api/src/modules/url.ts b/lib/api/src/modules/url.ts index 5cf6b86b55d..33707ffcf03 100644 --- a/lib/api/src/modules/url.ts +++ b/lib/api/src/modules/url.ts @@ -3,6 +3,7 @@ import { queryFromLocation } from '@storybook/router'; import { toId, sanitize } from '@storybook/csf'; import { NAVIGATE_URL } from '@storybook/core-events'; +import deepEqual from 'fast-deep-equal'; import { ModuleArgs, ModuleFn } from '../index'; import { PanelPositions } from './layout'; @@ -28,6 +29,7 @@ export interface SubState { // - nav: 0/1 -- show or hide the story list // // We also support legacy URLs from storybook <5 +let prevParams: ReturnType; const initialUrlSupport = ({ state: { location, path, viewMode, storyId: storyIdFromUrl }, }: ModuleArgs) => { @@ -46,7 +48,7 @@ const initialUrlSupport = ({ selectedKind, selectedStory, path: queryPath, - ...customQueryParams + ...otherParams } = query; if (full === '1') { @@ -89,6 +91,10 @@ const initialUrlSupport = ({ } } + // Avoid returning a new object each time if no params actually changed. + const customQueryParams = deepEqual(prevParams, otherParams) ? prevParams : otherParams; + prevParams = customQueryParams; + return { viewMode, layout: addition, selectedPanel, location, path, customQueryParams, storyId }; }; @@ -133,17 +139,17 @@ export const init: ModuleFn = ({ store, navigate, state, provider, fullAPI, ...r setQueryParams(input) { const { customQueryParams } = store.getState(); const queryParams: QueryParams = {}; - store.setState({ - customQueryParams: { - ...customQueryParams, - ...Object.entries(input).reduce((acc, [key, value]) => { - if (value !== null) { - acc[key] = value; - } - return acc; - }, queryParams), - }, - }); + const update = { + ...customQueryParams, + ...Object.entries(input).reduce((acc, [key, value]) => { + if (value !== null) { + acc[key] = value; + } + return acc; + }, queryParams), + }; + const equal = deepEqual(customQueryParams, update); + if (!equal) store.setState({ customQueryParams: update }); }, navigateUrl(url: string, options: NavigateOptions<{}>) { navigateRouter(url, options); From 21a648b8b30ea5e826ccb6195dd66aa11d2037eb Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 13 Nov 2020 16:19:20 +0100 Subject: [PATCH 04/11] Add React.memo and clean stuff up a bit. --- lib/ui/src/components/preview/preview.tsx | 4 +- lib/ui/src/components/preview/toolbar.tsx | 92 +++++++++-------------- 2 files changed, 39 insertions(+), 57 deletions(-) diff --git a/lib/ui/src/components/preview/preview.tsx b/lib/ui/src/components/preview/preview.tsx index e4af5249a74..3bdd3fbcbad 100644 --- a/lib/ui/src/components/preview/preview.tsx +++ b/lib/ui/src/components/preview/preview.tsx @@ -131,7 +131,7 @@ const useTabs = ( }, [story, canvas, ...tabsFromConfig]); }; -const Preview: FunctionComponent = (props) => { +const Preview = React.memo((props) => { const { api, id: previewId, @@ -195,7 +195,7 @@ const Preview: FunctionComponent = (props) => { ); -}; +}); export { Preview }; diff --git a/lib/ui/src/components/preview/toolbar.tsx b/lib/ui/src/components/preview/toolbar.tsx index 2b8aacd395b..24f33c7f311 100644 --- a/lib/ui/src/components/preview/toolbar.tsx +++ b/lib/ui/src/components/preview/toolbar.tsx @@ -111,32 +111,22 @@ const useTools = ( location: PreviewProps['location'], path: PreviewProps['path'] ) => { - const toolsFromConfig = useMemo(() => { - return getTools(getElements); - }, [getElements]); + const toolsFromConfig = useMemo(() => getTools(getElements), [getElements]); + const toolsExtraFromConfig = useMemo(() => getToolsExtra(getElements), [getElements]); - const toolsExtraFromConfig = useMemo(() => { - return getToolsExtra(getElements); - }, [getElements]); - - const tools = useMemo(() => { - return [...defaultTools, ...toolsFromConfig]; - }, [defaultTools, toolsFromConfig]); - - const toolsExtra = useMemo(() => { - return [...defaultToolsExtra, ...toolsExtraFromConfig]; - }, [defaultToolsExtra, toolsExtraFromConfig]); + const tools = useMemo(() => [...defaultTools, ...toolsFromConfig], [ + defaultTools, + toolsFromConfig, + ]); + const toolsExtra = useMemo(() => [...defaultToolsExtra, ...toolsExtraFromConfig], [ + defaultToolsExtra, + toolsExtraFromConfig, + ]); return useMemo(() => { - if (story && story.parameters) { - return filterTools(tools, toolsExtra, tabs, { - viewMode, - story, - location, - path, - }); - } - return { left: tools, right: toolsExtra }; + return story && story.parameters + ? filterTools(tools, toolsExtra, tabs, { viewMode, story, location, path }) + : { left: tools, right: toolsExtra }; }, [viewMode, story, location, path, tools, toolsExtra, tabs]); }; @@ -146,42 +136,34 @@ export interface ToolData { api: API; story: Story | Group; } -export const ToolRes: FunctionComponent = ({ - api, - story, - tabs, - isShown, - location, - path, - viewMode, -}) => { - const { left, right } = useTools(api.getElements, tabs, viewMode, story, location, path); - return left || right ? ( - - - - - ) : null; -}; +export const ToolRes = React.memo( + ({ api, story, tabs, isShown, location, path, viewMode }) => { + const { left, right } = useTools(api.getElements, tabs, viewMode, story, location, path); -export const ToolbarComp: FunctionComponent = (p) => ( - {(l) => } + return left || right ? ( + + + + + ) : null; + } ); -export const Tools: FunctionComponent<{ - list: Addon[]; -}> = ({ list }) => - list.filter(Boolean).reduce((acc, { render: Render, id, ...t }, index) => { - // @ts-ignore - const key = id || t.key || `f-${index}`; - return ( - - {acc} - - - ); - }, null as ReactElement); +export const ToolbarComp = React.memo((props) => ( + + {({ location, path, viewMode }) => } + +)); + +export const Tools = React.memo<{ list: Addon[] }>(({ list }) => ( + <> + {list.filter(Boolean).map(({ render: Render, id, ...t }, index) => ( + // @ts-ignore + + ))} + +)); export function filterTools( tools: Addon[], From bddd39d73c6ddb6cb618110d7fcc814ae548d82c Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 13 Nov 2020 16:34:17 +0100 Subject: [PATCH 05/11] Prevent rerendering needlessly. --- lib/ui/src/components/preview/tools/zoom.tsx | 72 ++++++++++++-------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/lib/ui/src/components/preview/tools/zoom.tsx b/lib/ui/src/components/preview/tools/zoom.tsx index 3c57a59bfc5..f03a6a4f451 100644 --- a/lib/ui/src/components/preview/tools/zoom.tsx +++ b/lib/ui/src/components/preview/tools/zoom.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, Component, FunctionComponent, SyntheticEvent } from 'react'; +import React, { Component, SyntheticEvent, useCallback, MouseEventHandler } from 'react'; import { Icons, IconButton, Separator } from '@storybook/components'; import { Addon } from '@storybook/addons'; @@ -28,46 +28,58 @@ class ZoomProvider extends Component<{ shouldScale: boolean }, { value: number } const { Consumer: ZoomConsumer } = Context; -const cancel = (e: SyntheticEvent) => { - e.preventDefault(); - return false; -}; - -const Zoom: FunctionComponent<{ set: Function; reset: Function }> = ({ set, reset }) => ( - - cancel(e) || set(0.8)} title="Zoom in"> +const Zoom = React.memo<{ + zoomIn: MouseEventHandler; + zoomOut: MouseEventHandler; + reset: MouseEventHandler; +}>(({ zoomIn, zoomOut, reset }) => ( + <> + - cancel(e) || set(1.25)} - title="Zoom out" - > + - cancel(e) || reset()} - title="Reset zoom" - > + - -); + +)); export { Zoom, ZoomConsumer, ZoomProvider }; +const ZoomWrapper = React.memo<{ set: Function; value: number }>(({ set, value }) => { + const zoomIn = useCallback( + (e: SyntheticEvent) => { + e.preventDefault(); + set(0.8 * value); + }, + [set, value] + ); + const zoomOut = useCallback( + (e: SyntheticEvent) => { + e.preventDefault(); + set(1.25 * value); + }, + [set, value] + ); + const reset = useCallback( + (e) => { + e.preventDefault(); + set(initialZoom); + }, + [set, initialZoom] + ); + return ; +}); + export const zoomTool: Addon = { title: 'zoom', match: ({ viewMode }) => viewMode === 'story', - render: () => ( - - - {({ set, value }) => ( - set(value * v)} reset={() => set(initialZoom)} /> - )} - + render: React.memo(() => ( + <> + {({ set, value }) => } - - ), + + )), }; From d9214e4892fa8f8e4b865227957a05a2eef9d103 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 13 Nov 2020 16:36:10 +0100 Subject: [PATCH 06/11] Avoid rerendering search all the time. --- lib/ui/src/components/sidebar/Search.tsx | 423 +++++++++--------- lib/ui/src/components/sidebar/Sidebar.tsx | 4 +- .../src/components/sidebar/useLastViewed.ts | 61 +-- 3 files changed, 247 insertions(+), 241 deletions(-) diff --git a/lib/ui/src/components/sidebar/Search.tsx b/lib/ui/src/components/sidebar/Search.tsx index fbce191550a..8eb7a6c22d1 100644 --- a/lib/ui/src/components/sidebar/Search.tsx +++ b/lib/ui/src/components/sidebar/Search.tsx @@ -5,7 +5,7 @@ import { styled } from '@storybook/theming'; import { Icons } from '@storybook/components'; import Downshift, { DownshiftState, StateChangeOptions } from 'downshift'; import Fuse, { FuseOptions } from 'fuse.js'; -import React, { useEffect, useMemo, useRef, useState, useCallback, FunctionComponent } from 'react'; +import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { DEFAULT_REF_ID } from './data'; import { @@ -140,238 +140,241 @@ const ClearIcon = styled(Icons)(({ theme }) => ({ const FocusContainer = styled.div({ outline: 0 }); -export const Search: FunctionComponent<{ +export const Search = React.memo<{ children: SearchChildrenFn; dataset: CombinedDataset; isLoading?: boolean; enableShortcuts?: boolean; - lastViewed: Selection[]; + getLastViewed: () => Selection[]; clearLastViewed: () => void; initialQuery?: string; -}> = ({ - children, - dataset, - isLoading = false, - enableShortcuts = true, - lastViewed, - clearLastViewed, - initialQuery = '', -}) => { - const api = useStorybookApi(); - const inputRef = useRef(null); - const [inputPlaceholder, setPlaceholder] = useState('Find components'); - const [allComponents, showAllComponents] = useState(false); +}>( + ({ + children, + dataset, + isLoading = false, + enableShortcuts = true, + getLastViewed, + clearLastViewed, + initialQuery = '', + }) => { + const api = useStorybookApi(); + const inputRef = useRef(null); + const [inputPlaceholder, setPlaceholder] = useState('Find components'); + const [allComponents, showAllComponents] = useState(false); - const selectStory = useCallback( - (id: string, refId: string) => { - if (api) api.selectStory(id, undefined, { ref: refId !== DEFAULT_REF_ID && refId }); - inputRef.current.blur(); - showAllComponents(false); - }, - [api, inputRef, showAllComponents, DEFAULT_REF_ID] - ); + const selectStory = useCallback( + (id: string, refId: string) => { + if (api) api.selectStory(id, undefined, { ref: refId !== DEFAULT_REF_ID && refId }); + inputRef.current.blur(); + showAllComponents(false); + }, + [api, inputRef, showAllComponents, DEFAULT_REF_ID] + ); - useEffect(() => { - const focusSearch = (event: KeyboardEvent) => { - if (!enableShortcuts || isLoading || !inputRef.current) return; - if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return; - if (event.key === '/' && inputRef.current !== document.activeElement) { - inputRef.current.focus(); - event.preventDefault(); - } - }; - - // Keyup prevents slashes from ending up in the input field when held down - document.addEventListener('keyup', focusSearch); - return () => document.removeEventListener('keyup', focusSearch); - }, [inputRef, isLoading, enableShortcuts]); - - const list: SearchItem[] = useMemo(() => { - return dataset.entries.reduce((acc: SearchItem[], [refId, { stories }]) => { - if (stories) { - acc.push(...Object.values(stories).map((item) => searchItem(item, dataset.hash[refId]))); - } - return acc; - }, []); - }, [dataset]); - - const fuse = useMemo(() => new Fuse(list, options), [list]); - - const getResults = useCallback( - (input: string) => { - if (!input) return []; - - let results: DownshiftItem[] = []; - const componentResults = (fuse.search(input) as SearchResult[]).filter( - ({ item }) => item.isComponent - ); - - if (componentResults.length) { - results = componentResults.slice(0, allComponents ? 1000 : DEFAULT_MAX_SEARCH_RESULTS); - if (componentResults.length > DEFAULT_MAX_SEARCH_RESULTS && !allComponents) { - results.push({ - showAll: () => showAllComponents(true), - totalCount: componentResults.length, - moreCount: componentResults.length - DEFAULT_MAX_SEARCH_RESULTS, - }); + useEffect(() => { + const focusSearch = (event: KeyboardEvent) => { + if (!enableShortcuts || isLoading || !inputRef.current) return; + if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return; + if (event.key === '/' && inputRef.current !== document.activeElement) { + inputRef.current.focus(); + event.preventDefault(); } - } + }; - return results; - }, - [allComponents, fuse] - ); + // Keyup prevents slashes from ending up in the input field when held down + document.addEventListener('keyup', focusSearch); + return () => document.removeEventListener('keyup', focusSearch); + }, [inputRef, isLoading, enableShortcuts]); - const stateReducer = useCallback( - (state: DownshiftState, changes: StateChangeOptions) => { - switch (changes.type) { - case Downshift.stateChangeTypes.blurInput: { - return { - ...changes, - // Prevent clearing the input on blur - inputValue: state.inputValue, - // Return to the tree view after selecting an item - isOpen: state.inputValue && !state.selectedItem, - selectedItem: null, - }; + const list: SearchItem[] = useMemo(() => { + return dataset.entries.reduce((acc: SearchItem[], [refId, { stories }]) => { + if (stories) { + acc.push(...Object.values(stories).map((item) => searchItem(item, dataset.hash[refId]))); } + return acc; + }, []); + }, [dataset]); - case Downshift.stateChangeTypes.mouseUp: { - // Prevent clearing the input on refocus - return {}; - } + const fuse = useMemo(() => new Fuse(list, options), [list]); - case Downshift.stateChangeTypes.keyDownEscape: { - if (state.inputValue) { - // Clear the inputValue, but don't return to the tree view - return { ...changes, inputValue: '', isOpen: true, selectedItem: null }; + const getResults = useCallback( + (input: string) => { + if (!input) return []; + + let results: DownshiftItem[] = []; + const componentResults = (fuse.search(input) as SearchResult[]).filter( + ({ item }) => item.isComponent + ); + + if (componentResults.length) { + results = componentResults.slice(0, allComponents ? 1000 : DEFAULT_MAX_SEARCH_RESULTS); + if (componentResults.length > DEFAULT_MAX_SEARCH_RESULTS && !allComponents) { + results.push({ + showAll: () => showAllComponents(true), + totalCount: componentResults.length, + moreCount: componentResults.length - DEFAULT_MAX_SEARCH_RESULTS, + }); } - // When pressing escape a second time, blur the input and return to the tree view - inputRef.current.blur(); - return { ...changes, isOpen: false, selectedItem: null }; } - case Downshift.stateChangeTypes.clickItem: - case Downshift.stateChangeTypes.keyDownEnter: { - if (isSearchResult(changes.selectedItem)) { - const { id, refId } = changes.selectedItem.item; - selectStory(id, refId); - // Return to the tree view, but keep the input value - return { ...changes, inputValue: state.inputValue, isOpen: false }; + return results; + }, + [allComponents, fuse] + ); + + const stateReducer = useCallback( + (state: DownshiftState, changes: StateChangeOptions) => { + switch (changes.type) { + case Downshift.stateChangeTypes.blurInput: { + return { + ...changes, + // Prevent clearing the input on blur + inputValue: state.inputValue, + // Return to the tree view after selecting an item + isOpen: state.inputValue && !state.selectedItem, + selectedItem: null, + }; } - if (isExpandType(changes.selectedItem)) { - changes.selectedItem.showAll(); - // Downshift should completely ignore this + + case Downshift.stateChangeTypes.mouseUp: { + // Prevent clearing the input on refocus return {}; } - if (isClearType(changes.selectedItem)) { - changes.selectedItem.clearLastViewed(); - inputRef.current.blur(); - // Nothing to see anymore, so return to the tree view - return { isOpen: false }; - } - if (isCloseType(changes.selectedItem)) { - inputRef.current.blur(); - // Return to the tree view - return { isOpen: false }; - } - return changes; - } - case Downshift.stateChangeTypes.changeInput: { - // Reset the "show more" state whenever the input changes - showAllComponents(false); - return changes; - } - - default: - return changes; - } - }, - [inputRef, selectStory, showAllComponents] - ); - - return ( - - initialInputValue={initialQuery} - stateReducer={stateReducer} - // @ts-ignore - itemToString={(result) => result?.item?.name || ''} - > - {({ - isOpen, - openMenu, - closeMenu, - inputValue, - clearSelection, - getInputProps, - getItemProps, - getLabelProps, - getMenuProps, - getRootProps, - highlightedIndex, - }) => { - const input = inputValue ? inputValue.trim() : ''; - let results: DownshiftItem[] = input ? getResults(input) : []; - - if (!input && lastViewed && lastViewed.length) { - results = lastViewed.reduce((acc, { storyId, refId }) => { - const data = dataset.hash[refId]; - if (data && data.stories && data.stories[storyId]) { - const story = data.stories[storyId]; - const item = - story.isLeaf && !story.isComponent && !story.isRoot - ? data.stories[story.parent] - : story; - // prevent duplicates - if (!acc.some((res) => res.item.refId === refId && res.item.id === item.id)) { - acc.push({ item: searchItem(item, dataset.hash[refId]), matches: [], score: 0 }); - } + case Downshift.stateChangeTypes.keyDownEscape: { + if (state.inputValue) { + // Clear the inputValue, but don't return to the tree view + return { ...changes, inputValue: '', isOpen: true, selectedItem: null }; } - return acc; - }, []); - results.push({ closeMenu }); - if (results.length > 0) { - results.push({ clearLastViewed }); + // When pressing escape a second time, blur the input and return to the tree view + inputRef.current.blur(); + return { ...changes, isOpen: false, selectedItem: null }; } + + case Downshift.stateChangeTypes.clickItem: + case Downshift.stateChangeTypes.keyDownEnter: { + if (isSearchResult(changes.selectedItem)) { + const { id, refId } = changes.selectedItem.item; + selectStory(id, refId); + // Return to the tree view, but keep the input value + return { ...changes, inputValue: state.inputValue, isOpen: false }; + } + if (isExpandType(changes.selectedItem)) { + changes.selectedItem.showAll(); + // Downshift should completely ignore this + return {}; + } + if (isClearType(changes.selectedItem)) { + changes.selectedItem.clearLastViewed(); + inputRef.current.blur(); + // Nothing to see anymore, so return to the tree view + return { isOpen: false }; + } + if (isCloseType(changes.selectedItem)) { + inputRef.current.blur(); + // Return to the tree view + return { isOpen: false }; + } + return changes; + } + + case Downshift.stateChangeTypes.changeInput: { + // Reset the "show more" state whenever the input changes + showAllComponents(false); + return changes; + } + + default: + return changes; } + }, + [inputRef, selectStory, showAllComponents] + ); - const inputProps = getInputProps({ - id: 'storybook-explorer-searchfield', - ref: inputRef, - required: true, - type: 'search', - placeholder: inputPlaceholder, - onFocus: () => { - openMenu(); - setPlaceholder('Type to find...'); - }, - onBlur: () => setPlaceholder('Find components'), - }); + return ( + + initialInputValue={initialQuery} + stateReducer={stateReducer} + // @ts-ignore + itemToString={(result) => result?.item?.name || ''} + > + {({ + isOpen, + openMenu, + closeMenu, + inputValue, + clearSelection, + getInputProps, + getItemProps, + getLabelProps, + getMenuProps, + getRootProps, + highlightedIndex, + }) => { + const input = inputValue ? inputValue.trim() : ''; + let results: DownshiftItem[] = input ? getResults(input) : []; - return ( - <> - Search for components - - - - {enableShortcuts && /} - clearSelection()} /> - - - {children({ - query: input, - results, - isBrowsing: !isOpen && document.activeElement !== inputRef.current, - closeMenu, - getMenuProps, - getItemProps, - highlightedIndex, - })} - - - ); - }} - - ); -}; + const lastViewed = !input && getLastViewed(); + if (lastViewed && lastViewed.length) { + results = lastViewed.reduce((acc, { storyId, refId }) => { + const data = dataset.hash[refId]; + if (data && data.stories && data.stories[storyId]) { + const story = data.stories[storyId]; + const item = + story.isLeaf && !story.isComponent && !story.isRoot + ? data.stories[story.parent] + : story; + // prevent duplicates + if (!acc.some((res) => res.item.refId === refId && res.item.id === item.id)) { + acc.push({ item: searchItem(item, dataset.hash[refId]), matches: [], score: 0 }); + } + } + return acc; + }, []); + results.push({ closeMenu }); + if (results.length > 0) { + results.push({ clearLastViewed }); + } + } + + const inputProps = getInputProps({ + id: 'storybook-explorer-searchfield', + ref: inputRef, + required: true, + type: 'search', + placeholder: inputPlaceholder, + onFocus: () => { + openMenu(); + setPlaceholder('Type to find...'); + }, + onBlur: () => setPlaceholder('Find components'), + }); + + return ( + <> + Search for components + + + + {enableShortcuts && /} + clearSelection()} /> + + + {children({ + query: input, + results, + isBrowsing: !isOpen && document.activeElement !== inputRef.current, + closeMenu, + getMenuProps, + getItemProps, + highlightedIndex, + })} + + + ); + }} + + ); + } +); diff --git a/lib/ui/src/components/sidebar/Sidebar.tsx b/lib/ui/src/components/sidebar/Sidebar.tsx index 4ab1d646eaf..59c6a5a4a7c 100644 --- a/lib/ui/src/components/sidebar/Sidebar.tsx +++ b/lib/ui/src/components/sidebar/Sidebar.tsx @@ -105,7 +105,7 @@ export const Sidebar: FunctionComponent = React.memo( ); const dataset = useCombination(stories, storiesConfigured, storiesFailed, refs); const isLoading = !dataset.hash[DEFAULT_REF_ID].ready; - const lastViewed = useLastViewed(selected); + const lastViewedProps = useLastViewed(selected); return ( @@ -117,7 +117,7 @@ export const Sidebar: FunctionComponent = React.memo( dataset={dataset} isLoading={isLoading} enableShortcuts={enableShortcuts} - {...lastViewed} + {...lastViewedProps} > {({ query, diff --git a/lib/ui/src/components/sidebar/useLastViewed.ts b/lib/ui/src/components/sidebar/useLastViewed.ts index 6e1fa480833..aef8865fb33 100644 --- a/lib/ui/src/components/sidebar/useLastViewed.ts +++ b/lib/ui/src/components/sidebar/useLastViewed.ts @@ -1,44 +1,47 @@ -import { useCallback, useEffect, useState } from 'react'; +import debounce from 'lodash/debounce'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import store from 'store2'; import { Selection, StoryRef } from './types'; -const retrieveLastViewedStoryIds = (): StoryRef[] => { - const items = store.get('lastViewedStoryIds'); - if (!items || !Array.isArray(items)) return []; - if (!items.some((item) => typeof item === 'object' && item.storyId && item.refId)) return []; - return items; -}; +const save = debounce((value) => store.set('lastViewedStoryIds', value), 1000); export const useLastViewed = (selection: Selection) => { - const [lastViewed, setLastViewed] = useState(retrieveLastViewedStoryIds); + const initialLastViewedStoryIds = useMemo((): StoryRef[] => { + const items = store.get('lastViewedStoryIds'); + if (!items || !Array.isArray(items)) return []; + if (!items.some((item) => typeof item === 'object' && item.storyId && item.refId)) return []; + return items; + }, [store]); + + const lastViewedRef = useRef(initialLastViewedStoryIds); const updateLastViewed = useCallback( - (story: StoryRef) => - setLastViewed((state: StoryRef[]) => { - const index = state.findIndex( - ({ storyId, refId }) => storyId === story.storyId && refId === story.refId - ); - if (index === 0) return state; - const update = - index === -1 - ? [story, ...state] - : [story, ...state.slice(0, index), ...state.slice(index + 1)]; - store.set('lastViewedStoryIds', update); - return update; - }), - [] + (story: StoryRef) => { + const items = lastViewedRef.current; + const index = items.findIndex( + ({ storyId, refId }) => storyId === story.storyId && refId === story.refId + ); + if (index === 0) return; + if (index === -1) { + lastViewedRef.current = [story, ...items]; + } else { + lastViewedRef.current = [story, ...items.slice(0, index), ...items.slice(index + 1)]; + } + save(lastViewedRef.current); + }, + [lastViewedRef] ); - const clearLastViewed = useCallback(() => { - const update = selection ? [selection] : []; - setLastViewed(update); - store.set('lastViewedStoryIds', update); - }, [selection]); - useEffect(() => { if (selection) updateLastViewed(selection); }, [selection]); - return { lastViewed, clearLastViewed }; + return { + getLastViewed: useCallback(() => lastViewedRef.current, [lastViewedRef]), + clearLastViewed: useCallback(() => { + lastViewedRef.current = lastViewedRef.current.slice(0, 1); + save(lastViewedRef.current); + }, [lastViewedRef]), + }; }; From b4d61d78db5e087ba470acfa1ec0efa44065efd2 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 13 Nov 2020 16:36:41 +0100 Subject: [PATCH 07/11] Optimize tabbar. --- lib/ui/src/settings/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/ui/src/settings/index.tsx b/lib/ui/src/settings/index.tsx index a34b1792007..8bded453d77 100644 --- a/lib/ui/src/settings/index.tsx +++ b/lib/ui/src/settings/index.tsx @@ -8,13 +8,13 @@ import { AboutPage } from './about_page'; import { ReleaseNotesPage } from './release_notes_page'; import { ShortcutsPage } from './shortcuts_page'; -const TabBarButton: FunctionComponent<{ +const TabBarButton = React.memo<{ changeTab: (tab: string) => void; id: string; title: string; -}> = ({ changeTab, id, title }) => ( +}>(({ changeTab, id, title }) => ( - {({ navigate, path }) => { + {({ path }) => { const active = path.includes(`settings/${id}`); return ( -); +)); const Content = styled(ScrollArea)( { From 0b437e4722c6db23730b7810859184bf00fd934e Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 13 Nov 2020 19:25:16 +0100 Subject: [PATCH 08/11] Fix stories. --- lib/ui/src/components/sidebar/Search.stories.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/ui/src/components/sidebar/Search.stories.tsx b/lib/ui/src/components/sidebar/Search.stories.tsx index 2a5f19bb8e5..e455be783db 100644 --- a/lib/ui/src/components/sidebar/Search.stories.tsx +++ b/lib/ui/src/components/sidebar/Search.stories.tsx @@ -11,9 +11,10 @@ import { Selection } from './types'; const refId = DEFAULT_REF_ID; const data = { [refId]: { id: refId, url: '/', stories } }; const dataset = { hash: data, entries: Object.entries(data) }; -const lastViewed = Object.values(stories) - .filter((item, index) => item.isComponent && index % 20 === 0) - .map((component) => ({ storyId: component.id, refId })); +const getLastViewed = () => + Object.values(stories) + .filter((item, index) => item.isComponent && index % 20 === 0) + .map((component) => ({ storyId: component.id, refId })); export default { component: Search, @@ -25,7 +26,7 @@ export default { const baseProps = { dataset, clearLastViewed: action('clear'), - lastViewed: [] as Selection[], + getLastViewed: () => [] as Selection[], }; export const Simple = () => {() => null}; @@ -37,7 +38,7 @@ export const FilledIn = () => ( ); export const LastViewed = () => ( - + {({ query, results, closeMenu, getMenuProps, getItemProps, highlightedIndex }) => ( Date: Fri, 13 Nov 2020 19:27:23 +0100 Subject: [PATCH 09/11] Remove unused imports. --- lib/ui/src/components/preview/preview.tsx | 2 +- lib/ui/src/components/preview/toolbar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ui/src/components/preview/preview.tsx b/lib/ui/src/components/preview/preview.tsx index 3bdd3fbcbad..2188e987600 100644 --- a/lib/ui/src/components/preview/preview.tsx +++ b/lib/ui/src/components/preview/preview.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, FunctionComponent, useMemo, useEffect, useRef } from 'react'; +import React, { Fragment, useMemo, useEffect, useRef } from 'react'; import { Helmet } from 'react-helmet-async'; import merge from '@storybook/api/dist/lib/merge'; diff --git a/lib/ui/src/components/preview/toolbar.tsx b/lib/ui/src/components/preview/toolbar.tsx index 24f33c7f311..b9670c7053e 100644 --- a/lib/ui/src/components/preview/toolbar.tsx +++ b/lib/ui/src/components/preview/toolbar.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useMemo, FunctionComponent, ReactElement } from 'react'; +import React, { Fragment, useMemo, FunctionComponent } from 'react'; import { styled } from '@storybook/theming'; From bda166c6049f75e8aa6ed98238157624ed160a47 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 13 Nov 2020 19:53:43 +0100 Subject: [PATCH 10/11] For some reason we need to explicitly state the type here. --- lib/ui/src/components/preview/toolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ui/src/components/preview/toolbar.tsx b/lib/ui/src/components/preview/toolbar.tsx index b9670c7053e..66f5c748970 100644 --- a/lib/ui/src/components/preview/toolbar.tsx +++ b/lib/ui/src/components/preview/toolbar.tsx @@ -137,7 +137,7 @@ export interface ToolData { story: Story | Group; } -export const ToolRes = React.memo( +export const ToolRes: FunctionComponent = React.memo( ({ api, story, tabs, isShown, location, path, viewMode }) => { const { left, right } = useTools(api.getElements, tabs, viewMode, story, location, path); From ec07da9a403edf9f87395d610873e291ec9386b6 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 16 Nov 2020 16:43:58 +0100 Subject: [PATCH 11/11] Stick with the sizeMe HOC and use props comparison to avoid rerenders instead. --- lib/ui/src/app.tsx | 141 +++++++++++++++++++++++++-------------------- 1 file changed, 80 insertions(+), 61 deletions(-) diff --git a/lib/ui/src/app.tsx b/lib/ui/src/app.tsx index 39a14e9ab8b..22c3948dc01 100644 --- a/lib/ui/src/app.tsx +++ b/lib/ui/src/app.tsx @@ -1,6 +1,6 @@ -import React, { ComponentProps, FunctionComponent, useMemo } from 'react'; +import React, { FunctionComponent, useMemo } from 'react'; import { Global, createGlobal, styled } from '@storybook/theming'; -import { SizeMe } from 'react-sizeme'; +import sizeMe from 'react-sizeme'; import { Route } from '@storybook/router'; @@ -26,71 +26,90 @@ export interface AppProps { docsOnly: boolean; layout: State['layout']; panelCount: number; - width: number; - height: number; + size: { + width: number; + height: number; + }; } -const App = React.memo(({ viewMode, docsOnly, layout, panelCount, width, height }) => { - let content; +const App = React.memo( + ({ viewMode, docsOnly, layout, panelCount, size: { width, height } }) => { + let content; - const props = useMemo( - () => ({ - Sidebar, - Preview, - Panel, - Notifications, - pages: [ - { - key: 'settings', - render: () => , - route: (({ children }) => ( - - {children} - - )) as FunctionComponent, - }, - ], - }), - [] - ); - - if (!width || !height) { - content =
; - } else if (width < 600) { - content = ; - } else { - content = ( - + const props = useMemo( + () => ({ + Sidebar, + Preview, + Panel, + Notifications, + pages: [ + { + key: 'settings', + render: () => , + route: (({ children }) => ( + + {children} + + )) as FunctionComponent, + }, + ], + }), + [] ); - } - return ( - - - {content} - - ); -}); + if (!width || !height) { + content =
; + } else if (width < 600) { + content = ; + } else { + content = ( + + ); + } + + return ( + + + {content} + + ); + }, + // This is the default shallowEqual implementation, but with custom behavior for the `size` prop. + (prevProps: any, nextProps: any) => { + if (Object.is(prevProps, nextProps)) return true; + if (typeof prevProps !== 'object' || prevProps === null) return false; + if (typeof nextProps !== 'object' || nextProps === null) return false; + + const keysA = Object.keys(prevProps); + const keysB = Object.keys(nextProps); + if (keysA.length !== keysB.length) return false; + + // eslint-disable-next-line no-restricted-syntax + for (const key of keysA) { + if (key === 'size') { + // SizeMe injects a new `size` object every time, even if the width/height doesn't change, + // so we chech that one manually. + if (prevProps[key].width !== nextProps[key].width) return false; + if (prevProps[key].height !== nextProps[key].height) return false; + } else { + if (!Object.prototype.hasOwnProperty.call(nextProps, key)) return false; + if (!Object.is(prevProps[key], nextProps[key])) return false; + } + } + + return true; + } +); + +const SizedApp = sizeMe({ monitorHeight: true })(App); App.displayName = 'App'; -const SizedApp: FunctionComponent, 'width' | 'height'>> = ( - props -) => ( - - {({ size }) => ( - // Don't pass size directly, because it's a new object each time. - - )} - -); - export default SizedApp;