From 41df088e4873ad50b605a2b20464556ea33f04b2 Mon Sep 17 00:00:00 2001 From: isqua Date: Wed, 25 Oct 2023 20:15:14 +0300 Subject: [PATCH] refactor: reorganize contexts to avoid menu rerendering when start filtering --- .../toc/ui/Menu/Context/MenuProvider.tsx | 22 +++++++--- src/features/toc/ui/Menu/Context/contexts.ts | 20 +++++++-- src/features/toc/ui/Menu/Context/hooks.ts | 18 +++----- src/features/toc/ui/Menu/Filter/Filter.tsx | 4 +- src/hooks/useFilter.test.ts | 34 +++++---------- src/hooks/useFilter.ts | 43 +++++++++---------- 6 files changed, 71 insertions(+), 70 deletions(-) diff --git a/src/features/toc/ui/Menu/Context/MenuProvider.tsx b/src/features/toc/ui/Menu/Context/MenuProvider.tsx index c17aabb..04e322e 100644 --- a/src/features/toc/ui/Menu/Context/MenuProvider.tsx +++ b/src/features/toc/ui/Menu/Context/MenuProvider.tsx @@ -1,4 +1,4 @@ -import type { PropsWithChildren } from 'react' +import { useCallback, useMemo, type PropsWithChildren } from 'react' import { useFilter } from '../../../../../hooks/useFilter' import { filterTreeNodes } from '../../../core/filterTreeNodes' import { getBreadCrumbs } from '../../../core/getBreadCrumbs' @@ -12,10 +12,22 @@ type MenuProviderProps = PropsWithChildren<{ }> export function MenuProvider({ toc, url, children, isLoading = false }: MenuProviderProps): JSX.Element { - const breadcrumbs = getBreadCrumbs(toc, url) - const tocContextValue = { toc, isLoading } - const locationContextValue = { url, breadcrumbs } - const filterContextValue = useFilter((text) => filterTreeNodes(toc, text)) + const filterCallback = useCallback( + (text: string) => filterTreeNodes(toc, text), + [toc] + ) + + const { data, manager: filterContextValue } = useFilter(filterCallback) + + const tocContextValue = useMemo( + () => ({ toc, filter: data, isLoading }), + [toc, data, isLoading], + ) + + const locationContextValue = useMemo( + () => ({ url, breadcrumbs: getBreadCrumbs(toc, url) }), + [toc, url] + ) return ( diff --git a/src/features/toc/ui/Menu/Context/contexts.ts b/src/features/toc/ui/Menu/Context/contexts.ts index 65940b5..bbe009c 100644 --- a/src/features/toc/ui/Menu/Context/contexts.ts +++ b/src/features/toc/ui/Menu/Context/contexts.ts @@ -1,10 +1,11 @@ import { createContext } from 'react' -import { initialFilterState, noopFilterActions, type UseFilterResult } from '../../../../../hooks/useFilter' +import { FilterActions, noopFilterActions } from '../../../../../hooks/useFilter' import type { PageDescriptor, PageURL, TableOfContent } from '../../../types' type TocContextValue = { toc: TableOfContent + filter: Set | null isLoading: boolean } @@ -13,6 +14,10 @@ type LocationContextValue = { breadcrumbs: PageDescriptor[] } +type FilterContextValue = FilterActions & { + isFiltering: boolean +} + const defaultToc: TableOfContent = { topLevelIds: [], entities: { pages: {} }, @@ -23,15 +28,22 @@ const defaultBreadCrumbs: PageDescriptor[] = [] export const TocContext = createContext({ toc: defaultToc, + filter: null, isLoading: true, }) +TocContext.displayName = 'TocContext' + export const LocationContext = createContext({ url: defaultUrl, breadcrumbs: defaultBreadCrumbs, }) -export const FilterContext = createContext>>({ - state: initialFilterState, - actions: noopFilterActions, +LocationContext.displayName = 'LocationContext' + +export const FilterContext = createContext({ + isFiltering: false, + ...noopFilterActions }) + +FilterContext.displayName = 'FilterContext' diff --git a/src/features/toc/ui/Menu/Context/hooks.ts b/src/features/toc/ui/Menu/Context/hooks.ts index ed4d8e6..2fa80de 100644 --- a/src/features/toc/ui/Menu/Context/hooks.ts +++ b/src/features/toc/ui/Menu/Context/hooks.ts @@ -4,18 +4,11 @@ import { buildMenuSection } from '../../../core/buildMenuSection' import type { PageId, SectionHighlight } from '../../../types' import { FilterContext, LocationContext, TocContext } from './contexts' -const useFilterData = () => { - const { state: { data } } = useContext(FilterContext) - - return data -} - const FILTER_DELAY_IN_MS = 1000 export const useSectionItems = (parentId: PageId = '', level: number = 0, highlight: SectionHighlight) => { - const { toc } = useContext(TocContext) + const { toc, filter } = useContext(TocContext) const currentLocation = useContext(LocationContext) - const filter = useFilterData() const items = buildMenuSection(toc, { url: currentLocation.url, @@ -36,8 +29,7 @@ export const useIsLoading = () => { } export const useFilterInput = () => { - const { actions, state: { isLoading } } = useContext(FilterContext) - const { onChange, onFilterStart, onReset } = actions + const { isFiltering, onChange, onFilterStart, onReset } = useContext(FilterContext) const timeout = useRef(0) const onChangeHandler = useCallback((value: string) => { @@ -48,7 +40,7 @@ export const useFilterInput = () => { return } - if (!isLoading) { + if (!isFiltering) { onFilterStart() } @@ -59,11 +51,11 @@ export const useFilterInput = () => { timeout.current = window.setTimeout(() => { onChange(text) }, FILTER_DELAY_IN_MS) - }, [isLoading, onChange, onFilterStart, onReset]) + }, [isFiltering, onChange, onFilterStart, onReset]) useEffect(() => () => { clearTimeout(timeout.current) }, []) - return { onChange: onChangeHandler, isLoading } + return { onChange: onChangeHandler, isFiltering } } diff --git a/src/features/toc/ui/Menu/Filter/Filter.tsx b/src/features/toc/ui/Menu/Filter/Filter.tsx index b310278..3c2fa2f 100644 --- a/src/features/toc/ui/Menu/Filter/Filter.tsx +++ b/src/features/toc/ui/Menu/Filter/Filter.tsx @@ -4,13 +4,13 @@ import { useFilterInput } from '../Context/hooks' import styles from './Filter.module.css' export function Filter(): JSX.Element { - const { onChange, isLoading } = useFilterInput() + const { onChange, isFiltering } = useFilterInput() return (
diff --git a/src/hooks/useFilter.test.ts b/src/hooks/useFilter.test.ts index 65eeced..9dccd2a 100644 --- a/src/hooks/useFilter.test.ts +++ b/src/hooks/useFilter.test.ts @@ -9,40 +9,28 @@ describe('hooks/useFilter', () => { const { result } = renderHook(() => useFilter(filter)) - expect(result.current.state).toEqual({ - isLoading: false, - text: '', - data: null, - }) + expect(result.current.data).toEqual(null) + expect(result.current.manager.isFiltering).toEqual(false) act(() => { - result.current.actions.onFilterStart() + result.current.manager.onFilterStart() }) - expect(result.current.state).toEqual({ - isLoading: true, - text: '', - data: null, - }) + expect(result.current.data).toEqual(null) + expect(result.current.manager.isFiltering).toEqual(true) act(() => { - result.current.actions.onChange('hello') + result.current.manager.onChange('hello') }) - expect(result.current.state).toEqual({ - isLoading: false, - text: 'hello', - data: 'text: hello', - }) + expect(result.current.data).toEqual('text: hello') + expect(result.current.manager.isFiltering).toEqual(false) act(() => { - result.current.actions.onReset() + result.current.manager.onReset() }) - expect(result.current.state).toEqual({ - isLoading: false, - text: '', - data: null, - }) + expect(result.current.data).toEqual(null) + expect(result.current.manager.isFiltering).toEqual(false) }) }) diff --git a/src/hooks/useFilter.ts b/src/hooks/useFilter.ts index 48cfd26..eb8c98f 100644 --- a/src/hooks/useFilter.ts +++ b/src/hooks/useFilter.ts @@ -1,4 +1,4 @@ -import { useReducer, type Reducer } from 'react' +import { useReducer, type Reducer, useMemo } from 'react' interface DataFilter { (text: string): T @@ -11,11 +11,11 @@ export type FilterActions = { } export type UseFilterResult = { - state: FilterState - actions: FilterActions + data: null | T + manager: FilterActions & { isFiltering: boolean } } -type FilterState = { +export type FilterState = { isLoading: boolean text: string data: null | T @@ -75,26 +75,23 @@ export function useFilter(fn: DataFilter): UseFilterResult { initialFilterState, ) - const onFilterStart = () => { - dispatch({ type: 'start' }) - } - - const onChange = (text: string) => { - const data = fn(text) - - dispatch({ type: 'change', text, data }) - } - - const onReset = () => { - dispatch({ type: 'reset' }) - } + const manager = useMemo(() => ({ + isFiltering: state.isLoading, + onFilterStart: () => { + dispatch({ type: 'start' }) + }, + onChange: (text: string) => { + const data = fn(text) + + dispatch({ type: 'change', text, data }) + }, + onReset: () => { + dispatch({ type: 'reset' }) + }, + }), [fn, state.isLoading]) return { - state, - actions: { - onFilterStart, - onChange, - onReset, - } + data: state.data, + manager, } }