Skip to content

Commit

Permalink
Fix tab group sync
Browse files Browse the repository at this point in the history
  • Loading branch information
0916dhkim committed Dec 25, 2022
1 parent c0102bd commit c44e113
Show file tree
Hide file tree
Showing 2 changed files with 140 additions and 121 deletions.
107 changes: 9 additions & 98 deletions packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx
Expand Up @@ -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,
Expand All @@ -33,57 +25,6 @@ function isTabItem(
return 'value' in comp.props;
}

function getSearchKey({
queryString = false,
groupId,
}: Pick<Props, 'queryString' | 'groupId'>) {
if (typeof queryString === 'string') {
return queryString;
}
if (queryString === false) {
return undefined;
}
if (queryString === true && !groupId) {
throw new Error(
`Docusaurus error: The <Tabs> 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<Props, 'queryString' | 'groupId'>) {
// 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,
Expand All @@ -107,7 +48,6 @@ function TabsComponent(props: Props): JSX.Element {
}>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> 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
Expand Down Expand Up @@ -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<HTMLLIElement>
Expand All @@ -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);
}
};

Expand Down
154 changes: 131 additions & 23 deletions packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx
Expand Up @@ -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<ContextValue | undefined>(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 <Tabs> 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],
);
}

Expand All @@ -80,10 +142,56 @@ export function TabGroupChoiceProvider({
return <Context.Provider value={value}>{children}</Context.Provider>;
}

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<string | null>(
defaultValue,
);
const context = useContext(Context);
if (context == null) {
throw new ReactContextError('TabGroupChoiceProvider');
}
return context;

const setTabGroupChoice = useCallback<UseTabGroupChoice['setTabGroupChoice']>(
(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,
};
}

0 comments on commit c44e113

Please sign in to comment.