test: improve test coverage; reorder theme-common files (#6956)

* test: improve test coverage; reorder theme-common files

* no need for this
This commit is contained in:
Joshua Chen 2022-03-22 15:33:55 +08:00 committed by GitHub
parent 0a5354dc32
commit 948271a0ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 555 additions and 317 deletions

View file

@ -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: ['<rootDir>/jest/snapshotPathNormalizer.ts'],
snapshotSerializers: [
'<rootDir>/jest/snapshotPathNormalizer.ts',
'jest-serializer-react-helmet-async',
],
snapshotFormat: {
printBasicPrototype: false,
},

View file

@ -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",

View file

@ -34,7 +34,7 @@ export type DetailsProps = {
summary?: ReactElement;
} & ComponentProps<'details'>;
export default function Details({
export function Details({
summary,
children,
...props

View file

@ -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';

View file

@ -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<ColorModeContextValue | undefined>(
ColorModeContext,
);
const context = useContext(ColorModeContext);
if (context == null) {
throw new ReactContextError(
'ColorModeProvider',

View file

@ -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<

View file

@ -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 <Context.Provider value={contextValue}>{children}</Context.Provider>;
}
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<string, GlobalVersion | null | undefined> = {};
pluginIds.forEach((pluginId) => {
result[pluginId] = getPluginIdPreferredVersion(pluginId);
});
return result;
}

View file

@ -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 <Context.Provider value={value}>{children}</Context.Provider>;
}
export function useNavbarMobileSidebar(): NavbarMobileSidebarContextValue {
const context = React.useContext(Context);
if (context == null) {
throw new ReactContextError('NavbarMobileSidebarProvider');
}
return context;
}

View file

@ -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.';

View file

@ -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);

View file

@ -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') {

View file

@ -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';

View file

@ -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;

View file

@ -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},

View file

@ -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<HTMLAnchorElement | undefined>(undefined);
const anchorTopOffsetRef = useAnchorTopOffsetRef();

View file

@ -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<WindowSize>(() => {
if (DevSimulateSSR) {
return 'ssr';

View file

@ -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';

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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<string, GlobalVersion | null | undefined> = {};
pluginIds.forEach((pluginId) => {
result[pluginId] = getPluginIdPreferredVersion(pluginId);
});
return result;
}

View file

@ -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

View file

@ -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<T extends {position?: 'left' | 'right'}>(
items: T[],
): [leftItems: T[], rightItems: T[]] {
@ -36,90 +27,10 @@ export function splitNavbarItems<T extends {position?: 'left' | 'right'}>(
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 (
<NavbarMobileSidebarContext.Provider value={value}>
{children}
</NavbarMobileSidebarContext.Provider>
);
}
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 (
<NavbarMobileSidebarProvider>

View file

@ -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,

View file

@ -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<T extends (...args: never[]) => unknown>(
return useCallback<T>((...args) => ref.current(...args), []);
}
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useIsomorphicLayoutEffect(() => {
ref.current = value;
});
return ref.current;
}
export class ReactContextError extends Error {
constructor(providerName: string, additionalInfo?: string) {
super();

View file

@ -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,

View file

@ -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';

View file

@ -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},

View file

@ -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;

View file

@ -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!

View file

@ -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<T>(value: T): T | undefined {
const ref = useRef<T>();
useIsomorphicLayoutEffect(() => {
ref.current = value;
});
return ref.current;
}

View file

@ -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}) => (
<RouteContextProvider value={{plugin: {id: 'test', name: 'test'}}}>
{children}
</RouteContextProvider>
),
}).result.current,
).toEqual({plugin: {id: 'test', name: 'test'}});
});
it('throws if there is no route context at all', () => {
expect(
() =>
renderHook(() => useRouteContext(), {
wrapper: ({children}) => (
<RouteContextProvider value={null}>{children}</RouteContextProvider>
),
}).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}) => (
<RouteContextProvider value={{data: {some: 'data'}}}>
{children}
</RouteContextProvider>
),
}).result.current,
).toThrowErrorMatchingInlineSnapshot(
`"Unexpected: Docusaurus topmost route context has no \`plugin\` attribute"`,
);
});
it('merges route context created by parent', () => {
expect(
renderHook(() => useRouteContext(), {
wrapper: ({children}) => (
<RouteContextProvider
value={{plugin: {id: 'test', name: 'test'}, data: {some: 'data'}}}>
<RouteContextProvider value={{data: {someMore: 'data'}}}>
{children}
</RouteContextProvider>
</RouteContextProvider>
),
}).result.current,
).toEqual({
data: {some: 'data', someMore: 'data'},
plugin: {id: 'test', name: 'test'},
});
});
it('never overrides the plugin attribute', () => {
expect(
renderHook(() => useRouteContext(), {
wrapper: ({children}) => (
<RouteContextProvider
value={{plugin: {id: 'test', name: 'test'}, data: {some: 'data'}}}>
<RouteContextProvider
value={{
plugin: {id: 'adversary', name: 'adversary'},
data: {someMore: 'data'},
}}>
{children}
</RouteContextProvider>
</RouteContextProvider>
),
}).result.current,
).toEqual({
data: {some: 'data', someMore: 'data'},
plugin: {id: 'test', name: 'test'},
});
});
});

View file

@ -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(
<HelmetProvider context={context}>
<Head>
<meta property="og:type" content="article" />
<meta property="og:description" content="some description" />
</Head>
<Head>
<meta
property="og:description"
content="some description overridden"
/>
</Head>
<Head>
<meta
property="duplicated?"
content="this property is duplicated"
/>
<meta property="duplicated?" content="another one" />
</Head>
<div>Content</div>
</HelmetProvider>,
)
.toJSON(),
).toMatchSnapshot();
expect((context as FilledContext).helmet).toMatchSnapshot();
});
});

View file

@ -0,0 +1,40 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Head does exactly what Helmet does 1`] = `
<div>
Content
</div>
`;
exports[`Head does exactly what Helmet does 2`] = `
<html>
<head>
<meta
content="article"
data-rh={true}
property="og:type"
/>
<meta
content="some description overridden"
data-rh={true}
property="og:description"
/>
<meta
content="this property is duplicated"
data-rh={true}
property="duplicated?"
/>
<meta
content="another one"
data-rh={true}
property="duplicated?"
/>
<title
data-rh={true}
>
</title>
</head>
<body />
</html>
`;

View file

@ -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}) => (
<RouteContextProvider
value={{plugin: {id: 'test', name: 'test'}, data: {some: 'data'}}}>
<RouteContextProvider value={{data: {someMore: 'data'}}}>
{children}
</RouteContextProvider>
</RouteContextProvider>
),
}).result.current,
).toEqual({
data: {some: 'data', someMore: 'data'},
plugin: {id: 'test', name: 'test'},
});
});
});

View file

@ -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;
}

View file

@ -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);

View file

@ -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]);
});
});

View file

@ -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"