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
Fix tab group sync #8483
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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<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, | ||
|
@@ -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 | ||
|
@@ -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> | ||
|
@@ -192,11 +107,7 @@ function TabsComponent(props: Props): JSX.Element { | |
|
||
if (newTabValue !== selectedValue) { | ||
blockElementScrollPositionUntilNextRender(newTab); | ||
setSelectedValue(newTabValue); | ||
Comment on lines
109
to
-195
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thought of moving |
||
tabQueryString.set(newTabValue); | ||
if (groupId != null) { | ||
setTabGroupChoices(groupId, String(newTabValue)); | ||
} | ||
setTabGroupChoice(newTabValue); | ||
} | ||
}; | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
/** 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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
( | ||
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 <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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
context.tabGroupChoicesInQueryParams, | ||
context.tabGroupChoicesInStorage, | ||
groupId, | ||
searchKey, | ||
selectedValue, | ||
values, | ||
]); | ||
|
||
return { | ||
selectedValue, | ||
setTabGroupChoice, | ||
}; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Manage
selectedValue
state insideuseTabGroupChoice
hook.