diff --git a/webapp/eslint.config.js b/webapp/eslint.config.js index 245c2a0812..fc16942269 100644 --- a/webapp/eslint.config.js +++ b/webapp/eslint.config.js @@ -124,6 +124,14 @@ export default [ "react/hook-use-state": "error", "react/prop-types": "off", "react/self-closing-comp": "error", + "react-hooks/exhaustive-deps": [ + "warn", + { + // Includes hooks from 'react-use' + additionalHooks: + "(useSafeMemo|useUpdateEffectOnce|useDeepCompareEffect|useShallowCompareEffect|useCustomCompareEffect)", + }, + ], "require-await": "warn", // TODO: switch to "error" when the quantity of warning will be low }, }, diff --git a/webapp/src/components/App/Singlestudy/FreezeStudy.tsx b/webapp/src/components/App/Singlestudy/FreezeStudy.tsx index 99431d9671..b5a3c02a92 100644 --- a/webapp/src/components/App/Singlestudy/FreezeStudy.tsx +++ b/webapp/src/components/App/Singlestudy/FreezeStudy.tsx @@ -28,7 +28,7 @@ import { Backdrop, Button, List, ListItem, ListItemText, Paper, Typography } fro import { useEffect, useState } from "react"; import LinearProgressWithLabel from "@/components/common/LinearProgressWithLabel"; import { useTranslation } from "react-i18next"; -import useAutoUpdateRef from "@/hooks/useAutoUpdateRef"; +import useUpdatedRef from "@/hooks/useUpdatedRef"; interface BlockingTask { id: TaskDTO["id"]; @@ -60,7 +60,7 @@ function FreezeStudy({ studyId }: FreezeStudyProps) { const [blockingTasks, setBlockingTasks] = useState([]); const { t } = useTranslation(); const hasLoadingTask = !!blockingTasks.find(isLoadingTask); - const blockingTasksRef = useAutoUpdateRef(blockingTasks); + const blockingTasksRef = useUpdatedRef(blockingTasks); // Fetch blocking tasks and subscribe to their WebSocket channels useEffect(() => { diff --git a/webapp/src/components/common/Form/useFormApiPlus.ts b/webapp/src/components/common/Form/useFormApiPlus.ts index b1c6ba2c33..4acfefd7b0 100644 --- a/webapp/src/components/common/Form/useFormApiPlus.ts +++ b/webapp/src/components/common/Form/useFormApiPlus.ts @@ -24,7 +24,7 @@ import type { } from "react-hook-form"; import * as RA from "ramda-adjunct"; import { useEffect, useMemo, useRef } from "react"; -import useAutoUpdateRef from "../../../hooks/useUpdatedRef"; +import useUpdatedRef from "../../../hooks/useUpdatedRef"; import type { UseFormRegisterPlus, UseFormReturnPlus, @@ -57,7 +57,7 @@ function useFormApiPlus( const initialDefaultValues = useRef(isLoading ? undefined : getDefaultValues()); // Prevent to add the values in `useMemo`'s deps - const dataRef = useAutoUpdateRef({ + const dataRef = useUpdatedRef({ ...data, // Don't read `formState` in `useMemo`. See `useEffect`'s comment below. isSubmitting, diff --git a/webapp/src/components/common/Form/useFormUndoRedo.ts b/webapp/src/components/common/Form/useFormUndoRedo.ts index 8e1901088b..27d4bb578a 100644 --- a/webapp/src/components/common/Form/useFormUndoRedo.ts +++ b/webapp/src/components/common/Form/useFormUndoRedo.ts @@ -17,7 +17,7 @@ import type { FieldValues } from "react-hook-form"; import { useCallback, useEffect, useRef } from "react"; import * as R from "ramda"; import type { UseFormReturnPlus } from "./types"; -import useAutoUpdateRef from "../../../hooks/useUpdatedRef"; +import useUpdatedRef from "../../../hooks/useUpdatedRef"; enum ActionType { Undo = "UNDO", @@ -34,7 +34,7 @@ function useFormUndoRedo( } = api; const [state, { undo, redo, set, ...rest }] = useUndo(initialDefaultValues); const lastAction = useRef(""); - const dataRef = useAutoUpdateRef({ state, initialDefaultValues }); + const dataRef = useUpdatedRef({ state, initialDefaultValues }); useEffect( () => { diff --git a/webapp/src/components/common/GroupedDataTable/index.tsx b/webapp/src/components/common/GroupedDataTable/index.tsx index 2bfd2ddbbc..a2acdd3a78 100644 --- a/webapp/src/components/common/GroupedDataTable/index.tsx +++ b/webapp/src/components/common/GroupedDataTable/index.tsx @@ -33,7 +33,7 @@ import ConfirmationDialog from "../dialogs/ConfirmationDialog"; import { generateUniqueValue, getTableOptionsForAlign } from "./utils"; import DuplicateDialog from "./DuplicateDialog"; import { translateWithColon } from "../../../utils/i18nUtils"; -import useAutoUpdateRef from "../../../hooks/useUpdatedRef"; +import useUpdatedRef from "../../../hooks/useUpdatedRef"; import * as R from "ramda"; import * as RA from "ramda-adjunct"; import type { PromiseAny } from "../../../utils/tsUtils"; @@ -85,7 +85,7 @@ function GroupedDataTable({}); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); // Allow to use the last version of `onNameClick` in `tableColumns` - const callbacksRef = useAutoUpdateRef({ onNameClick }); + const callbacksRef = useUpdatedRef({ onNameClick }); const pendingRows = useRef>>([]); const { createOps, deleteOps, totalOps } = useOperationInProgressCount(); diff --git a/webapp/src/components/common/JSONEditor/index.tsx b/webapp/src/components/common/JSONEditor/index.tsx index 81364510dd..3ffaa007b4 100644 --- a/webapp/src/components/common/JSONEditor/index.tsx +++ b/webapp/src/components/common/JSONEditor/index.tsx @@ -19,7 +19,7 @@ import { useDeepCompareEffect, useMount } from "react-use"; import "jsoneditor/dist/jsoneditor.min.css"; import "./dark-theme.css"; import type { PromiseAny } from "../../../utils/tsUtils"; -import useAutoUpdateRef from "../../../hooks/useUpdatedRef"; +import useUpdatedRef from "../../../hooks/useUpdatedRef"; import { createSaveButton } from "./utils"; import * as R from "ramda"; import * as RA from "ramda-adjunct"; @@ -37,8 +37,8 @@ function JSONEditor(props: JSONEditorProps) { const { json, onSave, onSaveSuccessful, ...options } = props; const ref = useRef(null); const editorRef = useRef(); - const onSaveRef = useAutoUpdateRef(onSave); - const callbackOptionsRef = useAutoUpdateRef>( + const onSaveRef = useUpdatedRef(onSave); + const callbackOptionsRef = useUpdatedRef>( R.pickBy(RA.isFunction, options), ); const saveBtn = useMemo(() => createSaveButton(handleSaveClick), []); diff --git a/webapp/src/components/common/TableForm/index.tsx b/webapp/src/components/common/TableForm/index.tsx index 9d0e216d7b..86d000bc81 100644 --- a/webapp/src/components/common/TableForm/index.tsx +++ b/webapp/src/components/common/TableForm/index.tsx @@ -24,7 +24,7 @@ import Form, { type FormProps } from "../Form"; import Table, { type TableProps } from "./Table"; import { getCellType } from "./utils"; import { mergeSxProp } from "../../../utils/muiUtils"; -import useMemoLocked from "../../../hooks/useMemoLocked"; +import useSafeMemo from "../../../hooks/useSafeMemo"; type TableFieldValuesByRow = Record>; @@ -61,11 +61,13 @@ function TableForm( const { columns, type, colHeaders, ...restTableProps } = tableProps; // useForm's defaultValues are cached on the first render within the custom hook. - const defaultData = useMemoLocked(() => - R.keys(defaultValues).map((id) => ({ - ...defaultValues[id], - id: id as IdType, - })), + const defaultData = useSafeMemo( + () => + R.keys(defaultValues).map((id) => ({ + ...defaultValues[id], + id: id as IdType, + })), + [], ); const formattedColumns = useMemo(() => { diff --git a/webapp/src/hooks/useBlocker.ts b/webapp/src/hooks/useBlocker.ts index b9ad93d73c..adc589b46f 100644 --- a/webapp/src/hooks/useBlocker.ts +++ b/webapp/src/hooks/useBlocker.ts @@ -15,7 +15,7 @@ import type { History, Transition } from "history"; import { useContext, useEffect } from "react"; import { UNSAFE_NavigationContext as NavigationContext } from "react-router-dom"; -import useAutoUpdateRef from "./useUpdatedRef"; +import useUpdatedRef from "./useUpdatedRef"; // * Workaround until it will be supported by react-router v6. // * Based on https://ui.dev/react-router-preventing-transitions @@ -24,7 +24,7 @@ type Blocker = (tx: Transition) => void; function useBlocker(blocker: Blocker, when = true): void { const { navigator } = useContext(NavigationContext); - const blockerRef = useAutoUpdateRef(blocker); + const blockerRef = useUpdatedRef(blocker); useEffect( () => { diff --git a/webapp/src/hooks/useConfirm.ts b/webapp/src/hooks/useConfirm.ts index 233ebca829..0e505def50 100644 --- a/webapp/src/hooks/useConfirm.ts +++ b/webapp/src/hooks/useConfirm.ts @@ -13,7 +13,7 @@ */ import { useCallback, useRef, useState } from "react"; -import useAutoUpdateRef from "./useUpdatedRef"; +import useUpdatedRef from "./useUpdatedRef"; function errorFunction() { throw new Error("Promise is not pending."); @@ -61,7 +61,7 @@ function errorFunction() { */ function useConfirm() { const [isPending, setIsPending] = useState(false); - const isPendingRef = useAutoUpdateRef(isPending); + const isPendingRef = useUpdatedRef(isPending); const yesRef = useRef(errorFunction); const noRef = useRef(errorFunction); const cancelRef = useRef(errorFunction); diff --git a/webapp/src/hooks/usePromiseHandler.ts b/webapp/src/hooks/usePromiseHandler.ts index 5ffe97c314..a5748cf44b 100644 --- a/webapp/src/hooks/usePromiseHandler.ts +++ b/webapp/src/hooks/usePromiseHandler.ts @@ -16,7 +16,7 @@ import { type SnackbarKey, useSnackbar } from "notistack"; import useEnqueueErrorSnackbar from "./useEnqueueErrorSnackbar"; import { toError } from "../utils/fnUtils"; import { useCallback } from "react"; -import useAutoUpdateRef from "./useUpdatedRef"; +import useUpdatedRef from "./useUpdatedRef"; interface UsePromiseHandlerParams { fn: (...args: T) => Promise; @@ -34,7 +34,7 @@ interface UsePromiseHandlerParams { function usePromiseHandler(params: UsePromiseHandlerParams) { const { enqueueSnackbar, closeSnackbar } = useSnackbar(); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); - const paramsRef = useAutoUpdateRef(params); + const paramsRef = useUpdatedRef(params); const handlePromise = useCallback( async (...args: T) => { diff --git a/webapp/src/hooks/useSafeMemo.ts b/webapp/src/hooks/useSafeMemo.ts index ec2d9aafcb..aa97ec723d 100644 --- a/webapp/src/hooks/useSafeMemo.ts +++ b/webapp/src/hooks/useSafeMemo.ts @@ -13,20 +13,26 @@ */ import { useState } from "react"; +import { useUpdateEffect } from "react-use"; -/* - "You may rely on useMemo as a performance optimization, not as a semantic - guarantee. In the future, React may choose to “forget” some previously - memoized values and recalculate them on next render, e.g. to free memory for - offscreen components. Write your code so that it still works without useMemo — - and then add it to optimize performance." - Source: https://reactjs.org/docs/hooks-reference.html#usememo -*/ +/** + * Hook that returns a memoized value with semantic guarantee. + * + * Semantic guarantee is not provided by `useMemo`, which is solely used + * for performance optimization (cf. https://react.dev/reference/react/useMemo#caveats). + * + * @param factory - A function that returns the value to memoize. + * @param deps - Dependencies that trigger the memoization. + * @returns The memoized value. + */ +function useSafeMemo(factory: () => T, deps: React.DependencyList): T { + const [state, setState] = useState(factory); + + useUpdateEffect(() => { + setState(factory); + }, deps); -function useMemoLocked(factory: () => T): T { - // eslint-disable-next-line react/hook-use-state - const [state] = useState(factory); return state; } -export default useMemoLocked; +export default useSafeMemo; diff --git a/webapp/src/hooks/useUpdatedRef.ts b/webapp/src/hooks/useUpdatedRef.ts index 1f8cad5f57..7099380074 100644 --- a/webapp/src/hooks/useUpdatedRef.ts +++ b/webapp/src/hooks/useUpdatedRef.ts @@ -12,16 +12,24 @@ * This file is part of the Antares project. */ -import { useEffect, useRef } from "react"; +import { useLayoutEffect, useRef } from "react"; -function useAutoUpdateRef(value: T): React.MutableRefObject { +/** + * Hook that returns a mutable ref that automatically updates its value. + * + * @param value - The value to store in the ref. + * @returns The mutable ref. + */ +function useUpdatedRef(value: T): React.MutableRefObject { const ref = useRef(value); - useEffect(() => { + // `useLayoutEffect` runs before `useEffect`. So `useLayoutEffect` is used to make sure + // the value is up-to-date before any other code runs. + useLayoutEffect(() => { ref.current = value; }); return ref; } -export default useAutoUpdateRef; +export default useUpdatedRef;