diff --git a/.eslintrc.js b/.eslintrc.js index 886bd74c917a..66d72dd28313 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -28,6 +28,7 @@ module.exports = { 'eslint:recommended', 'plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', 'airbnb', 'prettier', 'prettier/react', @@ -41,6 +42,8 @@ module.exports = { }, plugins: ['react-hooks', 'header'], rules: { + 'react-hooks/rules-of-hooks': ERROR, + 'react-hooks/exhaustive-deps': ERROR, 'class-methods-use-this': OFF, // It's a way of allowing private variables. 'func-names': OFF, // Ignore certain webpack alias because it can't be resolved @@ -77,7 +80,6 @@ module.exports = { 'react/destructuring-assignment': OFF, // Too many lines. 'react/prefer-stateless-function': WARNING, 'react/jsx-props-no-spreading': OFF, - 'react-hooks/rules-of-hooks': ERROR, 'react/require-default-props': [ERROR, {ignoreFunctionalComponents: true}], '@typescript-eslint/no-inferrable-types': OFF, 'import/first': OFF, diff --git a/packages/docusaurus-theme-classic/src/theme/DocPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocPage/index.tsx index 25bc454dcddf..ebb2ff1abb51 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocPage/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/DocPage/index.tsx @@ -51,7 +51,7 @@ function DocPageContent({ setHiddenSidebar(false); } - setHiddenSidebarContainer(!hiddenSidebarContainer); + setHiddenSidebarContainer((value) => !value); }, [hiddenSidebar]); return ( diff --git a/packages/docusaurus-theme-classic/src/theme/DocSidebarItem/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocSidebarItem/index.tsx index 8c769004fef5..59a2d2a330c4 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocSidebarItem/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/DocSidebarItem/index.tsx @@ -93,7 +93,7 @@ function useAutoExpandActiveCategory({ if (justBecameActive && collapsed) { setCollapsed(false); } - }, [isActive, wasActive, collapsed]); + }, [isActive, wasActive, collapsed, setCollapsed]); } function DocSidebarItemCategory({ diff --git a/packages/docusaurus-theme-classic/src/theme/NavbarItem/DropdownNavbarItem.tsx b/packages/docusaurus-theme-classic/src/theme/NavbarItem/DropdownNavbarItem.tsx index a3d38a2c28c8..35459e2b556a 100644 --- a/packages/docusaurus-theme-classic/src/theme/NavbarItem/DropdownNavbarItem.tsx +++ b/packages/docusaurus-theme-classic/src/theme/NavbarItem/DropdownNavbarItem.tsx @@ -141,7 +141,7 @@ function DropdownNavbarItemMobile({ if (containsActive) { setCollapsed(!containsActive); } - }, [localPathname, containsActive]); + }, [localPathname, containsActive, setCollapsed]); return (
  • { } catch (err) { console.error(err); } - }, [setTheme]); + }, [disableSwitch, setTheme]); useEffect(() => { if (disableSwitch && !respectPrefersColorScheme) { @@ -80,7 +80,7 @@ const useTheme = (): useThemeReturns => { .addListener(({matches}) => { setTheme(matches ? themes.dark : themes.light); }); - }, []); + }, [disableSwitch, respectPrefersColorScheme]); return { isDarkTheme: theme === themes.dark, diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index f5c9ab1cdd2a..48874061623f 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -88,3 +88,8 @@ export { useScrollPosition, useScrollPositionBlocker, } from './utils/scrollUtils'; + +export { + useIsomorphicLayoutEffect, + useDynamicCallback, +} from './utils/reactUtils'; diff --git a/packages/docusaurus-theme-common/src/utils/announcementBarUtils.tsx b/packages/docusaurus-theme-common/src/utils/announcementBarUtils.tsx index 8a4c7a376e5e..14f67e9d4476 100644 --- a/packages/docusaurus-theme-common/src/utils/announcementBarUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/announcementBarUtils.tsx @@ -83,14 +83,14 @@ const useAnnouncementBarContextValue = (): AnnouncementBarAPI => { if (isNewAnnouncement || !isDismissedInStorage()) { setClosed(false); } - }, []); + }, [announcementBar]); return useMemo(() => { return { isActive: !!announcementBar && !isClosed, close: handleClose, }; - }, [isClosed]); + }, [announcementBar, isClosed, handleClose]); }; const AnnouncementBarContext = createContext(null); diff --git a/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/DocsPreferredVersionProvider.tsx b/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/DocsPreferredVersionProvider.tsx index 2176c25e5f51..07911a858ed8 100644 --- a/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/DocsPreferredVersionProvider.tsx +++ b/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/DocsPreferredVersionProvider.tsx @@ -120,7 +120,7 @@ function useContextValue() { return { savePreferredVersion, }; - }, [setState]); + }, [versionPersistence]); return [state, api] as const; } diff --git a/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/useDocsPreferredVersion.ts b/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/useDocsPreferredVersion.ts index 5bfa49b3729d..ace170eafd40 100644 --- a/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/useDocsPreferredVersion.ts +++ b/packages/docusaurus-theme-common/src/utils/docsPreferredVersion/useDocsPreferredVersion.ts @@ -30,7 +30,7 @@ export function useDocsPreferredVersion( (versionName: string) => { api.savePreferredVersion(pluginId, versionName); }, - [api], + [api, pluginId], ); return {preferredVersion, savePreferredVersionName} as const; diff --git a/packages/docusaurus-theme-common/src/utils/mobileSecondaryMenu.tsx b/packages/docusaurus-theme-common/src/utils/mobileSecondaryMenu.tsx index f51c93795e64..b58b621a38c1 100644 --- a/packages/docusaurus-theme-common/src/utils/mobileSecondaryMenu.tsx +++ b/packages/docusaurus-theme-common/src/utils/mobileSecondaryMenu.tsx @@ -83,6 +83,7 @@ function useShallowMemoizedObject>(obj: O) { return useMemo( () => obj, // Is this safe? + // eslint-disable-next-line react-hooks/exhaustive-deps [...Object.keys(obj), ...Object.values(obj)], ); } diff --git a/packages/docusaurus-theme-common/src/utils/reactUtils.tsx b/packages/docusaurus-theme-common/src/utils/reactUtils.tsx new file mode 100644 index 000000000000..5a3ce9ec7064 --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/reactUtils.tsx @@ -0,0 +1,34 @@ +/** + * 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, useEffect, useLayoutEffect, useRef} from 'react'; + +// This hook is like useLayoutEffect, but without the SSR warning +// It seems hacky but it's used in many React libs (Redux, Formik...) +// Also mentioned here: https://github.com/facebook/react/issues/16956 +// 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; + +// 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 array +// Useful to avoid React stale closure problems + avoid useless effect re-executions +// +// Workaround until the React team recommends a good solution, see https://github.com/facebook/react/issues/16956 +// This generally works has some potential drawbacks, such as https://github.com/facebook/react/issues/16956#issuecomment-536636418 +export function useDynamicCallback unknown>( + callback: T, +): T { + const ref = useRef(callback); + + useIsomorphicLayoutEffect(() => { + ref.current = callback; + }, [callback]); + + // @ts-expect-error: TODO, not sure how to fix this TS error + return useCallback((...args) => ref.current(...args), []); +} diff --git a/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx index 9c54f405ae31..6bd3e4b766ef 100644 --- a/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx @@ -15,6 +15,7 @@ import React, { useMemo, useRef, } from 'react'; +import {useDynamicCallback} from './reactUtils'; import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; /** @@ -103,20 +104,22 @@ export function useScrollPosition( const {scrollEventsEnabledRef} = useScrollController(); const lastPositionRef = useRef(getScrollPosition()); - const handleScroll = () => { - if (!scrollEventsEnabledRef.current) { - return; - } - const currentPosition = getScrollPosition()!; + const dynamicEffect = useDynamicCallback(effect); - if (effect) { - effect(currentPosition, lastPositionRef.current); - } + useEffect(() => { + const handleScroll = () => { + if (!scrollEventsEnabledRef.current) { + return; + } + const currentPosition = getScrollPosition()!; - lastPositionRef.current = currentPosition; - }; + if (dynamicEffect) { + dynamicEffect(currentPosition, lastPositionRef.current); + } + + lastPositionRef.current = currentPosition; + }; - useEffect(() => { const opts: AddEventListenerOptions & EventListenerOptions = { passive: true, }; @@ -125,7 +128,12 @@ export function useScrollPosition( window.addEventListener('scroll', handleScroll, opts); return () => window.removeEventListener('scroll', handleScroll, opts); - }, deps); + }, [ + dynamicEffect, + scrollEventsEnabledRef, + // eslint-disable-next-line react-hooks/exhaustive-deps + ...deps, + ]); } type UseScrollPositionSaver = { @@ -170,7 +178,7 @@ function useScrollPositionSaver(): UseScrollPositionSaver { return {restored: heightDiff !== 0}; }, []); - return useMemo(() => ({save, restore}), []); + return useMemo(() => ({save, restore}), [restore, save]); } type UseScrollPositionBlockerReturn = { @@ -217,7 +225,7 @@ export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn { } }; }, - [scrollController], + [scrollController, scrollPositionSaver], ); useLayoutEffect(() => { diff --git a/packages/docusaurus-theme-common/src/utils/useLocationChange.ts b/packages/docusaurus-theme-common/src/utils/useLocationChange.ts index 70120ca277c0..6be0d3000a7f 100644 --- a/packages/docusaurus-theme-common/src/utils/useLocationChange.ts +++ b/packages/docusaurus-theme-common/src/utils/useLocationChange.ts @@ -9,6 +9,7 @@ import {useEffect} from 'react'; import {useLocation} from '@docusaurus/router'; import {Location} from '@docusaurus/history'; import {usePrevious} from './usePrevious'; +import {useDynamicCallback} from './reactUtils'; type LocationChangeEvent = { location: Location; @@ -21,10 +22,12 @@ export function useLocationChange(onLocationChange: OnLocationChange): void { const location = useLocation(); const previousLocation = usePrevious(location); + const onLocationChangeDynamic = useDynamicCallback(onLocationChange); + useEffect(() => { - onLocationChange({ + onLocationChangeDynamic({ location, previousLocation, }); - }, [location]); + }, [onLocationChangeDynamic, location, previousLocation]); } diff --git a/packages/docusaurus-theme-common/src/utils/usePrevious.ts b/packages/docusaurus-theme-common/src/utils/usePrevious.ts index 2a27462ff374..22cb744e05bd 100644 --- a/packages/docusaurus-theme-common/src/utils/usePrevious.ts +++ b/packages/docusaurus-theme-common/src/utils/usePrevious.ts @@ -5,12 +5,13 @@ * LICENSE file in the root directory of this source tree. */ -import {useRef, useEffect} from 'react'; +import {useRef} from 'react'; +import {useIsomorphicLayoutEffect} from './reactUtils'; export function usePrevious(value: T): T | undefined { const ref = useRef(); - useEffect(() => { + useIsomorphicLayoutEffect(() => { ref.current = value; }); diff --git a/packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.js b/packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.js index 8e0fd9e973f0..d319b37bcff6 100644 --- a/packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.js +++ b/packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.js @@ -16,7 +16,11 @@ import clsx from 'clsx'; import Head from '@docusaurus/Head'; import Link from '@docusaurus/Link'; import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; -import {useTitleFormatter, usePluralForm} from '@docusaurus/theme-common'; +import { + useTitleFormatter, + usePluralForm, + useDynamicCallback, +} from '@docusaurus/theme-common'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import {useAllDocsData} from '@theme/hooks/useDocs'; import useSearchQuery from '@theme/hooks/useSearchQuery'; @@ -173,6 +177,7 @@ function SearchPage() { }, initialSearchResultState, ); + const algoliaClient = algoliaSearch(appId, apiKey); const algoliaHelper = algoliaSearchHelper(algoliaClient, indexName, { hitsPerPage: 15, @@ -271,7 +276,7 @@ function SearchPage() { description: 'The search page title for empty query', }); - const makeSearch = (page = 0) => { + const makeSearch = useDynamicCallback((page = 0) => { algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default'); algoliaHelper.addDisjunctiveFacetRefinement('language', currentLocale); @@ -285,18 +290,16 @@ function SearchPage() { ); algoliaHelper.setQuery(searchQuery).setPage(page).search(); - }; + }); useEffect(() => { if (!loaderRef) { return undefined; } + const currentObserver = observer.current; - observer.current.observe(loaderRef); - - return () => { - observer.current.unobserve(loaderRef); - }; + currentObserver.observe(loaderRef); + return () => currentObserver.unobserve(loaderRef); }, [loaderRef]); useEffect(() => { @@ -311,7 +314,12 @@ function SearchPage() { makeSearch(); }, 300); } - }, [searchQuery, docsSearchVersionsHelpers.searchVersions]); + }, [ + searchQuery, + docsSearchVersionsHelpers.searchVersions, + updateSearchPath, + makeSearch, + ]); useEffect(() => { if (!searchResultState.lastPage || searchResultState.lastPage === 0) { @@ -319,13 +327,13 @@ function SearchPage() { } makeSearch(searchResultState.lastPage); - }, [searchResultState.lastPage]); + }, [makeSearch, searchResultState.lastPage]); useEffect(() => { if (searchValue && searchValue !== searchQuery) { setSearchQuery(searchValue); } - }, [searchValue]); + }, [searchQuery, searchValue]); return ( diff --git a/packages/docusaurus/src/client/exports/Link.tsx b/packages/docusaurus/src/client/exports/Link.tsx index 562653c5ad30..14203a1becea 100644 --- a/packages/docusaurus/src/client/exports/Link.tsx +++ b/packages/docusaurus/src/client/exports/Link.tsx @@ -90,16 +90,16 @@ function Link({ const IOSupported = ExecutionEnvironment.canUseIntersectionObserver; - let io: IntersectionObserver; + const ioRef = useRef(); const handleIntersection = (el: HTMLAnchorElement, cb: () => void) => { - io = new window.IntersectionObserver((entries) => { + ioRef.current = new window.IntersectionObserver((entries) => { entries.forEach((entry) => { if (el === entry.target) { // If element is in viewport, stop listening/observing and run callback. // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API if (entry.isIntersecting || entry.intersectionRatio > 0) { - io.unobserve(el); - io.disconnect(); + ioRef.current!.unobserve(el); + ioRef.current!.disconnect(); cb(); } } @@ -107,7 +107,7 @@ function Link({ }); // Add element to the observer. - io.observe(el); + ioRef.current!.observe(el); }; const handleRef = (ref: HTMLAnchorElement | null) => { @@ -138,11 +138,11 @@ function Link({ // When unmounting, stop intersection observer from watching. return () => { - if (IOSupported && io) { - io.disconnect(); + if (IOSupported && ioRef.current) { + ioRef.current.disconnect(); } }; - }, [targetLink, IOSupported, isInternal]); + }, [ioRef, targetLink, IOSupported, isInternal]); const isAnchorLink = targetLink?.startsWith('#') ?? false; const isRegularHtmlLink = !targetLink || !isInternal || isAnchorLink;