Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix tab group sync #8483

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manage selectedValue state inside useTabGroupChoice hook.

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);
Comment on lines 109 to -195
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought of moving blockElementScrollPositionUntilNextRender into tabGroupChoice.tsx, but decided to leave it as-is. Let me know if you think it's better to move it.

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};
Comment on lines -22 to +26
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Remove ready property.
  • Manage both local storage values & query param values in this context.

/** 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(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setTabGroupChoice pushes tab choice to local storage or query param depending on groupId & queryString.

(
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);
}
}, [
Comment on lines +173 to +184
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Essence of this PR: Detect changes in local storage or query params, then update selectedValue accordingly.

context.tabGroupChoicesInQueryParams,
context.tabGroupChoicesInStorage,
groupId,
searchKey,
selectedValue,
values,
]);

return {
selectedValue,
setTabGroupChoice,
};
}