diff --git a/webapp/eslint.config.js b/webapp/eslint.config.js index 245c2a0812..f7487ebe11 100644 --- a/webapp/eslint.config.js +++ b/webapp/eslint.config.js @@ -58,6 +58,14 @@ export default [ }, rules: { ...reactHookPlugin.configs.recommended.rules, + "react-hooks/exhaustive-deps": [ + "warn", + { + // Includes hooks from 'react-use' + additionalHooks: + "(useSafeMemo|useUpdateEffectOnce|useDeepCompareEffect|useShallowCompareEffect|useCustomCompareEffect)", + }, + ], "@typescript-eslint/array-type": ["error", { default: "array-simple" }], "@typescript-eslint/no-restricted-imports": [ "error", 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 1f682d943c..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/useAutoUpdateRef"; +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 e0cc842ecc..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/useAutoUpdateRef"; +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 bb2f9d8df0..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/useAutoUpdateRef"; +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 d6011a555a..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/useAutoUpdateRef"; +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..c1d8cef77c 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/useMemoLocked"; 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/useAutoUpdateRef.ts b/webapp/src/hooks/useAutoUpdateRef.ts deleted file mode 100644 index 1f8cad5f57..0000000000 --- a/webapp/src/hooks/useAutoUpdateRef.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (c) 2025, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { useEffect, useRef } from "react"; - -function useAutoUpdateRef(value: T): React.MutableRefObject { - const ref = useRef(value); - - useEffect(() => { - ref.current = value; - }); - - return ref; -} - -export default useAutoUpdateRef; diff --git a/webapp/src/hooks/useBlocker.ts b/webapp/src/hooks/useBlocker.ts index b404e3bf5f..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 "./useAutoUpdateRef"; +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 026388b0a6..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 "./useAutoUpdateRef"; +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/useMemoLocked.ts b/webapp/src/hooks/useMemoLocked.ts deleted file mode 100644 index ec2d9aafcb..0000000000 --- a/webapp/src/hooks/useMemoLocked.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright (c) 2025, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { useState } from "react"; - -/* - "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 -*/ - -function useMemoLocked(factory: () => T): T { - // eslint-disable-next-line react/hook-use-state - const [state] = useState(factory); - return state; -} - -export default useMemoLocked; diff --git a/webapp/src/hooks/usePromiseHandler.ts b/webapp/src/hooks/usePromiseHandler.ts index a4dd3b68f5..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 "./useAutoUpdateRef"; +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 new file mode 100644 index 0000000000..aa97ec723d --- /dev/null +++ b/webapp/src/hooks/useSafeMemo.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2025, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { useState } from "react"; +import { useUpdateEffect } from "react-use"; + +/** + * 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); + + return state; +} + +export default useSafeMemo; diff --git a/webapp/src/hooks/useUpdatedRef.ts b/webapp/src/hooks/useUpdatedRef.ts new file mode 100644 index 0000000000..7099380074 --- /dev/null +++ b/webapp/src/hooks/useUpdatedRef.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2025, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { useLayoutEffect, useRef } from "react"; + +/** + * 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); + + // `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 useUpdatedRef;