From c44e11389bace279b28a66e6eb322487470ce93d Mon Sep 17 00:00:00 2001 From: Donghyeon Kim <0916dhkim@gmail.com> Date: Sun, 25 Dec 2022 15:34:38 -0500 Subject: [PATCH] Fix tab group sync --- .../src/theme/Tabs/index.tsx | 107 +----------- .../src/contexts/tabGroupChoice.tsx | 154 +++++++++++++++--- 2 files changed, 140 insertions(+), 121 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 303d84375e2f..737c074f28d7 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -5,17 +5,9 @@ * LICENSE file in the root directory of this source tree. */ -import React, { - cloneElement, - isValidElement, - useCallback, - useEffect, - useState, - type ReactElement, -} from 'react'; +import React, {cloneElement, isValidElement, type ReactElement} from 'react'; import clsx from 'clsx'; -import {useHistory, useLocation} from '@docusaurus/router'; -import {duplicates, useEvent} from '@docusaurus/theme-common'; +import {duplicates} from '@docusaurus/theme-common'; import { useScrollPositionBlocker, useTabGroupChoice, @@ -33,57 +25,6 @@ function isTabItem( return 'value' in comp.props; } -function getSearchKey({ - queryString = false, - groupId, -}: Pick) { - if (typeof queryString === 'string') { - return queryString; - } - if (queryString === false) { - return undefined; - } - if (queryString === true && !groupId) { - throw new Error( - `Docusaurus error: The component groupId prop is required if queryString=true, because this value is used as the search param name. You can also provide an explicit value such as queryString="my-search-param".`, - ); - } - return groupId; -} - -function useTabQueryString({ - queryString = false, - groupId, -}: Pick) { - // TODO not re-render optimized - // See https://thisweekinreact.com/articles/useSyncExternalStore-the-underrated-react-api - const location = useLocation(); - const history = useHistory(); - - const searchKey = getSearchKey({queryString, groupId}); - - const get = useCallback(() => { - if (!searchKey) { - return undefined; - } - return new URLSearchParams(location.search).get(searchKey); - }, [searchKey, location.search]); - - const set = useCallback( - (newTabValue: string) => { - if (!searchKey) { - return; // no-op - } - const searchParams = new URLSearchParams(location.search); - searchParams.set(searchKey, newTabValue); - history.replace({...location, search: searchParams.toString()}); - }, - [searchKey, history, location], - ); - - return {get, set}; -} - function TabsComponent(props: Props): JSX.Element { const { lazy, @@ -107,7 +48,6 @@ function TabsComponent(props: Props): JSX.Element { }>: all children of the component should be , and every should have a unique "value" prop.`, ); }); - const tabQueryString = useTabQueryString({queryString, groupId}); const values = valuesProp ?? // Only pick keys that we recognize. MDX would inject some keys by default @@ -140,46 +80,21 @@ function TabsComponent(props: Props): JSX.Element { ); } - const { - ready: tabGroupChoicesReady, - tabGroupChoices, - setTabGroupChoices, - } = useTabGroupChoice(); const defaultValue = defaultValueProp !== undefined ? defaultValueProp : children.find((child) => child.props.default)?.props.value ?? children[0]!.props.value; - - const [selectedValue, setSelectedValue] = useState(defaultValue); + const {selectedValue, setTabGroupChoice} = useTabGroupChoice( + groupId, + queryString, + defaultValue, + values, + ); const tabRefs: (HTMLLIElement | null)[] = []; const {blockElementScrollPositionUntilNextRender} = useScrollPositionBlocker(); - // Lazily restore the appropriate tab selected value - // We can't read queryString/localStorage on first render - // It would trigger a React SSR/client hydration mismatch - const restoreTabSelectedValue = useEvent(() => { - // wait for localStorage values to be set (initially empty object :s) - if (tabGroupChoicesReady) { - // querystring value > localStorage value - const valueToRestore = - tabQueryString.get() ?? (groupId && tabGroupChoices[groupId]); - const isValid = - valueToRestore && - values.some((value) => value.value === valueToRestore); - if (isValid) { - setSelectedValue(valueToRestore); - } - } - }); - useEffect(() => { - // wait for localStorage values to be set (initially empty object :s) - if (tabGroupChoicesReady) { - restoreTabSelectedValue(); - } - }, [tabGroupChoicesReady, restoreTabSelectedValue]); - const handleTabChange = ( event: | React.FocusEvent @@ -192,11 +107,7 @@ function TabsComponent(props: Props): JSX.Element { if (newTabValue !== selectedValue) { blockElementScrollPositionUntilNextRender(newTab); - setSelectedValue(newTabValue); - tabQueryString.set(newTabValue); - if (groupId != null) { - setTabGroupChoices(groupId, String(newTabValue)); - } + setTabGroupChoice(newTabValue); } }; diff --git a/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx b/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx index bb4eb3a97ece..61fab189c417 100644 --- a/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx +++ b/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx @@ -13,61 +13,123 @@ import React, { useContext, type ReactNode, } from 'react'; +import {useHistory, useLocation} from '@docusaurus/router'; import {createStorageSlot, listStorageKeys} from '../utils/storageUtils'; import {ReactContextError} from '../utils/reactUtils'; const TAB_CHOICE_PREFIX = 'docusaurus.tab.'; type ContextValue = { - /** A boolean that tells if choices have already been restored from storage */ - readonly ready: boolean; - /** A map from `groupId` to the `value` of the saved choice. */ - readonly tabGroupChoices: {readonly [groupId: string]: string}; + /** A map from `groupId` to the `value` of the saved choice in storage. */ + readonly tabGroupChoicesInStorage: {readonly [groupId: string]: string}; + /** A map from `searchKey` to the `value` of the choice in query parameter. */ + readonly tabGroupChoicesInQueryParams: {readonly [searchKey: string]: string}; /** Set the new choice value of a group. */ - readonly setTabGroupChoices: (groupId: string, newChoice: string) => void; + readonly setTabGroupChoice: ( + groupId: string | undefined, + queryString: string | boolean | undefined, + newChoice: string, + ) => void; }; const Context = React.createContext(undefined); +function getSearchKey( + groupId: string | undefined, + queryString: string | boolean | undefined, +) { + if (typeof queryString === 'string') { + return queryString; + } + if (queryString === false) { + return undefined; + } + if (queryString === true && !groupId) { + throw new Error( + `Docusaurus error: The component groupId prop is required if queryString=true, because this value is used as the search param name. You can also provide an explicit value such as queryString="my-search-param".`, + ); + } + return groupId; +} + function useContextValue(): ContextValue { - const [ready, setReady] = useState(false); - const [tabGroupChoices, setChoices] = useState<{ - readonly [groupId: string]: string; - }>({}); - const setChoiceSyncWithLocalStorage = useCallback( + // TODO not re-render optimized + // See https://thisweekinreact.com/articles/useSyncExternalStore-the-underrated-react-api + const location = useLocation(); + const history = useHistory(); + + const [tabGroupChoicesInStorage, setGroupChoicesInStorage] = useState< + ContextValue['tabGroupChoicesInStorage'] + >({}); + const [tabGroupChoicesInQueryParams, setGroupChoicesInQueryParams] = useState< + ContextValue['tabGroupChoicesInQueryParams'] + >({}); + + const updateLocalStorage = useCallback( (groupId: string, newChoice: string) => { createStorageSlot(`${TAB_CHOICE_PREFIX}${groupId}`).set(newChoice); }, [], ); + const updateHistory = useCallback( + (searchKey: string, newTabValue: string) => { + const searchParams = new URLSearchParams(location.search); + searchParams.set(searchKey, newTabValue); + history.replace({...location, search: searchParams.toString()}); + }, + [history, location], + ); useEffect(() => { try { const localStorageChoices: {[groupId: string]: string} = {}; listStorageKeys().forEach((storageKey) => { if (storageKey.startsWith(TAB_CHOICE_PREFIX)) { - const groupId = storageKey.substring(TAB_CHOICE_PREFIX.length); - localStorageChoices[groupId] = createStorageSlot(storageKey).get()!; + const groupIdFromStorage = storageKey.substring( + TAB_CHOICE_PREFIX.length, + ); + localStorageChoices[groupIdFromStorage] = + createStorageSlot(storageKey).get()!; } }); - setChoices(localStorageChoices); + setGroupChoicesInStorage(localStorageChoices); } catch (err) { console.error(err); } - setReady(true); }, []); - const setTabGroupChoices = useCallback( - (groupId: string, newChoice: string) => { - setChoices((oldChoices) => ({...oldChoices, [groupId]: newChoice})); - setChoiceSyncWithLocalStorage(groupId, newChoice); + const setTabGroupChoice = useCallback( + ( + groupId: string | undefined, + queryString: string | boolean | undefined, + newChoice: string, + ) => { + const searchKey = getSearchKey(groupId, queryString); + if (groupId != null) { + setGroupChoicesInStorage((oldChoices) => ({ + ...oldChoices, + [groupId]: newChoice, + })); + updateLocalStorage(groupId, newChoice); + } + if (searchKey != null) { + setGroupChoicesInQueryParams((oldChoices) => ({ + ...oldChoices, + [searchKey]: newChoice, + })); + updateHistory(searchKey, newChoice); + } }, - [setChoiceSyncWithLocalStorage], + [updateLocalStorage, updateHistory], ); return useMemo( - () => ({ready, tabGroupChoices, setTabGroupChoices}), - [ready, tabGroupChoices, setTabGroupChoices], + () => ({ + tabGroupChoicesInStorage, + tabGroupChoicesInQueryParams, + setTabGroupChoice, + }), + [tabGroupChoicesInStorage, tabGroupChoicesInQueryParams, setTabGroupChoice], ); } @@ -80,10 +142,56 @@ export function TabGroupChoiceProvider({ return {children}; } -export function useTabGroupChoice(): ContextValue { +type UseTabGroupChoice = { + selectedValue: string | null; + setTabGroupChoice: (newChoice: string) => void; +}; + +export function useTabGroupChoice( + groupId: string | undefined, + queryString: string | boolean | undefined, + defaultValue: string | null, + values: readonly {value: string}[], +): UseTabGroupChoice { + const searchKey = getSearchKey(groupId, queryString); + const [selectedValue, setSelectedValue] = useState( + defaultValue, + ); const context = useContext(Context); if (context == null) { throw new ReactContextError('TabGroupChoiceProvider'); } - return context; + + const setTabGroupChoice = useCallback( + (newChoice) => { + setSelectedValue(newChoice); + context.setTabGroupChoice(groupId, queryString, newChoice); + }, + [context, groupId, queryString], + ); + + // Sync storage, query params, and selected state. + useEffect(() => { + const queryParamValue = + searchKey && context.tabGroupChoicesInQueryParams[searchKey]; + const storageValue = groupId && context.tabGroupChoicesInStorage[groupId]; + const valueToSync = queryParamValue ?? storageValue; + const isValid = + !!valueToSync && values.some(({value}) => value === valueToSync); + if (isValid && valueToSync !== selectedValue) { + setSelectedValue(valueToSync); + } + }, [ + context.tabGroupChoicesInQueryParams, + context.tabGroupChoicesInStorage, + groupId, + searchKey, + selectedValue, + values, + ]); + + return { + selectedValue, + setTabGroupChoice, + }; }