From c621e428e0dcc1064d48695571ac31cbc713efc8 Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Tue, 18 Oct 2022 19:05:19 +0200 Subject: [PATCH 01/20] feat(theme-classic): store selected tab in query string. --- .../src/theme/Tabs/index.tsx | 74 ++++++++++++++----- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 54843fa48ade..d970c287452f 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -31,6 +31,13 @@ function isTabItem( return 'value' in comp.props; } +const NON_GROUP_TAB_KEY = '__noGroup__'; +function getValueFromSearchParams(groupId = NON_GROUP_TAB_KEY): string | null { + const searchParams = new URLSearchParams(window.location.search); + const prevSearchParams = searchParams.get('tabs'); + return prevSearchParams ? JSON.parse(prevSearchParams)[groupId] : null; +} + function TabsComponent(props: Props): JSX.Element { const { lazy, @@ -69,14 +76,41 @@ function TabsComponent(props: Props): JSX.Element { .join(', ')}" found in . Every value needs to be unique.`, ); } - // When defaultValueProp is null, don't show a default tab - const defaultValue = - defaultValueProp === null - ? defaultValueProp - : defaultValueProp ?? - children.find((child) => child.props.default)?.props.value ?? - children[0]!.props.value; - if (defaultValue !== null && !values.some((a) => a.value === defaultValue)) { + + const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoice(); + // search params > + // local storage > + // specified defaultValue > + // first child with "default" attr > + // first tab item. + let defaultValue: string | null | undefined = + getValueFromSearchParams(groupId); + if (!defaultValue && groupId != null) { + const relevantTabGroupChoice = tabGroupChoices[groupId]; + if ( + relevantTabGroupChoice != null && + relevantTabGroupChoice !== defaultValue && + values.some((value) => value.value === relevantTabGroupChoice) + ) { + defaultValue = relevantTabGroupChoice; + } + } + // If we didn't find the right value in search params or local storage, + // fallback to props > child with "default" specified > first tab. + if (!defaultValue || !values.some((a) => a.value === defaultValue)) { + defaultValue = + defaultValueProp !== undefined + ? defaultValueProp + : children.find((child) => child.props.default)?.props.value ?? + children[0]!.props.value; + } + + // Warn user about passing incorrect defaultValue as prop. + if ( + defaultValueProp !== null && + defaultValueProp !== undefined && + !values.some((a) => a.value === defaultValueProp) + ) { throw new Error( `Docusaurus error: The has a defaultValue "${defaultValue}" but none of its children has the corresponding value. Available values are: ${values .map((a) => a.value) @@ -86,23 +120,11 @@ function TabsComponent(props: Props): JSX.Element { ); } - const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoice(); const [selectedValue, setSelectedValue] = useState(defaultValue); const tabRefs: (HTMLLIElement | null)[] = []; const {blockElementScrollPositionUntilNextRender} = useScrollPositionBlocker(); - if (groupId != null) { - const relevantTabGroupChoice = tabGroupChoices[groupId]; - if ( - relevantTabGroupChoice != null && - relevantTabGroupChoice !== selectedValue && - values.some((value) => value.value === relevantTabGroupChoice) - ) { - setSelectedValue(relevantTabGroupChoice); - } - } - const handleTabChange = ( event: | React.FocusEvent @@ -117,6 +139,18 @@ function TabsComponent(props: Props): JSX.Element { blockElementScrollPositionUntilNextRender(newTab); setSelectedValue(newTabValue); + const searchParams = new URLSearchParams(window.location.search); + const prevSearchParams = searchParams.get('tabs'); + const prevVal = prevSearchParams ? JSON.parse(prevSearchParams) : null; + const newVal = {[groupId || NON_GROUP_TAB_KEY]: newTabValue}; + const url = new URL(window.location.origin + window.location.pathname); + searchParams.set( + 'tabs', + JSON.stringify(prevVal ? {...prevVal, ...newVal} : newVal), + ); + url.search = searchParams.toString(); + window.history.replaceState({}, '', url); + if (groupId != null) { setTabGroupChoices(groupId, String(newTabValue)); } From 634c0f237e9c6b313fb182b229caf73be7aab85a Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Tue, 18 Oct 2022 19:28:56 +0200 Subject: [PATCH 02/20] test: fix failing tests --- .../src/theme/Tabs/index.tsx | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index d970c287452f..c53deb28c078 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -33,6 +33,9 @@ function isTabItem( const NON_GROUP_TAB_KEY = '__noGroup__'; function getValueFromSearchParams(groupId = NON_GROUP_TAB_KEY): string | null { + if (typeof window === 'undefined') { + return null; // Ignore during SSR. + } const searchParams = new URLSearchParams(window.location.search); const prevSearchParams = searchParams.get('tabs'); return prevSearchParams ? JSON.parse(prevSearchParams)[groupId] : null; @@ -77,6 +80,21 @@ function TabsComponent(props: Props): JSX.Element { ); } + // Warn user about passing incorrect defaultValue as prop. + if ( + defaultValueProp !== null && + defaultValueProp !== undefined && + !values.some((a) => a.value === defaultValueProp) + ) { + throw new Error( + `Docusaurus error: The has a defaultValue "${defaultValueProp}" but none of its children has the corresponding value. Available values are: ${values + .map((a) => a.value) + .join( + ', ', + )}. If you intend to show no default tab, use defaultValue={null} instead.`, + ); + } + const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoice(); // search params > // local storage > @@ -105,21 +123,6 @@ function TabsComponent(props: Props): JSX.Element { children[0]!.props.value; } - // Warn user about passing incorrect defaultValue as prop. - if ( - defaultValueProp !== null && - defaultValueProp !== undefined && - !values.some((a) => a.value === defaultValueProp) - ) { - throw new Error( - `Docusaurus error: The has a defaultValue "${defaultValue}" but none of its children has the corresponding value. Available values are: ${values - .map((a) => a.value) - .join( - ', ', - )}. If you intend to show no default tab, use defaultValue={null} instead.`, - ); - } - const [selectedValue, setSelectedValue] = useState(defaultValue); const tabRefs: (HTMLLIElement | null)[] = []; const {blockElementScrollPositionUntilNextRender} = From 52fa90bb7bb39a01a9294ac4c6b04fe276e40be8 Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Mon, 24 Oct 2022 15:33:49 +0200 Subject: [PATCH 03/20] chore: Use react router instead of native window location. --- .../src/theme-classic.d.ts | 1 + .../src/theme/Tabs/index.tsx | 47 ++++++++----------- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index 2e89d14c8ddd..d34fd77e82c8 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -1263,6 +1263,7 @@ declare module '@theme/Tabs' { }[]; readonly groupId?: string; readonly className?: string; + readonly queryString: string | boolean; } export default function Tabs(props: Props): JSX.Element; diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index c53deb28c078..305272c4a094 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -6,21 +6,21 @@ */ import React, { - useState, cloneElement, isValidElement, + useState, type ReactElement, } from 'react'; import clsx from 'clsx'; -import useIsBrowser from '@docusaurus/useIsBrowser'; +import {useHistory, useLocation} from '@docusaurus/router'; import {duplicates} from '@docusaurus/theme-common'; import { useScrollPositionBlocker, useTabGroupChoice, } from '@docusaurus/theme-common/internal'; -import type {Props} from '@theme/Tabs'; +import useIsBrowser from '@docusaurus/useIsBrowser'; import type {Props as TabItemProps} from '@theme/TabItem'; - +import type {Props} from '@theme/Tabs'; import styles from './styles.module.css'; // A very rough duck type, but good enough to guard against mistakes while @@ -31,16 +31,6 @@ function isTabItem( return 'value' in comp.props; } -const NON_GROUP_TAB_KEY = '__noGroup__'; -function getValueFromSearchParams(groupId = NON_GROUP_TAB_KEY): string | null { - if (typeof window === 'undefined') { - return null; // Ignore during SSR. - } - const searchParams = new URLSearchParams(window.location.search); - const prevSearchParams = searchParams.get('tabs'); - return prevSearchParams ? JSON.parse(prevSearchParams)[groupId] : null; -} - function TabsComponent(props: Props): JSX.Element { const { lazy, @@ -49,7 +39,10 @@ function TabsComponent(props: Props): JSX.Element { values: valuesProp, groupId, className, + queryString = false, } = props; + const location = useLocation(); + const history = useHistory(); const children = React.Children.map(props.children, (child) => { if (isValidElement(child) && isTabItem(child)) { return child; @@ -101,8 +94,12 @@ function TabsComponent(props: Props): JSX.Element { // specified defaultValue > // first child with "default" attr > // first tab item. - let defaultValue: string | null | undefined = - getValueFromSearchParams(groupId); + let defaultValue: string | null | undefined; + if (queryString) { + const searchKey = + typeof queryString === 'string' ? queryString : groupId || ''; + defaultValue = new URLSearchParams(location.search).get(searchKey); + } if (!defaultValue && groupId != null) { const relevantTabGroupChoice = tabGroupChoices[groupId]; if ( @@ -142,17 +139,13 @@ function TabsComponent(props: Props): JSX.Element { blockElementScrollPositionUntilNextRender(newTab); setSelectedValue(newTabValue); - const searchParams = new URLSearchParams(window.location.search); - const prevSearchParams = searchParams.get('tabs'); - const prevVal = prevSearchParams ? JSON.parse(prevSearchParams) : null; - const newVal = {[groupId || NON_GROUP_TAB_KEY]: newTabValue}; - const url = new URL(window.location.origin + window.location.pathname); - searchParams.set( - 'tabs', - JSON.stringify(prevVal ? {...prevVal, ...newVal} : newVal), - ); - url.search = searchParams.toString(); - window.history.replaceState({}, '', url); + if (queryString) { + const searchKey = + typeof queryString === 'string' ? queryString : groupId || ''; + const searchParams = new URLSearchParams(location.search); + searchParams.set(searchKey, newTabValue); + history.push({...location, search: searchParams.toString()}); + } if (groupId != null) { setTabGroupChoices(groupId, String(newTabValue)); From 04053c5ec9d6ec0a678c3d50e8a5a91fb251ec0d Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Mon, 24 Oct 2022 16:09:48 +0200 Subject: [PATCH 04/20] fix: Make tabs sync work again. --- .../src/theme/Tabs/index.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 305272c4a094..019129afb0d2 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -100,16 +100,6 @@ function TabsComponent(props: Props): JSX.Element { typeof queryString === 'string' ? queryString : groupId || ''; defaultValue = new URLSearchParams(location.search).get(searchKey); } - if (!defaultValue && groupId != null) { - const relevantTabGroupChoice = tabGroupChoices[groupId]; - if ( - relevantTabGroupChoice != null && - relevantTabGroupChoice !== defaultValue && - values.some((value) => value.value === relevantTabGroupChoice) - ) { - defaultValue = relevantTabGroupChoice; - } - } // If we didn't find the right value in search params or local storage, // fallback to props > child with "default" specified > first tab. if (!defaultValue || !values.some((a) => a.value === defaultValue)) { @@ -125,6 +115,17 @@ function TabsComponent(props: Props): JSX.Element { const {blockElementScrollPositionUntilNextRender} = useScrollPositionBlocker(); + if (groupId != null) { + const relevantTabGroupChoice = tabGroupChoices[groupId]; + if ( + relevantTabGroupChoice != null && + relevantTabGroupChoice !== selectedValue && + values.some((value) => value.value === relevantTabGroupChoice) + ) { + setSelectedValue(relevantTabGroupChoice); + } + } + const handleTabChange = ( event: | React.FocusEvent From 216119da387f70b9da9380085a7d374789791508 Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Mon, 24 Oct 2022 16:15:44 +0200 Subject: [PATCH 05/20] test: Temporarily skip tabs tests. --- .../src/theme/Tabs/__tests__/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx index cedcbe9f4120..057547a8f713 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx @@ -14,7 +14,7 @@ import { import Tabs from '../index'; import TabItem from '../../TabItem'; -describe('Tabs', () => { +describe.skip('Tabs', () => { it('rejects bad Tabs child', () => { expect(() => { renderer.create( From cabac96ec66e681245dc8a35c36ec8c8f1aa27ee Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Tue, 25 Oct 2022 08:39:19 +0200 Subject: [PATCH 06/20] fix: Make queryString param optional. --- packages/docusaurus-theme-classic/src/theme-classic.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index d34fd77e82c8..f04d9f1a454e 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -1263,7 +1263,7 @@ declare module '@theme/Tabs' { }[]; readonly groupId?: string; readonly className?: string; - readonly queryString: string | boolean; + readonly queryString?: string | boolean; } export default function Tabs(props: Props): JSX.Element; From 80e978d20b70264dff62719846a5ad6cf0343e51 Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Sun, 30 Oct 2022 17:21:59 +0100 Subject: [PATCH 07/20] test: Remove tab unit test skip. --- package.json | 2 +- .../src/theme/Tabs/__tests__/index.test.tsx | 172 ++++++++++-------- 2 files changed, 93 insertions(+), 81 deletions(-) diff --git a/package.json b/package.json index 7d6513841978..31db9c7c4131 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "lint:spelling": "cspell \"**\" --no-progress", "lint:style": "stylelint \"**/*.css\"", "lerna": "lerna", - "test": "jest", + "test": "cross-env TEST=true jest", "test:build:website": "./admin/scripts/test-release.sh", "watch": "yarn lerna run --parallel watch", "clear": "(yarn workspace website clear || echo 'Failure while running docusaurus clear') && yarn lerna exec --ignore docusaurus yarn rimraf lib", diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx index 057547a8f713..3db3a62de269 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx @@ -11,17 +11,21 @@ import { TabGroupChoiceProvider, ScrollControllerProvider, } from '@docusaurus/theme-common/internal'; +import {StaticRouter} from 'react-router-dom'; import Tabs from '../index'; import TabItem from '../../TabItem'; -describe.skip('Tabs', () => { +describe('Tabs', () => { it('rejects bad Tabs child', () => { expect(() => { renderer.create( - -
Naughty
- Good -
, + + +
Naughty
+ Good +
+ , +
, ); }).toThrowErrorMatchingInlineSnapshot( `"Docusaurus error: Bad child
: all children of the component should be , and every should have a unique "value" prop."`, @@ -30,10 +34,12 @@ describe.skip('Tabs', () => { it('rejects bad Tabs defaultValue', () => { expect(() => { renderer.create( - - Tab 1 - Tab 2 - , + + + Tab 1 + Tab 2 + + , ); }).toThrowErrorMatchingInlineSnapshot( `"Docusaurus error: The has a defaultValue "bad" but none of its children has the corresponding value. Available values are: v1, v2. If you intend to show no default tab, use defaultValue={null} instead."`, @@ -42,14 +48,16 @@ describe.skip('Tabs', () => { it('rejects duplicate values', () => { expect(() => { renderer.create( - - Tab 1 - Tab 2 - Tab 3 - Tab 4 - Tab 5 - Tab 6 - , + + + Tab 1 + Tab 2 + Tab 3 + Tab 4 + Tab 5 + Tab 6 + + , ); }).toThrowErrorMatchingInlineSnapshot( `"Docusaurus error: Duplicate values "v1, v2" found in . Every value needs to be unique."`, @@ -58,54 +66,56 @@ describe.skip('Tabs', () => { it('accepts valid Tabs config', () => { expect(() => { renderer.create( - - - - Tab 1 - Tab 2 - - - Tab 1 - - Tab 2 - - - - - Tab 1 - - - Tab 2 - - - - Tab 1 - Tab 2 - - - Tab 1 - Tab 2 - - - - Tab 1 - - - Tab 2 - - - - , + + + + + Tab 1 + Tab 2 + + + Tab 1 + + Tab 2 + + + + + Tab 1 + + + Tab 2 + + + + Tab 1 + Tab 2 + + + Tab 1 + Tab 2 + + + + Tab 1 + + + Tab 2 + + + + + , ); }).not.toThrow(); // TODO Better Jest infrastructure to mock the Layout }); @@ -114,22 +124,24 @@ describe.skip('Tabs', () => { expect(() => { const tabs = ['Apple', 'Banana', 'Carrot']; renderer.create( - - - ({label: t, value: idx}))} - // @ts-expect-error: for an edge-case that we didn't write types for - defaultValue={0}> - {tabs.map((t, idx) => ( + + + + - {t} - - ))} - - - , + values={tabs.map((t, idx) => ({label: t, value: idx}))} + // @ts-expect-error: for an edge-case that we didn't write types for + defaultValue={0}> + {tabs.map((t, idx) => ( + // @ts-expect-error: for an edge-case that we didn't write types for + + {t} + + ))} + + + + , ); }).not.toThrow(); }); From 6e1fb037ceb88cdc79dc6a7b9ebdcb648016614c Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Sun, 30 Oct 2022 17:27:55 +0100 Subject: [PATCH 08/20] feat: Querystring user props valdiation. --- .../src/theme/Tabs/__tests__/index.test.tsx | 14 +++++++++++++- .../src/theme/Tabs/index.tsx | 6 ++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx index 3db3a62de269..dd12fba15f92 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx @@ -24,7 +24,6 @@ describe('Tabs', () => {
Naughty
Good - , , ); }).toThrowErrorMatchingInlineSnapshot( @@ -145,4 +144,17 @@ describe('Tabs', () => { ); }).not.toThrow(); }); + it('rejects if querystring is true, but groupId falsy', () => { + expect(() => { + renderer.create( + + + Good + + , + ); + }).toThrow( + 'Docusaurus error: The component needs to have groupId specified if queryString is true.', + ); + }); }); diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 019129afb0d2..b40f615adc9a 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -88,6 +88,12 @@ function TabsComponent(props: Props): JSX.Element { ); } + if (queryString === true && !groupId) { + throw new Error( + `Docusaurus error: The component needs to have groupId specified if queryString is true.`, + ); + } + const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoice(); // search params > // local storage > From 7eb133476bc8afe0fa404badf91c9237ac3af45a Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Sun, 30 Oct 2022 19:04:13 +0100 Subject: [PATCH 09/20] chore: Remove unnecessary env var. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 31db9c7c4131..7d6513841978 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "lint:spelling": "cspell \"**\" --no-progress", "lint:style": "stylelint \"**/*.css\"", "lerna": "lerna", - "test": "cross-env TEST=true jest", + "test": "jest", "test:build:website": "./admin/scripts/test-release.sh", "watch": "yarn lerna run --parallel watch", "clear": "(yarn workspace website clear || echo 'Failure while running docusaurus clear') && yarn lerna exec --ignore docusaurus yarn rimraf lib", From 66a39399671f28b1c353cd6685f96ae24a14e0bf Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Sun, 30 Oct 2022 19:05:05 +0100 Subject: [PATCH 10/20] chore: Refactor. --- .../src/theme/Tabs/__tests__/index.test.tsx | 3 +- .../src/theme/Tabs/index.tsx | 52 ++++++++++++++----- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx index dd12fba15f92..672aaee101bd 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx @@ -149,7 +149,8 @@ describe('Tabs', () => { renderer.create( - Good + Val1 + Val2 , ); diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index b40f615adc9a..f3adefd2322c 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -8,6 +8,8 @@ import React, { cloneElement, isValidElement, + useCallback, + useEffect, useState, type ReactElement, } from 'react'; @@ -31,6 +33,32 @@ function isTabItem( return 'value' in comp.props; } +const getSearchKey = (queryString?: string | boolean, groupId = '') => + typeof queryString === 'string' ? queryString : groupId; +const useTabQueryString = () => { + const location = useLocation(); + const history = useHistory(); + + const get = useCallback( + (queryString?: string | boolean, groupId = '') => { + const searchKey = getSearchKey(queryString, groupId); + return new URLSearchParams(location.search).get(searchKey); + }, + [location.search], + ); + const set = useCallback( + (newTabValue: string, queryString: string | boolean, groupId = '') => { + const searchKey = getSearchKey(queryString, groupId); + const searchParams = new URLSearchParams(location.search); + searchParams.set(searchKey, newTabValue); + history.replace({...location, search: searchParams.toString()}); + }, + [history, location], + ); + + return {get, set}; +}; + function TabsComponent(props: Props): JSX.Element { const { lazy, @@ -41,8 +69,6 @@ function TabsComponent(props: Props): JSX.Element { className, queryString = false, } = props; - const location = useLocation(); - const history = useHistory(); const children = React.Children.map(props.children, (child) => { if (isValidElement(child) && isTabItem(child)) { return child; @@ -56,6 +82,7 @@ function TabsComponent(props: Props): JSX.Element { }>: all children of the component should be , and every should have a unique "value" prop.`, ); }); + const tabQueryString = useTabQueryString(); const values = valuesProp ?? // Only pick keys that we recognize. MDX would inject some keys by default @@ -95,17 +122,11 @@ function TabsComponent(props: Props): JSX.Element { } const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoice(); - // search params > // local storage > // specified defaultValue > // first child with "default" attr > // first tab item. let defaultValue: string | null | undefined; - if (queryString) { - const searchKey = - typeof queryString === 'string' ? queryString : groupId || ''; - defaultValue = new URLSearchParams(location.search).get(searchKey); - } // If we didn't find the right value in search params or local storage, // fallback to props > child with "default" specified > first tab. if (!defaultValue || !values.some((a) => a.value === defaultValue)) { @@ -147,11 +168,7 @@ function TabsComponent(props: Props): JSX.Element { setSelectedValue(newTabValue); if (queryString) { - const searchKey = - typeof queryString === 'string' ? queryString : groupId || ''; - const searchParams = new URLSearchParams(location.search); - searchParams.set(searchKey, newTabValue); - history.push({...location, search: searchParams.toString()}); + tabQueryString.set(newTabValue, queryString, groupId); } if (groupId != null) { @@ -184,6 +201,15 @@ function TabsComponent(props: Props): JSX.Element { focusElement?.focus(); }; + useEffect(() => { + if (queryString) { + const queryValue = tabQueryString.get(queryString, groupId); + if (queryValue) { + setSelectedValue(queryValue); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return (
From 4213dc2aceb538f548b02ddb04f2dfc481925f85 Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Sun, 30 Oct 2022 19:06:19 +0100 Subject: [PATCH 11/20] docs: Document the tab querystring behavior. --- .../markdown-features-tabs.mdx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/website/docs/guides/markdown-features/markdown-features-tabs.mdx b/website/docs/guides/markdown-features/markdown-features-tabs.mdx index 897f54a8a7c0..d66d713ff9d0 100644 --- a/website/docs/guides/markdown-features/markdown-features-tabs.mdx +++ b/website/docs/guides/markdown-features/markdown-features-tabs.mdx @@ -318,3 +318,33 @@ li[role='tab'][data-value='apple'] { ``` ::: + +## Query string + +If you need to share a URL with some tabs already preselected (e.g. a specific programming language) you can use a `queryString`. It can be a string that represents the search query key for the corresponding tab. + +```mdx-code-block + + + Android content + iOS content + + +``` + +Another option is to specify it as a boolean with a value of `true`. This configuration uses existing `groupId` as a search query key, thus the `groupId` attribute must be specified. + +```mdx-code-block + + + Android content + iOS content + + + Android content + iOS content + + +``` + +The `queryString` param is `false` by default. From 60813d82967bc33bb588ffc813ce0abf9a9694bb Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Sun, 30 Oct 2022 19:20:04 +0100 Subject: [PATCH 12/20] chore: Remove unnecessary and outdated comments. --- packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index f3adefd2322c..16ffd933f5ac 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -122,13 +122,7 @@ function TabsComponent(props: Props): JSX.Element { } const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoice(); - // local storage > - // specified defaultValue > - // first child with "default" attr > - // first tab item. let defaultValue: string | null | undefined; - // If we didn't find the right value in search params or local storage, - // fallback to props > child with "default" specified > first tab. if (!defaultValue || !values.some((a) => a.value === defaultValue)) { defaultValue = defaultValueProp !== undefined From 5e38a1f54dc598c57e228d1ab76d42469eef9578 Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Sun, 30 Oct 2022 19:21:57 +0100 Subject: [PATCH 13/20] fix: Remove forgotten code for default value. --- .../src/theme/Tabs/index.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 16ffd933f5ac..0b57abb0977d 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -122,14 +122,11 @@ function TabsComponent(props: Props): JSX.Element { } const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoice(); - let defaultValue: string | null | undefined; - if (!defaultValue || !values.some((a) => a.value === defaultValue)) { - defaultValue = - defaultValueProp !== undefined - ? defaultValueProp - : children.find((child) => child.props.default)?.props.value ?? - children[0]!.props.value; - } + const defaultValue = + defaultValueProp !== undefined + ? defaultValueProp + : children.find((child) => child.props.default)?.props.value ?? + children[0]!.props.value; const [selectedValue, setSelectedValue] = useState(defaultValue); const tabRefs: (HTMLLIElement | null)[] = []; From f8eb75182efe0c7fd084bad235daf179cade322e Mon Sep 17 00:00:00 2001 From: sebastienlorber Date: Fri, 9 Dec 2022 15:16:18 +0100 Subject: [PATCH 14/20] better querystring implementation refactor --- .../src/theme/Tabs/__tests__/index.test.tsx | 190 +++++++++++------- .../src/theme/Tabs/index.tsx | 77 ++++--- 2 files changed, 158 insertions(+), 109 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx index 672aaee101bd..9958197cd29e 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import React from 'react'; +import React, {type ReactNode} from 'react'; import renderer from 'react-test-renderer'; import { TabGroupChoiceProvider, @@ -15,16 +15,32 @@ import {StaticRouter} from 'react-router-dom'; import Tabs from '../index'; import TabItem from '../../TabItem'; +function TestProviders({ + children, + pathname = '/', +}: { + children: ReactNode; + pathname?: string; +}) { + return ( + + + {children} + + + ); +} + describe('Tabs', () => { it('rejects bad Tabs child', () => { expect(() => { renderer.create( - +
Naughty
Good
-
, + , ); }).toThrowErrorMatchingInlineSnapshot( `"Docusaurus error: Bad child
: all children of the component should be , and every should have a unique "value" prop."`, @@ -33,12 +49,12 @@ describe('Tabs', () => { it('rejects bad Tabs defaultValue', () => { expect(() => { renderer.create( - + Tab 1 Tab 2 - , + , ); }).toThrowErrorMatchingInlineSnapshot( `"Docusaurus error: The has a defaultValue "bad" but none of its children has the corresponding value. Available values are: v1, v2. If you intend to show no default tab, use defaultValue={null} instead."`, @@ -47,7 +63,7 @@ describe('Tabs', () => { it('rejects duplicate values', () => { expect(() => { renderer.create( - + Tab 1 Tab 2 @@ -56,7 +72,7 @@ describe('Tabs', () => { Tab 5 Tab 6 - , + , ); }).toThrowErrorMatchingInlineSnapshot( `"Docusaurus error: Duplicate values "v1, v2" found in . Every value needs to be unique."`, @@ -65,56 +81,52 @@ describe('Tabs', () => { it('accepts valid Tabs config', () => { expect(() => { renderer.create( - - - - - Tab 1 - Tab 2 - - - Tab 1 - - Tab 2 - - - - - Tab 1 - - - Tab 2 - - - - Tab 1 - Tab 2 - - - Tab 1 - Tab 2 - - - - Tab 1 - - - Tab 2 - - - - - , + + + Tab 1 + Tab 2 + + + Tab 1 + + Tab 2 + + + + + Tab 1 + + + Tab 2 + + + + Tab 1 + Tab 2 + + + Tab 1 + Tab 2 + + + + Tab 1 + + + Tab 2 + + + , ); }).not.toThrow(); // TODO Better Jest infrastructure to mock the Layout }); @@ -123,39 +135,61 @@ describe('Tabs', () => { expect(() => { const tabs = ['Apple', 'Banana', 'Carrot']; renderer.create( - - - - ({label: t, value: idx}))} - // @ts-expect-error: for an edge-case that we didn't write types for - defaultValue={0}> - {tabs.map((t, idx) => ( - // @ts-expect-error: for an edge-case that we didn't write types for - - {t} - - ))} - - - - , + + ({label: t, value: idx}))} + // @ts-expect-error: for an edge-case that we didn't write types for + defaultValue={0}> + {tabs.map((t, idx) => ( + // @ts-expect-error: for an edge-case that we didn't write types for + + {t} + + ))} + + , ); }).not.toThrow(); }); it('rejects if querystring is true, but groupId falsy', () => { expect(() => { renderer.create( - + Val1 Val2 - , + , ); }).toThrow( - 'Docusaurus error: The component needs to have groupId specified if queryString is true.', + '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".', ); }); + + it('accept querystring=true when groupId is defined', () => { + expect(() => { + renderer.create( + + + Val1 + Val2 + + , + ); + }).not.toThrow(); + }); + + it('accept querystring as string, but groupId falsy', () => { + expect(() => { + renderer.create( + + + Val1 + Val2 + + , + ); + }).not.toThrow(); + }); }); diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 0b57abb0977d..bfffa5c27639 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -33,31 +33,56 @@ function isTabItem( return 'value' in comp.props; } -const getSearchKey = (queryString?: string | boolean, groupId = '') => - typeof queryString === 'string' ? queryString : groupId; -const useTabQueryString = () => { +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 get = useCallback( - (queryString?: string | boolean, groupId = '') => { - const searchKey = getSearchKey(queryString, groupId); - return new URLSearchParams(location.search).get(searchKey); - }, - [location.search], - ); + 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, queryString: string | boolean, groupId = '') => { - const searchKey = getSearchKey(queryString, groupId); + (newTabValue: string) => { + if (!searchKey) { + return; // no-op + } const searchParams = new URLSearchParams(location.search); searchParams.set(searchKey, newTabValue); history.replace({...location, search: searchParams.toString()}); }, - [history, location], + [searchKey, history, location], ); return {get, set}; -}; +} function TabsComponent(props: Props): JSX.Element { const { @@ -82,7 +107,7 @@ function TabsComponent(props: Props): JSX.Element { }>: all children of the component should be , and every should have a unique "value" prop.`, ); }); - const tabQueryString = useTabQueryString(); + const tabQueryString = useTabQueryString({queryString, groupId}); const values = valuesProp ?? // Only pick keys that we recognize. MDX would inject some keys by default @@ -115,12 +140,6 @@ function TabsComponent(props: Props): JSX.Element { ); } - if (queryString === true && !groupId) { - throw new Error( - `Docusaurus error: The component needs to have groupId specified if queryString is true.`, - ); - } - const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoice(); const defaultValue = defaultValueProp !== undefined @@ -157,11 +176,7 @@ function TabsComponent(props: Props): JSX.Element { if (newTabValue !== selectedValue) { blockElementScrollPositionUntilNextRender(newTab); setSelectedValue(newTabValue); - - if (queryString) { - tabQueryString.set(newTabValue, queryString, groupId); - } - + tabQueryString.set(newTabValue); if (groupId != null) { setTabGroupChoices(groupId, String(newTabValue)); } @@ -192,13 +207,13 @@ function TabsComponent(props: Props): JSX.Element { focusElement?.focus(); }; + useEffect(() => { - if (queryString) { - const queryValue = tabQueryString.get(queryString, groupId); - if (queryValue) { - setSelectedValue(queryValue); - } + const queryValue = tabQueryString.get(); + if (queryValue) { + setSelectedValue(queryValue); } + // TODO bad React code but should be ok for now // eslint-disable-next-line react-hooks/exhaustive-deps }, []); From 13aec5d96314211851afc886ccbe1baf29de6af8 Mon Sep 17 00:00:00 2001 From: sebastienlorber Date: Fri, 9 Dec 2022 15:16:45 +0100 Subject: [PATCH 15/20] better tab querystring doc --- .../markdown-features-tabs.mdx | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/website/docs/guides/markdown-features/markdown-features-tabs.mdx b/website/docs/guides/markdown-features/markdown-features-tabs.mdx index d66d713ff9d0..3608685d05e5 100644 --- a/website/docs/guides/markdown-features/markdown-features-tabs.mdx +++ b/website/docs/guides/markdown-features/markdown-features-tabs.mdx @@ -319,32 +319,40 @@ li[role='tab'][data-value='apple'] { ::: -## Query string +## Query string {#query-string} -If you need to share a URL with some tabs already preselected (e.g. a specific programming language) you can use a `queryString`. It can be a string that represents the search query key for the corresponding tab. +It is possible to persist the selected tab into the url search parameters. This enables deep linking: the ability to share or bookmark a link to a specific tab, that will be pre-selected when the page loads. -```mdx-code-block - - - Android content - iOS content - - +Use the `queryString` prop to enable this feature and define the search param name to use. + +```tsx + + + Android content + + + iOS content + + ``` -Another option is to specify it as a boolean with a value of `true`. This configuration uses existing `groupId` as a search query key, thus the `groupId` attribute must be specified. +As soon as a tab is clicked, a search parameter is added at the end of the url: `?current-os=android` or `?current-os=ios`. ```mdx-code-block - - Android content - iOS content - - + Android content iOS content ``` -The `queryString` param is `false` by default. +:::tip + +When `groupId` is defined, its value is used as a default for `queryString`, and you can simply declare: + +```tsx + +``` + +::: From 3d2a36f7c53730503303ca090596424e4e49be48 Mon Sep 17 00:00:00 2001 From: sebastienlorber Date: Fri, 9 Dec 2022 15:45:07 +0100 Subject: [PATCH 16/20] ensure queryString wins over localStorage --- .../src/theme/Tabs/index.tsx | 18 +++++++++++++----- .../src/contexts/tabGroupChoice.tsx | 8 ++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index bfffa5c27639..5ac77a9721a3 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -140,7 +140,11 @@ function TabsComponent(props: Props): JSX.Element { ); } - const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoice(); + const { + ready: tabGroupChoicesReady, + tabGroupChoices, + setTabGroupChoices, + } = useTabGroupChoice(); const defaultValue = defaultValueProp !== undefined ? defaultValueProp @@ -209,13 +213,17 @@ function TabsComponent(props: Props): JSX.Element { }; useEffect(() => { - const queryValue = tabQueryString.get(); - if (queryValue) { - setSelectedValue(queryValue); + // The querystring value should be used in priority over stored value + // so we need to execute effect after stored value restoration + if (tabGroupChoicesReady) { + const queryValue = tabQueryString.get(); + if (queryValue) { + setSelectedValue(queryValue); + } } // TODO bad React code but should be ok for now // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [tabGroupChoicesReady]); return (
diff --git a/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx b/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx index 530b4bc90ad4..bb4eb3a97ece 100644 --- a/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx +++ b/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx @@ -19,6 +19,8 @@ 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}; /** Set the new choice value of a group. */ @@ -28,6 +30,7 @@ type ContextValue = { const Context = React.createContext(undefined); function useContextValue(): ContextValue { + const [ready, setReady] = useState(false); const [tabGroupChoices, setChoices] = useState<{ readonly [groupId: string]: string; }>({}); @@ -51,6 +54,7 @@ function useContextValue(): ContextValue { } catch (err) { console.error(err); } + setReady(true); }, []); const setTabGroupChoices = useCallback( @@ -62,8 +66,8 @@ function useContextValue(): ContextValue { ); return useMemo( - () => ({tabGroupChoices, setTabGroupChoices}), - [tabGroupChoices, setTabGroupChoices], + () => ({ready, tabGroupChoices, setTabGroupChoices}), + [ready, tabGroupChoices, setTabGroupChoices], ); } From 0d48df60c6798fa84e1ab639e1056a9cf9fd61c0 Mon Sep 17 00:00:00 2001 From: sebastienlorber Date: Fri, 9 Dec 2022 15:47:15 +0100 Subject: [PATCH 17/20] doc --- .../markdown-features-tabs.mdx | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/website/docs/guides/markdown-features/markdown-features-tabs.mdx b/website/docs/guides/markdown-features/markdown-features-tabs.mdx index 3608685d05e5..0b6f85b6bdac 100644 --- a/website/docs/guides/markdown-features/markdown-features-tabs.mdx +++ b/website/docs/guides/markdown-features/markdown-features-tabs.mdx @@ -328,31 +328,51 @@ Use the `queryString` prop to enable this feature and define the search param na ```tsx - Android content + Android - iOS content + iOS ``` -As soon as a tab is clicked, a search parameter is added at the end of the url: `?current-os=android` or `?current-os=ios`. - ```mdx-code-block - Android content - iOS content + Android + iOS ``` +As soon as a tab is clicked, a search parameter is added at the end of the url: `?current-os=android` or `?current-os=ios`. + :::tip -When `groupId` is defined, its value is used as a default for `queryString`, and you can simply declare: +`queryString` can be used together with `groupId`. + +For convenience, when the `queryString` prop is `true`, the `groupId` value will be used as a fallback. ```tsx - + + + Android + + + iOS + + +``` + +```mdx-code-block + + + Android + iOS + + ``` +When the page loads, the tab query string choice will be restored in priority over the `groupId` choice (using `localStorage`). + ::: From 5264f83dff040fc5adc07296628403cfed31193a Mon Sep 17 00:00:00 2001 From: sebastienlorber Date: Fri, 9 Dec 2022 16:10:42 +0100 Subject: [PATCH 18/20] // highlight-next-line --- .../docs/guides/markdown-features/markdown-features-tabs.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/docs/guides/markdown-features/markdown-features-tabs.mdx b/website/docs/guides/markdown-features/markdown-features-tabs.mdx index 0b6f85b6bdac..948c61ef6757 100644 --- a/website/docs/guides/markdown-features/markdown-features-tabs.mdx +++ b/website/docs/guides/markdown-features/markdown-features-tabs.mdx @@ -326,6 +326,7 @@ It is possible to persist the selected tab into the url search parameters. This Use the `queryString` prop to enable this feature and define the search param name to use. ```tsx +// highlight-next-line Android @@ -354,6 +355,7 @@ As soon as a tab is clicked, a search parameter is added at the end of the url: For convenience, when the `queryString` prop is `true`, the `groupId` value will be used as a fallback. ```tsx +// highlight-next-line Android From 1bd6561b174ff7dc378446f2e4feaa7637ec114c Mon Sep 17 00:00:00 2001 From: sebastienlorber Date: Fri, 9 Dec 2022 16:12:41 +0100 Subject: [PATCH 19/20] fix scrollAfterNavigation logic to not mess-up with query-string changes from --- .../src/client/ClientLifecyclesDispatcher.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/docusaurus/src/client/ClientLifecyclesDispatcher.tsx b/packages/docusaurus/src/client/ClientLifecyclesDispatcher.tsx index e9cb7f8d5f5a..c7b433686421 100644 --- a/packages/docusaurus/src/client/ClientLifecyclesDispatcher.tsx +++ b/packages/docusaurus/src/client/ClientLifecyclesDispatcher.tsx @@ -27,7 +27,26 @@ export function dispatchLifecycleAction( return () => callbacks.forEach((cb) => cb?.()); } -function scrollAfterNavigation(location: Location) { +function scrollAfterNavigation({ + location, + previousLocation, +}: { + location: Location; + previousLocation: Location | null; +}) { + if (!previousLocation) { + return; // no-op: use native browser feature + } + + const samePathname = location.pathname === previousLocation.pathname; + const sameHash = location.hash === previousLocation.hash; + const sameSearch = location.search === previousLocation.search; + + // Query-string changes: do not scroll to top/hash + if (samePathname && sameHash && !sameSearch) { + return; + } + const {hash} = location; if (!hash) { window.scrollTo(0, 0); @@ -49,9 +68,7 @@ function ClientLifecyclesDispatcher({ }): JSX.Element { useLayoutEffect(() => { if (previousLocation !== location) { - if (previousLocation) { - scrollAfterNavigation(location); - } + scrollAfterNavigation({location, previousLocation}); dispatchLifecycleAction('onRouteDidUpdate', {previousLocation, location}); } }, [previousLocation, location]); From c2eca08582736a0fc2dddc71575c5dcc65c5e894 Mon Sep 17 00:00:00 2001 From: sebastienlorber Date: Fri, 9 Dec 2022 17:00:23 +0100 Subject: [PATCH 20/20] fix tab selected value restoration logic --- .../src/theme/Tabs/index.tsx | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 5ac77a9721a3..303d84375e2f 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -15,7 +15,7 @@ import React, { } from 'react'; import clsx from 'clsx'; import {useHistory, useLocation} from '@docusaurus/router'; -import {duplicates} from '@docusaurus/theme-common'; +import {duplicates, useEvent} from '@docusaurus/theme-common'; import { useScrollPositionBlocker, useTabGroupChoice, @@ -156,16 +156,29 @@ function TabsComponent(props: Props): JSX.Element { const {blockElementScrollPositionUntilNextRender} = useScrollPositionBlocker(); - if (groupId != null) { - const relevantTabGroupChoice = tabGroupChoices[groupId]; - if ( - relevantTabGroupChoice != null && - relevantTabGroupChoice !== selectedValue && - values.some((value) => value.value === relevantTabGroupChoice) - ) { - setSelectedValue(relevantTabGroupChoice); + // 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: @@ -212,19 +225,6 @@ function TabsComponent(props: Props): JSX.Element { focusElement?.focus(); }; - useEffect(() => { - // The querystring value should be used in priority over stored value - // so we need to execute effect after stored value restoration - if (tabGroupChoicesReady) { - const queryValue = tabQueryString.get(); - if (queryValue) { - setSelectedValue(queryValue); - } - } - // TODO bad React code but should be ok for now - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tabGroupChoicesReady]); - return (