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;