From 948271a0ff070c80c3bdd6d69efd9ede89009a55 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 22 Mar 2022 15:33:55 +0800 Subject: [PATCH] test: improve test coverage; reorder theme-common files (#6956) * test: improve test coverage; reorder theme-common files * no need for this --- jest.config.mjs | 14 ++- package.json | 1 + .../src/components/Details/index.tsx | 2 +- .../announcementBar.tsx} | 6 +- .../colorMode.tsx} | 10 +- .../docSidebarItemsExpandedState.tsx | 2 +- .../docsPreferredVersion.tsx} | 93 ++++++++++++++- .../src/contexts/navbarMobileSidebar.tsx | 98 ++++++++++++++++ .../tabGroupChoice.tsx} | 4 +- .../src/hooks/useHideableNavbar.ts | 8 +- .../src/hooks/useKeyboardNavigation.ts | 8 +- .../src/hooks/useLockBodyScroll.ts | 2 +- .../src/hooks/usePrismTheme.ts | 4 +- .../src/hooks/useSearchPage.ts | 6 +- .../src/{utils => hooks}/useTOCHighlight.ts | 6 +- .../src/hooks/useWindowSize.ts | 2 +- packages/docusaurus-theme-common/src/index.ts | 46 ++++---- .../src/utils/__tests__/reactUtils.test.ts | 22 ++++ .../DocsPreferredVersionStorage.ts | 33 ------ .../useDocsPreferredVersion.ts | 70 ----------- ...yMenuUtils.tsx => navbarSecondaryMenu.tsx} | 5 +- .../src/utils/navbarUtils.tsx | 109 ++---------------- .../src/utils/pathUtils.ts | 4 +- .../src/utils/reactUtils.tsx | 18 ++- .../src/utils/routesUtils.ts | 7 +- .../src/utils/useContextualSearchFilters.ts | 2 +- .../src/utils/useLocalPathname.ts | 8 +- .../src/utils/useLocationChange.ts | 3 +- .../src/utils/usePluralForm.ts | 14 +-- .../src/utils/usePrevious.ts | 19 --- .../client/__tests__/routeContext.test.tsx | 89 ++++++++++++++ .../client/exports/__tests__/Head.test.tsx | 44 +++++++ .../__snapshots__/Head.test.tsx.snap | 40 +++++++ .../__tests__/useRouteContext.test.tsx | 38 ++++++ .../src/client/exports/useRouteContext.tsx | 4 +- .../docusaurus/src/client/routeContext.tsx | 9 +- website/src/utils/__tests__/jsUtils.test.ts | 17 +++ yarn.lock | 5 + 38 files changed, 555 insertions(+), 317 deletions(-) rename packages/docusaurus-theme-common/src/{utils/announcementBarUtils.tsx => contexts/announcementBar.tsx} (94%) rename packages/docusaurus-theme-common/src/{utils/colorModeUtils.tsx => contexts/colorMode.tsx} (95%) rename packages/docusaurus-theme-common/src/{utils => contexts}/docSidebarItemsExpandedState.tsx (95%) rename packages/docusaurus-theme-common/src/{utils/docsPreferredVersion/DocsPreferredVersionProvider.tsx => contexts/docsPreferredVersion.tsx} (63%) create mode 100644 packages/docusaurus-theme-common/src/contexts/navbarMobileSidebar.tsx rename packages/docusaurus-theme-common/src/{utils/tabGroupChoiceUtils.tsx => contexts/tabGroupChoice.tsx} (94%) rename packages/docusaurus-theme-common/src/{utils => hooks}/useTOCHighlight.ts (97%) create mode 100644 packages/docusaurus-theme-common/src/utils/__tests__/reactUtils.test.ts delete mode 100644 packages/docusaurus-theme-common/src/utils/docsPreferredVersion/DocsPreferredVersionStorage.ts delete mode 100644 packages/docusaurus-theme-common/src/utils/docsPreferredVersion/useDocsPreferredVersion.ts rename packages/docusaurus-theme-common/src/utils/{navbarSecondaryMenuUtils.tsx => navbarSecondaryMenu.tsx} (96%) delete mode 100644 packages/docusaurus-theme-common/src/utils/usePrevious.ts create mode 100644 packages/docusaurus/src/client/__tests__/routeContext.test.tsx create mode 100644 packages/docusaurus/src/client/exports/__tests__/Head.test.tsx create mode 100644 packages/docusaurus/src/client/exports/__tests__/__snapshots__/Head.test.tsx.snap create mode 100644 packages/docusaurus/src/client/exports/__tests__/useRouteContext.test.tsx create mode 100644 website/src/utils/__tests__/jsUtils.test.ts diff --git a/jest.config.mjs b/jest.config.mjs index c927d764c5..6a012fdadb 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -14,13 +14,16 @@ const ignorePatterns = [ '__fixtures__', '/testUtils.ts', '/packages/docusaurus/lib', + '/packages/docusaurus-logger/lib', '/packages/docusaurus-utils/lib', + '/packages/docusaurus-utils-common/lib', '/packages/docusaurus-utils-validation/lib', '/packages/docusaurus-plugin-content-blog/lib', '/packages/docusaurus-plugin-content-docs/lib', '/packages/docusaurus-plugin-content-pages/lib', '/packages/docusaurus-theme-classic/lib', '/packages/docusaurus-theme-classic/lib-next', + '/packages/docusaurus-theme-common/lib', '/packages/docusaurus-migrate/lib', ]; @@ -30,7 +33,11 @@ export default { testURL: 'https://docusaurus.io/', testEnvironment: 'node', testPathIgnorePatterns: ignorePatterns, - coveragePathIgnorePatterns: ignorePatterns, + coveragePathIgnorePatterns: [ + ...ignorePatterns, + // We also ignore all package entry points + '/packages/docusaurus-utils/src/index.ts', + ], transform: { '^.+\\.[jt]sx?$': '@swc/jest', }, @@ -54,7 +61,10 @@ export default { '@docusaurus/plugin-content-docs/client': '@docusaurus/plugin-content-docs/src/client/index.ts', }, - snapshotSerializers: ['/jest/snapshotPathNormalizer.ts'], + snapshotSerializers: [ + '/jest/snapshotPathNormalizer.ts', + 'jest-serializer-react-helmet-async', + ], snapshotFormat: { printBasicPrototype: false, }, diff --git a/package.json b/package.json index 6b0b2dc0ac..8a79ccd54c 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "husky": "^7.0.4", "image-size": "^1.0.1", "jest": "^27.5.1", + "jest-serializer-react-helmet-async": "^1.0.21", "lerna": "^4.0.0", "lerna-changelog": "^2.2.0", "lint-staged": "^12.3.7", diff --git a/packages/docusaurus-theme-common/src/components/Details/index.tsx b/packages/docusaurus-theme-common/src/components/Details/index.tsx index d5b1b77f5a..b8e172d62a 100644 --- a/packages/docusaurus-theme-common/src/components/Details/index.tsx +++ b/packages/docusaurus-theme-common/src/components/Details/index.tsx @@ -34,7 +34,7 @@ export type DetailsProps = { summary?: ReactElement; } & ComponentProps<'details'>; -export default function Details({ +export function Details({ summary, children, ...props diff --git a/packages/docusaurus-theme-common/src/utils/announcementBarUtils.tsx b/packages/docusaurus-theme-common/src/contexts/announcementBar.tsx similarity index 94% rename from packages/docusaurus-theme-common/src/utils/announcementBarUtils.tsx rename to packages/docusaurus-theme-common/src/contexts/announcementBar.tsx index 93eb7cbeff..01fe0ac848 100644 --- a/packages/docusaurus-theme-common/src/utils/announcementBarUtils.tsx +++ b/packages/docusaurus-theme-common/src/contexts/announcementBar.tsx @@ -14,9 +14,9 @@ import React, { type ReactNode, } from 'react'; import useIsBrowser from '@docusaurus/useIsBrowser'; -import {createStorageSlot} from './storageUtils'; -import {ReactContextError} from './reactUtils'; -import {useThemeConfig} from './useThemeConfig'; +import {createStorageSlot} from '../utils/storageUtils'; +import {ReactContextError} from '../utils/reactUtils'; +import {useThemeConfig} from '../utils/useThemeConfig'; export const AnnouncementBarDismissStorageKey = 'docusaurus.announcement.dismiss'; diff --git a/packages/docusaurus-theme-common/src/utils/colorModeUtils.tsx b/packages/docusaurus-theme-common/src/contexts/colorMode.tsx similarity index 95% rename from packages/docusaurus-theme-common/src/utils/colorModeUtils.tsx rename to packages/docusaurus-theme-common/src/contexts/colorMode.tsx index cc6da81807..7fe480d32b 100644 --- a/packages/docusaurus-theme-common/src/utils/colorModeUtils.tsx +++ b/packages/docusaurus-theme-common/src/contexts/colorMode.tsx @@ -14,11 +14,11 @@ import React, { useRef, type ReactNode, } from 'react'; -import {ReactContextError} from './reactUtils'; +import {ReactContextError} from '../utils/reactUtils'; import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; -import {createStorageSlot} from './storageUtils'; -import {useThemeConfig} from './useThemeConfig'; +import {createStorageSlot} from '../utils/storageUtils'; +import {useThemeConfig} from '../utils/useThemeConfig'; type ColorModeContextValue = { readonly colorMode: ColorMode; @@ -171,9 +171,7 @@ export function ColorModeProvider({ } export function useColorMode(): ColorModeContextValue { - const context = useContext( - ColorModeContext, - ); + const context = useContext(ColorModeContext); if (context == null) { throw new ReactContextError( 'ColorModeProvider', diff --git a/packages/docusaurus-theme-common/src/utils/docSidebarItemsExpandedState.tsx b/packages/docusaurus-theme-common/src/contexts/docSidebarItemsExpandedState.tsx similarity index 95% rename from packages/docusaurus-theme-common/src/utils/docSidebarItemsExpandedState.tsx rename to packages/docusaurus-theme-common/src/contexts/docSidebarItemsExpandedState.tsx index e3f040db6c..7268702480 100644 --- a/packages/docusaurus-theme-common/src/utils/docSidebarItemsExpandedState.tsx +++ b/packages/docusaurus-theme-common/src/contexts/docSidebarItemsExpandedState.tsx @@ -6,7 +6,7 @@ */ import React, {type ReactNode, useMemo, useState, useContext} from 'react'; -import {ReactContextError} from './reactUtils'; +import {ReactContextError} from '../utils/reactUtils'; const EmptyContext: unique symbol = Symbol('EmptyContext'); const Context = React.createContext< diff --git a/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/DocsPreferredVersionProvider.tsx b/packages/docusaurus-theme-common/src/contexts/docsPreferredVersion.tsx similarity index 63% rename from packages/docusaurus-theme-common/src/utils/docsPreferredVersion/DocsPreferredVersionProvider.tsx rename to packages/docusaurus-theme-common/src/contexts/docsPreferredVersion.tsx index e425d86d53..a1c95d5921 100644 --- a/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/DocsPreferredVersionProvider.tsx +++ b/packages/docusaurus-theme-common/src/contexts/docsPreferredVersion.tsx @@ -10,18 +10,47 @@ import React, { useEffect, useMemo, useState, + useCallback, type ReactNode, } from 'react'; -import {useThemeConfig, type DocsVersionPersistence} from '../useThemeConfig'; -import {isDocsPluginEnabled} from '../docsUtils'; -import {ReactContextError} from '../reactUtils'; +import { + useThemeConfig, + type DocsVersionPersistence, +} from '../utils/useThemeConfig'; +import {isDocsPluginEnabled} from '../utils/docsUtils'; +import {ReactContextError} from '../utils/reactUtils'; +import {createStorageSlot} from '../utils/storageUtils'; import { useAllDocsData, + useDocsData, type GlobalPluginData, + type GlobalVersion, } from '@docusaurus/plugin-content-docs/client'; -import DocsPreferredVersionStorage from './DocsPreferredVersionStorage'; +import {DEFAULT_PLUGIN_ID} from '@docusaurus/constants'; + +const storageKey = (pluginId: string) => `docs-preferred-version-${pluginId}`; + +const DocsPreferredVersionStorage = { + save: ( + pluginId: string, + persistence: DocsVersionPersistence, + versionName: string, + ): void => { + createStorageSlot(storageKey(pluginId), {persistence}).set(versionName); + }, + + read: ( + pluginId: string, + persistence: DocsVersionPersistence, + ): string | null => + createStorageSlot(storageKey(pluginId), {persistence}).get(), + + clear: (pluginId: string, persistence: DocsVersionPersistence): void => { + createStorageSlot(storageKey(pluginId), {persistence}).del(); + }, +}; type DocsPreferredVersionName = string | null; @@ -158,10 +187,64 @@ function DocsPreferredVersionContextProviderUnsafe({ return {children}; } -export function useDocsPreferredVersionContext(): DocsPreferredVersionContextValue { +function useDocsPreferredVersionContext(): DocsPreferredVersionContextValue { const value = useContext(Context); if (!value) { throw new ReactContextError('DocsPreferredVersionContextProvider'); } return value; } + +// Note, the preferredVersion attribute will always be null before mount +export function useDocsPreferredVersion( + pluginId: string | undefined = DEFAULT_PLUGIN_ID, +): { + preferredVersion: GlobalVersion | null | undefined; + savePreferredVersionName: (versionName: string) => void; +} { + const docsData = useDocsData(pluginId); + const [state, api] = useDocsPreferredVersionContext(); + + const {preferredVersionName} = state[pluginId]!; + + const preferredVersion = preferredVersionName + ? docsData.versions.find((version) => version.name === preferredVersionName) + : null; + + const savePreferredVersionName = useCallback( + (versionName: string) => { + api.savePreferredVersion(pluginId, versionName); + }, + [api, pluginId], + ); + + return {preferredVersion, savePreferredVersionName} as const; +} + +export function useDocsPreferredVersionByPluginId(): Record< + string, + GlobalVersion | null | undefined +> { + const allDocsData = useAllDocsData(); + const [state] = useDocsPreferredVersionContext(); + + function getPluginIdPreferredVersion(pluginId: string) { + const docsData = allDocsData[pluginId]!; + const {preferredVersionName} = state[pluginId]!; + + return preferredVersionName + ? docsData.versions.find( + (version) => version.name === preferredVersionName, + ) + : null; + } + + const pluginIds = Object.keys(allDocsData); + + const result: Record = {}; + pluginIds.forEach((pluginId) => { + result[pluginId] = getPluginIdPreferredVersion(pluginId); + }); + + return result; +} diff --git a/packages/docusaurus-theme-common/src/contexts/navbarMobileSidebar.tsx b/packages/docusaurus-theme-common/src/contexts/navbarMobileSidebar.tsx new file mode 100644 index 0000000000..76f77974e6 --- /dev/null +++ b/packages/docusaurus-theme-common/src/contexts/navbarMobileSidebar.tsx @@ -0,0 +1,98 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { + type ReactNode, + useCallback, + useEffect, + useState, + useMemo, +} from 'react'; +import {useWindowSize} from '../hooks/useWindowSize'; +import {useHistoryPopHandler} from '../utils/historyUtils'; +import {useActivePlugin} from '@docusaurus/plugin-content-docs/client'; +import {useThemeConfig} from '../utils/useThemeConfig'; +import {ReactContextError} from '../utils/reactUtils'; + +type NavbarMobileSidebarContextValue = { + disabled: boolean; + shouldRender: boolean; + toggle: () => void; + shown: boolean; +}; + +const Context = React.createContext< + NavbarMobileSidebarContextValue | undefined +>(undefined); + +// Mobile sidebar can be disabled in case it would lead to an empty sidebar +// In this case it's not useful to display a navbar sidebar toggle button +function useNavbarMobileSidebarDisabled() { + const activeDocPlugin = useActivePlugin(); + const {items} = useThemeConfig().navbar; + return items.length === 0 && !activeDocPlugin; +} + +function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue { + const disabled = useNavbarMobileSidebarDisabled(); + const windowSize = useWindowSize(); + + // Mobile sidebar not visible until user interaction: can avoid SSR rendering + const shouldRender = !disabled && windowSize === 'mobile'; // || windowSize === 'ssr'; + + const [shown, setShown] = useState(false); + + // Close mobile sidebar on navigation pop + // Most likely firing when using the Android back button (but not only) + useHistoryPopHandler(() => { + if (shown) { + setShown(false); + // Should we prevent the navigation here? + // See https://github.com/facebook/docusaurus/pull/5462#issuecomment-911699846 + return false; // prevent pop navigation + } + return undefined; + }); + + const toggle = useCallback(() => { + setShown((s) => !s); + }, []); + + useEffect(() => { + if (windowSize === 'desktop') { + setShown(false); + } + }, [windowSize]); + + // Return stable context value + return useMemo( + () => ({ + disabled, + shouldRender, + toggle, + shown, + }), + [disabled, shouldRender, toggle, shown], + ); +} + +export function NavbarMobileSidebarProvider({ + children, +}: { + children: ReactNode; +}): JSX.Element { + const value = useNavbarMobileSidebarContextValue(); + return {children}; +} + +export function useNavbarMobileSidebar(): NavbarMobileSidebarContextValue { + const context = React.useContext(Context); + if (context == null) { + throw new ReactContextError('NavbarMobileSidebarProvider'); + } + return context; +} diff --git a/packages/docusaurus-theme-common/src/utils/tabGroupChoiceUtils.tsx b/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx similarity index 94% rename from packages/docusaurus-theme-common/src/utils/tabGroupChoiceUtils.tsx rename to packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx index 02ad080f8c..3b0f14d90c 100644 --- a/packages/docusaurus-theme-common/src/utils/tabGroupChoiceUtils.tsx +++ b/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx @@ -13,8 +13,8 @@ import React, { useContext, type ReactNode, } from 'react'; -import {createStorageSlot, listStorageKeys} from './storageUtils'; -import {ReactContextError} from './reactUtils'; +import {createStorageSlot, listStorageKeys} from '../utils/storageUtils'; +import {ReactContextError} from '../utils/reactUtils'; const TAB_CHOICE_PREFIX = 'docusaurus.tab.'; diff --git a/packages/docusaurus-theme-common/src/hooks/useHideableNavbar.ts b/packages/docusaurus-theme-common/src/hooks/useHideableNavbar.ts index f83486f2c1..62e9b37405 100644 --- a/packages/docusaurus-theme-common/src/hooks/useHideableNavbar.ts +++ b/packages/docusaurus-theme-common/src/hooks/useHideableNavbar.ts @@ -9,14 +9,10 @@ import {useState, useCallback, useRef} from 'react'; import {useLocationChange} from '../utils/useLocationChange'; import {useScrollPosition} from '../utils/scrollUtils'; -type UseHideableNavbarReturns = { +export function useHideableNavbar(hideOnScroll: boolean): { readonly navbarRef: (node: HTMLElement | null) => void; readonly isNavbarVisible: boolean; -}; - -export default function useHideableNavbar( - hideOnScroll: boolean, -): UseHideableNavbarReturns { +} { const [isNavbarVisible, setIsNavbarVisible] = useState(hideOnScroll); const isFocusedAnchor = useRef(false); const navbarHeight = useRef(0); diff --git a/packages/docusaurus-theme-common/src/hooks/useKeyboardNavigation.ts b/packages/docusaurus-theme-common/src/hooks/useKeyboardNavigation.ts index 5d9180a355..be6bfe8ecb 100644 --- a/packages/docusaurus-theme-common/src/hooks/useKeyboardNavigation.ts +++ b/packages/docusaurus-theme-common/src/hooks/useKeyboardNavigation.ts @@ -11,9 +11,11 @@ import './styles.css'; export const keyboardFocusedClassName = 'navigation-with-keyboard'; -// This hook detect keyboard focus indicator to not show outline for mouse users -// Inspired by https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2 -export default function useKeyboardNavigation(): void { +/** + * Detect keyboard focus indicator to not show outline for mouse users + * Inspired by https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2 + */ +export function useKeyboardNavigation(): void { useEffect(() => { function handleOutlineStyles(e: MouseEvent | KeyboardEvent) { if (e.type === 'keydown' && (e as KeyboardEvent).key === 'Tab') { diff --git a/packages/docusaurus-theme-common/src/hooks/useLockBodyScroll.ts b/packages/docusaurus-theme-common/src/hooks/useLockBodyScroll.ts index 2ac498d69e..c35e127fcf 100644 --- a/packages/docusaurus-theme-common/src/hooks/useLockBodyScroll.ts +++ b/packages/docusaurus-theme-common/src/hooks/useLockBodyScroll.ts @@ -7,7 +7,7 @@ import {useEffect} from 'react'; -export default function useLockBodyScroll(lock: boolean = true): void { +export function useLockBodyScroll(lock: boolean = true): void { useEffect(() => { document.body.style.overflow = lock ? 'hidden' : 'visible'; diff --git a/packages/docusaurus-theme-common/src/hooks/usePrismTheme.ts b/packages/docusaurus-theme-common/src/hooks/usePrismTheme.ts index 94bacfff05..3c8f847601 100644 --- a/packages/docusaurus-theme-common/src/hooks/usePrismTheme.ts +++ b/packages/docusaurus-theme-common/src/hooks/usePrismTheme.ts @@ -6,10 +6,10 @@ */ import defaultTheme from 'prism-react-renderer/themes/palenight'; -import {useColorMode} from '../utils/colorModeUtils'; +import {useColorMode} from '../contexts/colorMode'; import {useThemeConfig} from '../utils/useThemeConfig'; -export default function usePrismTheme(): typeof defaultTheme { +export function usePrismTheme(): typeof defaultTheme { const {prism} = useThemeConfig(); const {colorMode} = useColorMode(); const lightModeTheme = prism.theme || defaultTheme; diff --git a/packages/docusaurus-theme-common/src/hooks/useSearchPage.ts b/packages/docusaurus-theme-common/src/hooks/useSearchPage.ts index 62271f89a0..f97c43c97c 100644 --- a/packages/docusaurus-theme-common/src/hooks/useSearchPage.ts +++ b/packages/docusaurus-theme-common/src/hooks/useSearchPage.ts @@ -11,13 +11,11 @@ import {useCallback, useEffect, useState} from 'react'; const SEARCH_PARAM_QUERY = 'q'; -interface UseSearchPageReturn { +export function useSearchPage(): { searchQuery: string; setSearchQuery: (newSearchQuery: string) => void; generateSearchPageLink: (targetSearchQuery: string) => string; -} - -export default function useSearchPage(): UseSearchPageReturn { +} { const history = useHistory(); const { siteConfig: {baseUrl}, diff --git a/packages/docusaurus-theme-common/src/utils/useTOCHighlight.ts b/packages/docusaurus-theme-common/src/hooks/useTOCHighlight.ts similarity index 97% rename from packages/docusaurus-theme-common/src/utils/useTOCHighlight.ts rename to packages/docusaurus-theme-common/src/hooks/useTOCHighlight.ts index 56f0b55d29..aca25bf831 100644 --- a/packages/docusaurus-theme-common/src/utils/useTOCHighlight.ts +++ b/packages/docusaurus-theme-common/src/hooks/useTOCHighlight.ts @@ -6,7 +6,7 @@ */ import {useEffect, useRef} from 'react'; -import {useThemeConfig} from './useThemeConfig'; +import {useThemeConfig} from '../utils/useThemeConfig'; // TODO make the hardcoded theme-classic classnames configurable (or add them // to ThemeClassNames?) @@ -120,9 +120,7 @@ export type TOCHighlightConfig = { maxHeadingLevel: number; }; -export default function useTOCHighlight( - config: TOCHighlightConfig | undefined, -): void { +export function useTOCHighlight(config: TOCHighlightConfig | undefined): void { const lastActiveLinkRef = useRef(undefined); const anchorTopOffsetRef = useAnchorTopOffsetRef(); diff --git a/packages/docusaurus-theme-common/src/hooks/useWindowSize.ts b/packages/docusaurus-theme-common/src/hooks/useWindowSize.ts index 1106e11e8f..b867cf4fd9 100644 --- a/packages/docusaurus-theme-common/src/hooks/useWindowSize.ts +++ b/packages/docusaurus-theme-common/src/hooks/useWindowSize.ts @@ -41,7 +41,7 @@ const DevSimulateSSR = process.env.NODE_ENV === 'development' && true; // This hook returns an enum value on purpose! // We don't want it to return the actual width value, for resize perf reasons // We only want to re-render once a breakpoint is crossed -export default function useWindowSize(): WindowSize { +export function useWindowSize(): WindowSize { const [windowSize, setWindowSize] = useState(() => { if (DevSimulateSSR) { return 'ssr'; diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index b479c3089a..4d0a02ef10 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -22,7 +22,7 @@ export { export { DocSidebarItemsExpandedStateProvider, useDocSidebarItemsExpandedState, -} from './utils/docSidebarItemsExpandedState'; +} from './contexts/docSidebarItemsExpandedState'; export {createStorageSlot, listStorageKeys} from './utils/storageUtils'; @@ -60,8 +60,6 @@ export {usePluralForm} from './utils/usePluralForm'; export {useLocationChange} from './utils/useLocationChange'; -export {usePrevious} from './utils/usePrevious'; - export { useCollapsible, Collapsible, @@ -69,23 +67,22 @@ export { type UseCollapsibleReturns, } from './components/Collapsible'; -export {default as Details, type DetailsProps} from './components/Details'; +export {Details, type DetailsProps} from './components/Details'; export { useDocsPreferredVersion, useDocsPreferredVersionByPluginId, -} from './utils/docsPreferredVersion/useDocsPreferredVersion'; + DocsPreferredVersionContextProvider, +} from './contexts/docsPreferredVersion'; export {duplicates, uniq} from './utils/jsUtils'; -export {DocsPreferredVersionContextProvider} from './utils/docsPreferredVersion/DocsPreferredVersionProvider'; - export {ThemeClassNames} from './utils/ThemeClassNames'; export { AnnouncementBarProvider, useAnnouncementBar, -} from './utils/announcementBarUtils'; +} from './contexts/announcementBar'; export {useLocalPathname} from './utils/useLocalPathname'; @@ -99,9 +96,9 @@ export { export {useHistoryPopHandler} from './utils/historyUtils'; export { - default as useTOCHighlight, + useTOCHighlight, type TOCHighlightConfig, -} from './utils/useTOCHighlight'; +} from './hooks/useTOCHighlight'; export { useFilteredAndTreeifiedTOC, @@ -121,6 +118,7 @@ export { export { useIsomorphicLayoutEffect, useDynamicCallback, + usePrevious, ReactContextError, } from './utils/reactUtils'; @@ -138,30 +136,28 @@ export { useColorMode, ColorModeProvider, type ColorMode, -} from './utils/colorModeUtils'; +} from './contexts/colorMode'; + +export {splitNavbarItems, NavbarProvider} from './utils/navbarUtils'; export { useTabGroupChoice, TabGroupChoiceProvider, -} from './utils/tabGroupChoiceUtils'; +} from './contexts/tabGroupChoice'; -export { - splitNavbarItems, - NavbarProvider, - useNavbarMobileSidebar, -} from './utils/navbarUtils'; +export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar'; export { useNavbarSecondaryMenu, NavbarSecondaryMenuFiller, -} from './utils/navbarSecondaryMenuUtils'; -export type {NavbarSecondaryMenuComponent} from './utils/navbarSecondaryMenuUtils'; +} from './utils/navbarSecondaryMenu'; +export type {NavbarSecondaryMenuComponent} from './utils/navbarSecondaryMenu'; -export {default as useHideableNavbar} from './hooks/useHideableNavbar'; +export {useHideableNavbar} from './hooks/useHideableNavbar'; export { - default as useKeyboardNavigation, + useKeyboardNavigation, keyboardFocusedClassName, } from './hooks/useKeyboardNavigation'; -export {default as usePrismTheme} from './hooks/usePrismTheme'; -export {default as useLockBodyScroll} from './hooks/useLockBodyScroll'; -export {default as useWindowSize} from './hooks/useWindowSize'; -export {default as useSearchPage} from './hooks/useSearchPage'; +export {usePrismTheme} from './hooks/usePrismTheme'; +export {useLockBodyScroll} from './hooks/useLockBodyScroll'; +export {useWindowSize} from './hooks/useWindowSize'; +export {useSearchPage} from './hooks/useSearchPage'; diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/reactUtils.test.ts b/packages/docusaurus-theme-common/src/utils/__tests__/reactUtils.test.ts new file mode 100644 index 0000000000..0b3e11fa1d --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/__tests__/reactUtils.test.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {usePrevious} from '../reactUtils'; +import {renderHook} from '@testing-library/react-hooks'; + +describe('usePrevious', () => { + it('returns the previous value of a variable', () => { + const {result, rerender} = renderHook((val) => usePrevious(val), { + initialProps: 1, + }); + expect(result.current).toBeUndefined(); + rerender(2); + expect(result.current).toBe(1); + rerender(3); + expect(result.current).toBe(2); + }); +}); diff --git a/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/DocsPreferredVersionStorage.ts b/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/DocsPreferredVersionStorage.ts deleted file mode 100644 index 6e063446f3..0000000000 --- a/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/DocsPreferredVersionStorage.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import {createStorageSlot} from '../storageUtils'; -import type {DocsVersionPersistence} from '../useThemeConfig'; - -const storageKey = (pluginId: string) => `docs-preferred-version-${pluginId}`; - -const DocsPreferredVersionStorage = { - save: ( - pluginId: string, - persistence: DocsVersionPersistence, - versionName: string, - ): void => { - createStorageSlot(storageKey(pluginId), {persistence}).set(versionName); - }, - - read: ( - pluginId: string, - persistence: DocsVersionPersistence, - ): string | null => - createStorageSlot(storageKey(pluginId), {persistence}).get(), - - clear: (pluginId: string, persistence: DocsVersionPersistence): void => { - createStorageSlot(storageKey(pluginId), {persistence}).del(); - }, -}; - -export default DocsPreferredVersionStorage; diff --git a/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/useDocsPreferredVersion.ts b/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/useDocsPreferredVersion.ts deleted file mode 100644 index e035374012..0000000000 --- a/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/useDocsPreferredVersion.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import {useCallback} from 'react'; -import {useDocsPreferredVersionContext} from './DocsPreferredVersionProvider'; -import { - useAllDocsData, - useDocsData, - type GlobalVersion, -} from '@docusaurus/plugin-content-docs/client'; - -import {DEFAULT_PLUGIN_ID} from '@docusaurus/constants'; - -// Note, the preferredVersion attribute will always be null before mount -export function useDocsPreferredVersion( - pluginId: string | undefined = DEFAULT_PLUGIN_ID, -): { - preferredVersion: GlobalVersion | null | undefined; - savePreferredVersionName: (versionName: string) => void; -} { - const docsData = useDocsData(pluginId); - const [state, api] = useDocsPreferredVersionContext(); - - const {preferredVersionName} = state[pluginId]!; - - const preferredVersion = preferredVersionName - ? docsData.versions.find((version) => version.name === preferredVersionName) - : null; - - const savePreferredVersionName = useCallback( - (versionName: string) => { - api.savePreferredVersion(pluginId, versionName); - }, - [api, pluginId], - ); - - return {preferredVersion, savePreferredVersionName} as const; -} - -export function useDocsPreferredVersionByPluginId(): Record< - string, - GlobalVersion | null | undefined -> { - const allDocsData = useAllDocsData(); - const [state] = useDocsPreferredVersionContext(); - - function getPluginIdPreferredVersion(pluginId: string) { - const docsData = allDocsData[pluginId]!; - const {preferredVersionName} = state[pluginId]!; - - return preferredVersionName - ? docsData.versions.find( - (version) => version.name === preferredVersionName, - ) - : null; - } - - const pluginIds = Object.keys(allDocsData); - - const result: Record = {}; - pluginIds.forEach((pluginId) => { - result[pluginId] = getPluginIdPreferredVersion(pluginId); - }); - - return result; -} diff --git a/packages/docusaurus-theme-common/src/utils/navbarSecondaryMenuUtils.tsx b/packages/docusaurus-theme-common/src/utils/navbarSecondaryMenu.tsx similarity index 96% rename from packages/docusaurus-theme-common/src/utils/navbarSecondaryMenuUtils.tsx rename to packages/docusaurus-theme-common/src/utils/navbarSecondaryMenu.tsx index d5bedcb8ce..3e983234f9 100644 --- a/packages/docusaurus-theme-common/src/utils/navbarSecondaryMenuUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/navbarSecondaryMenu.tsx @@ -14,9 +14,8 @@ import React, { type ReactNode, type ComponentType, } from 'react'; -import {ReactContextError} from './reactUtils'; -import {usePrevious} from './usePrevious'; -import {useNavbarMobileSidebar} from './navbarUtils'; +import {ReactContextError, usePrevious} from './reactUtils'; +import {useNavbarMobileSidebar} from '../contexts/navbarMobileSidebar'; /* The idea behind all this is that a specific component must be able to fill a diff --git a/packages/docusaurus-theme-common/src/utils/navbarUtils.tsx b/packages/docusaurus-theme-common/src/utils/navbarUtils.tsx index f6a1bc9d78..b9433574ca 100644 --- a/packages/docusaurus-theme-common/src/utils/navbarUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/navbarUtils.tsx @@ -5,24 +5,15 @@ * LICENSE file in the root directory of this source tree. */ -import React, { - type ReactNode, - useCallback, - useEffect, - useState, - useMemo, -} from 'react'; -import useWindowSize from '../hooks/useWindowSize'; -import {useHistoryPopHandler} from './historyUtils'; -import {NavbarSecondaryMenuProvider} from './navbarSecondaryMenuUtils'; -import {useActivePlugin} from '@docusaurus/plugin-content-docs/client'; -import {useThemeConfig} from './useThemeConfig'; -import {ReactContextError} from './reactUtils'; +import React, {type ReactNode} from 'react'; +import {NavbarMobileSidebarProvider} from '../contexts/navbarMobileSidebar'; +import {NavbarSecondaryMenuProvider} from './navbarSecondaryMenu'; const DefaultNavItemPosition = 'right'; -// If split links by left/right -// if position is unspecified, fallback to right +/** + * Split links by left/right. If position is unspecified, fallback to right. + */ export function splitNavbarItems( items: T[], ): [leftItems: T[], rightItems: T[]] { @@ -36,90 +27,10 @@ export function splitNavbarItems( return [leftItems, rightItems]; } -type NavbarMobileSidebarContextValue = { - disabled: boolean; - shouldRender: boolean; - toggle: () => void; - shown: boolean; -}; - -const NavbarMobileSidebarContext = React.createContext< - NavbarMobileSidebarContextValue | undefined ->(undefined); - -// Mobile sidebar can be disabled in case it would lead to an empty sidebar -// In this case it's not useful to display a navbar sidebar toggle button -function useNavbarMobileSidebarDisabled() { - const activeDocPlugin = useActivePlugin(); - const {items} = useThemeConfig().navbar; - return items.length === 0 && !activeDocPlugin; -} - -function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue { - const disabled = useNavbarMobileSidebarDisabled(); - const windowSize = useWindowSize(); - - // Mobile sidebar not visible until user interaction: can avoid SSR rendering - const shouldRender = !disabled && windowSize === 'mobile'; // || windowSize === 'ssr'; - - const [shown, setShown] = useState(false); - - // Close mobile sidebar on navigation pop - // Most likely firing when using the Android back button (but not only) - useHistoryPopHandler(() => { - if (shown) { - setShown(false); - // Should we prevent the navigation here? - // See https://github.com/facebook/docusaurus/pull/5462#issuecomment-911699846 - return false; // prevent pop navigation - } - return undefined; - }); - - const toggle = useCallback(() => { - setShown((s) => !s); - }, []); - - useEffect(() => { - if (windowSize === 'desktop') { - setShown(false); - } - }, [windowSize]); - - // Return stable context value - return useMemo( - () => ({ - disabled, - shouldRender, - toggle, - shown, - }), - [disabled, shouldRender, toggle, shown], - ); -} - -function NavbarMobileSidebarProvider({ - children, -}: { - children: ReactNode; -}): JSX.Element { - const value = useNavbarMobileSidebarContextValue(); - return ( - - {children} - - ); -} - -export function useNavbarMobileSidebar(): NavbarMobileSidebarContextValue { - const context = React.useContext(NavbarMobileSidebarContext); - if (context == null) { - throw new ReactContextError('NavbarMobileSidebarProvider'); - } - return context; -} - -// Add all Navbar providers at once +/** + * Composes the `NavbarMobileSidebarProvider` and `NavbarSecondaryMenuProvider`. + * Because the latter depends on the former, they can't be re-ordered. + */ export function NavbarProvider({children}: {children: ReactNode}): JSX.Element { return ( diff --git a/packages/docusaurus-theme-common/src/utils/pathUtils.ts b/packages/docusaurus-theme-common/src/utils/pathUtils.ts index 9441add949..a3eacf4723 100644 --- a/packages/docusaurus-theme-common/src/utils/pathUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/pathUtils.ts @@ -5,7 +5,9 @@ * LICENSE file in the root directory of this source tree. */ -// Compare the 2 paths, case insensitive and ignoring trailing slash +/** + * Compare the 2 paths, case insensitive and ignoring trailing slash + */ export const isSamePath = ( path1: string | undefined, path2: string | undefined, diff --git a/packages/docusaurus-theme-common/src/utils/reactUtils.tsx b/packages/docusaurus-theme-common/src/utils/reactUtils.tsx index e1555c1750..b2e5d5c043 100644 --- a/packages/docusaurus-theme-common/src/utils/reactUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/reactUtils.tsx @@ -6,6 +6,7 @@ */ import {useCallback, useEffect, useLayoutEffect, useRef} from 'react'; +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; /** * This hook is like useLayoutEffect, but without the SSR warning @@ -14,12 +15,13 @@ import {useCallback, useEffect, useLayoutEffect, useRef} from 'react'; * It is useful when you need to update a ref as soon as possible after a React * render (before `useEffect`) */ -export const useIsomorphicLayoutEffect = - typeof window !== 'undefined' ? useLayoutEffect : useEffect; +export const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM + ? useLayoutEffect + : useEffect; /** * Permits to transform an unstable callback (like an arrow function provided as - * props) to a "stable" callback that is safe to use in a useEffect dependency + * props) to a "stable" callback that is safe to use in a `useEffect` dependency * array. Useful to avoid React stale closure problems + avoid useless effect * re-executions * @@ -42,6 +44,16 @@ export function useDynamicCallback unknown>( return useCallback((...args) => ref.current(...args), []); } +export function usePrevious(value: T): T | undefined { + const ref = useRef(); + + useIsomorphicLayoutEffect(() => { + ref.current = value; + }); + + return ref.current; +} + export class ReactContextError extends Error { constructor(providerName: string, additionalInfo?: string) { super(); diff --git a/packages/docusaurus-theme-common/src/utils/routesUtils.ts b/packages/docusaurus-theme-common/src/utils/routesUtils.ts index ceb76dd2b2..e826771119 100644 --- a/packages/docusaurus-theme-common/src/utils/routesUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/routesUtils.ts @@ -10,8 +10,11 @@ import {useMemo} from 'react'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import type {Route} from '@docusaurus/types'; -// Note that all sites don't always have a homepage in practice -// See https://github.com/facebook/docusaurus/pull/6517#issuecomment-1048709116 +/** + * Note that sites don't always have a homepage in practice, so we can't assume + * that linking to '/' is always safe. + * @see https://github.com/facebook/docusaurus/pull/6517#issuecomment-1048709116 + */ export function findHomePageRoute({ baseUrl, routes: initialRoutes, diff --git a/packages/docusaurus-theme-common/src/utils/useContextualSearchFilters.ts b/packages/docusaurus-theme-common/src/utils/useContextualSearchFilters.ts index 3461777f7e..28f9231a37 100644 --- a/packages/docusaurus-theme-common/src/utils/useContextualSearchFilters.ts +++ b/packages/docusaurus-theme-common/src/utils/useContextualSearchFilters.ts @@ -9,7 +9,7 @@ import { useAllDocsData, useActivePluginAndVersion, } from '@docusaurus/plugin-content-docs/client'; -import {useDocsPreferredVersionByPluginId} from './docsPreferredVersion/useDocsPreferredVersion'; +import {useDocsPreferredVersionByPluginId} from '../contexts/docsPreferredVersion'; import {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './searchUtils'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; diff --git a/packages/docusaurus-theme-common/src/utils/useLocalPathname.ts b/packages/docusaurus-theme-common/src/utils/useLocalPathname.ts index 8e16158242..a7097b13b5 100644 --- a/packages/docusaurus-theme-common/src/utils/useLocalPathname.ts +++ b/packages/docusaurus-theme-common/src/utils/useLocalPathname.ts @@ -8,9 +8,11 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import {useLocation} from '@docusaurus/router'; -// Get the pathname of current route, without the optional site baseUrl -// - /docs/myDoc => /docs/myDoc -// - /baseUrl/docs/myDoc => /docs/myDoc +/** + * Get the pathname of current route, without the optional site baseUrl + * - /docs/myDoc => /docs/myDoc + * - /baseUrl/docs/myDoc => /docs/myDoc + */ export function useLocalPathname(): string { const { siteConfig: {baseUrl}, diff --git a/packages/docusaurus-theme-common/src/utils/useLocationChange.ts b/packages/docusaurus-theme-common/src/utils/useLocationChange.ts index 1aeaf69f0b..8d947257c3 100644 --- a/packages/docusaurus-theme-common/src/utils/useLocationChange.ts +++ b/packages/docusaurus-theme-common/src/utils/useLocationChange.ts @@ -8,8 +8,7 @@ import {useEffect} from 'react'; import {useLocation} from '@docusaurus/router'; import type {Location} from 'history'; -import {usePrevious} from './usePrevious'; -import {useDynamicCallback} from './reactUtils'; +import {useDynamicCallback, usePrevious} from './reactUtils'; type LocationChangeEvent = { location: Location; diff --git a/packages/docusaurus-theme-common/src/utils/usePluralForm.ts b/packages/docusaurus-theme-common/src/utils/usePluralForm.ts index 029dd1bca8..349047d216 100644 --- a/packages/docusaurus-theme-common/src/utils/usePluralForm.ts +++ b/packages/docusaurus-theme-common/src/utils/usePluralForm.ts @@ -50,17 +50,17 @@ function createLocalePluralForms(locale: string): LocalePluralForms { } /** - * Poor man's PluralSelector implementation, using an english fallback. We want - * a lightweight, future-proof and good-enough solution. We don't want a perfect - * and heavy solution. + * Poor man's `PluralSelector` implementation, using an English fallback. We + * want a lightweight, future-proof and good-enough solution. We don't want a + * perfect and heavy solution. * * Docusaurus classic theme has only 2 deeply nested labels requiring complex - * plural rules. We don't want to use Intl + PluralRules polyfills + full ICU - * syntax (react-intl) just for that. + * plural rules. We don't want to use `Intl` + `PluralRules` polyfills + full + * ICU syntax (react-intl) just for that. * * Notes: - * - 2021: 92+% Browsers support Intl.PluralRules, and support will increase in - * the future + * - 2021: 92+% Browsers support `Intl.PluralRules`, and support will increase + * in the future * - NodeJS >= 13 has full ICU support by default * - In case of "mismatch" between SSR and Browser ICU support, React keeps * working! diff --git a/packages/docusaurus-theme-common/src/utils/usePrevious.ts b/packages/docusaurus-theme-common/src/utils/usePrevious.ts deleted file mode 100644 index 22cb744e05..0000000000 --- a/packages/docusaurus-theme-common/src/utils/usePrevious.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import {useRef} from 'react'; -import {useIsomorphicLayoutEffect} from './reactUtils'; - -export function usePrevious(value: T): T | undefined { - const ref = useRef(); - - useIsomorphicLayoutEffect(() => { - ref.current = value; - }); - - return ref.current; -} diff --git a/packages/docusaurus/src/client/__tests__/routeContext.test.tsx b/packages/docusaurus/src/client/__tests__/routeContext.test.tsx new file mode 100644 index 0000000000..558cf157a6 --- /dev/null +++ b/packages/docusaurus/src/client/__tests__/routeContext.test.tsx @@ -0,0 +1,89 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import {renderHook} from '@testing-library/react-hooks/server'; +import {RouteContextProvider} from '../routeContext'; +import useRouteContext from '../exports/useRouteContext'; + +describe('RouteContextProvider', () => { + it('creates root route context registered by plugin', () => { + expect( + renderHook(() => useRouteContext(), { + wrapper: ({children}) => ( + + {children} + + ), + }).result.current, + ).toEqual({plugin: {id: 'test', name: 'test'}}); + }); + it('throws if there is no route context at all', () => { + expect( + () => + renderHook(() => useRouteContext(), { + wrapper: ({children}) => ( + {children} + ), + }).result.current, + ).toThrowErrorMatchingInlineSnapshot( + `"Unexpected: no Docusaurus route context found"`, + ); + }); + it('throws if there is no parent context created by plugin', () => { + expect( + () => + renderHook(() => useRouteContext(), { + wrapper: ({children}) => ( + + {children} + + ), + }).result.current, + ).toThrowErrorMatchingInlineSnapshot( + `"Unexpected: Docusaurus topmost route context has no \`plugin\` attribute"`, + ); + }); + it('merges route context created by parent', () => { + expect( + renderHook(() => useRouteContext(), { + wrapper: ({children}) => ( + + + {children} + + + ), + }).result.current, + ).toEqual({ + data: {some: 'data', someMore: 'data'}, + plugin: {id: 'test', name: 'test'}, + }); + }); + it('never overrides the plugin attribute', () => { + expect( + renderHook(() => useRouteContext(), { + wrapper: ({children}) => ( + + + {children} + + + ), + }).result.current, + ).toEqual({ + data: {some: 'data', someMore: 'data'}, + plugin: {id: 'test', name: 'test'}, + }); + }); +}); diff --git a/packages/docusaurus/src/client/exports/__tests__/Head.test.tsx b/packages/docusaurus/src/client/exports/__tests__/Head.test.tsx new file mode 100644 index 0000000000..847fa52391 --- /dev/null +++ b/packages/docusaurus/src/client/exports/__tests__/Head.test.tsx @@ -0,0 +1,44 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import Head from '../Head'; +import {type FilledContext, HelmetProvider} from 'react-helmet-async'; +import renderer from 'react-test-renderer'; + +describe('Head', () => { + it('does exactly what Helmet does', () => { + const context = {}; + expect( + renderer + .create( + + + + + + + + + + + + +
Content
+
, + ) + .toJSON(), + ).toMatchSnapshot(); + expect((context as FilledContext).helmet).toMatchSnapshot(); + }); +}); diff --git a/packages/docusaurus/src/client/exports/__tests__/__snapshots__/Head.test.tsx.snap b/packages/docusaurus/src/client/exports/__tests__/__snapshots__/Head.test.tsx.snap new file mode 100644 index 0000000000..419daccd48 --- /dev/null +++ b/packages/docusaurus/src/client/exports/__tests__/__snapshots__/Head.test.tsx.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Head does exactly what Helmet does 1`] = ` +
+ Content +
+`; + +exports[`Head does exactly what Helmet does 2`] = ` + + + + + + + + + + + + +`; diff --git a/packages/docusaurus/src/client/exports/__tests__/useRouteContext.test.tsx b/packages/docusaurus/src/client/exports/__tests__/useRouteContext.test.tsx new file mode 100644 index 0000000000..d1c469cdc0 --- /dev/null +++ b/packages/docusaurus/src/client/exports/__tests__/useRouteContext.test.tsx @@ -0,0 +1,38 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import {renderHook} from '@testing-library/react-hooks/server'; +import {RouteContextProvider} from '../../routeContext'; +import useRouteContext from '../useRouteContext'; + +describe('useRouteContext', () => { + it('throws when there is no route context at all', () => { + expect( + () => renderHook(() => useRouteContext()).result.current, + ).toThrowErrorMatchingInlineSnapshot( + `"Unexpected: no Docusaurus route context found"`, + ); + }); + it('returns merged route contexts', () => { + expect( + renderHook(() => useRouteContext(), { + wrapper: ({children}) => ( + + + {children} + + + ), + }).result.current, + ).toEqual({ + data: {some: 'data', someMore: 'data'}, + plugin: {id: 'test', name: 'test'}, + }); + }); +}); diff --git a/packages/docusaurus/src/client/exports/useRouteContext.tsx b/packages/docusaurus/src/client/exports/useRouteContext.tsx index 79c57fa9ae..9ba1a1f570 100644 --- a/packages/docusaurus/src/client/exports/useRouteContext.tsx +++ b/packages/docusaurus/src/client/exports/useRouteContext.tsx @@ -12,9 +12,7 @@ import {Context} from '../routeContext'; export default function useRouteContext(): PluginRouteContext { const context = React.useContext(Context); if (!context) { - throw new Error( - 'Unexpected: no Docusaurus parent/current route context found', - ); + throw new Error('Unexpected: no Docusaurus route context found'); } return context; } diff --git a/packages/docusaurus/src/client/routeContext.tsx b/packages/docusaurus/src/client/routeContext.tsx index 78e87a5a65..1b285859b8 100644 --- a/packages/docusaurus/src/client/routeContext.tsx +++ b/packages/docusaurus/src/client/routeContext.tsx @@ -19,12 +19,10 @@ function mergeContexts({ }): PluginRouteContext { if (!parent) { if (!value) { - throw new Error( - 'Unexpected: no Docusaurus parent/current route context found', - ); + throw new Error('Unexpected: no Docusaurus route context found'); } else if (!('plugin' in value)) { throw new Error( - 'Unexpected: Docusaurus parent route context has no plugin attribute', + 'Unexpected: Docusaurus topmost route context has no `plugin` attribute', ); } return value; @@ -45,7 +43,8 @@ export function RouteContextProvider({ value, }: { children: ReactNode; - value: PluginRouteContext | null; + // Only topmost route has the `plugin` attribute + value: PluginRouteContext | RouteContext | null; }): JSX.Element { const parent = React.useContext(Context); diff --git a/website/src/utils/__tests__/jsUtils.test.ts b/website/src/utils/__tests__/jsUtils.test.ts new file mode 100644 index 0000000000..70dee1e5ba --- /dev/null +++ b/website/src/utils/__tests__/jsUtils.test.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {toggleListItem} from '../jsUtils'; + +describe('toggleListItem', () => { + it('removes item already in list', () => { + expect(toggleListItem([1, 2, 3], 2)).toEqual([1, 3]); + }); + it('appends item not in list', () => { + expect(toggleListItem([1, 2], 3)).toEqual([1, 2, 3]); + }); +}); diff --git a/yarn.lock b/yarn.lock index 7156263542..6795d63859 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11669,6 +11669,11 @@ jest-runtime@^27.5.1: slash "^3.0.0" strip-bom "^4.0.0" +jest-serializer-react-helmet-async@^1.0.21: + version "1.0.21" + resolved "https://registry.yarnpkg.com/jest-serializer-react-helmet-async/-/jest-serializer-react-helmet-async-1.0.21.tgz#bf2aee7522909bc4c933a0911db236b92db4685c" + integrity sha512-oJARA6ACc3QNR4s/EUjecLQclGf2+vMO0azoiEBwjJrsDHGHkMHcM935+r7aGkmteY1awdoyQ78ZGDiC7dwtsw== + jest-serializer@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-27.5.1.tgz#81438410a30ea66fd57ff730835123dea1fb1f64"