Repository: lablup/backend.ai-webui (language: python)

Issue:
hooks:  useMemoWithPrevious
Sometimes, when using `useMemo`, you may need to utilize the previous value. To achieve this, let's implement a custom hook that memoizes not only the value of `useMemo` but also the previous value when the dependency list is updated.
 
- [Teams](https://teams.microsoft.com/l/message/19:14c484402d874dafb15806d093b95a82@thread.skype/1730801712819?tenantId=13c6a44d-9b52-4b9e-aa34-0513ee7131f2&groupId=74ae2c4d-ec4d-4fdf-b2c2-f5041d1e8631&parentMessageId=1730109267517&teamName=devops&channelName=Frontend&createdTime=1730801712819)


Discussion / hints:

Fix patch:
diff --git a/react/src/components/ComputeSessionNodeItems/ContainerLogModal.tsx b/react/src/components/ComputeSessionNodeItems/ContainerLogModal.tsx
index 6f1e010c20..b8a1b2cda0 100644
--- a/react/src/components/ComputeSessionNodeItems/ContainerLogModal.tsx
+++ b/react/src/components/ComputeSessionNodeItems/ContainerLogModal.tsx
@@ -1,6 +1,7 @@
 import { downloadBlob } from '../../helper/csv-util';
 import { useSuspendedBackendaiClient } from '../../hooks';
 import { useTanQuery } from '../../hooks/reactQueryAlias';
+import { useMemoWithPrevious } from '../../hooks/useMemoWithPrevious';
 import BAIModal, { BAIModalProps } from '../BAIModal';
 import Flex from '../Flex';
 import { ContainerLogModalFragment$key } from './__generated__/ContainerLogModalFragment.graphql';
@@ -18,7 +19,7 @@ import {
 import graphql from 'babel-plugin-relay/macro';
 import _ from 'lodash';
 import { DownloadIcon } from 'lucide-react';
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useFragment } from 'react-relay';
 
@@ -112,15 +113,11 @@ const ContainerLogModal: React.FC<ContainerLogModalProps> = ({
   // console.log(signed)
   // console.log(signed.uri);
 
-  const previousLastLineNumber = useRef(0);
-
-  useEffect(() => {
-    previousLastLineNumber.current = logs?.split('\n').length || 0;
-  }, [logs]);
+  const [lastLineNumbers, { resetPrevious: resetPreviousLineNumber }] =
+    useMemoWithPrevious(() => logs?.split('\n').length || 0, [logs]);
 
   const { md } = Grid.useBreakpoint();
   const { t } = useTranslation();
-  const fixedPreviousLastLineNumber = previousLastLineNumber.current;
 
   return (
     <BAIModal
@@ -170,7 +167,7 @@ const ContainerLogModal: React.FC<ContainerLogModalProps> = ({
             value={selectedKernelId}
             onChange={(value) => {
               setSelectedKernelId(value);
-              previousLastLineNumber.current = 0; // reset previous last line number
+              resetPreviousLineNumber();
             }}
             options={_.chain(session?.kernel_nodes?.edges)
               .map((e) => {
@@ -199,7 +196,7 @@ const ContainerLogModal: React.FC<ContainerLogModalProps> = ({
             onChange={(value) => { 
               setLogSize(value);
               if(value!=='full'){
-                previousLastLineNumber.current = 0; // reset previous last line number
+                resetPreviousLineNumber();
               }
               refetch();
             }}
@@ -248,7 +245,7 @@ const ContainerLogModal: React.FC<ContainerLogModalProps> = ({
                 enableSearchNavigation
                 selectableLines
                 text={logs || ''}
-                highlight={fixedPreviousLastLineNumber}
+                highlight={lastLineNumbers.previous}
                 extraLines={1}
                 // url={signed.uri}
                 // fetchOptions={
diff --git a/react/src/hooks/useMemoWithPrevious.tsx b/react/src/hooks/useMemoWithPrevious.tsx
new file mode 100644
index 0000000000..0d6eb0aaed
--- /dev/null
+++ b/react/src/hooks/useMemoWithPrevious.tsx
@@ -0,0 +1,37 @@
+import { DependencyList, useEffect, useMemo, useRef, useState } from 'react';
+
+export const useMemoWithPrevious = <T,>(
+  factory: () => T,
+  deps: DependencyList,
+  { initialPrev }: { initialPrev?: T } | undefined = {},
+) => {
+  const prevRef = useRef(initialPrev);
+  const [prevResetKey, setPrevResetKey] = useState({});
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const current = useMemo(factory, deps);
+  const memoizedPrev = useMemo(() => {
+    return prevRef.current;
+    // Only update when the reset key changes and deps change
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [...deps, prevResetKey]);
+
+  useEffect(() => {
+    prevRef.current = current;
+    // Only update when deps change
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, deps);
+
+  return [
+    {
+      previous: memoizedPrev,
+      current: current,
+    },
+    {
+      resetPrevious: () => {
+        prevRef.current = initialPrev;
+        setPrevResetKey({});
+      },
+    },
+  ] as const;
+};


Test patch:
diff --git a/react/src/hooks/useMemoWithPrevious.test.tsx b/react/src/hooks/useMemoWithPrevious.test.tsx
new file mode 100644
index 0000000000..279c67c981
--- /dev/null
+++ b/react/src/hooks/useMemoWithPrevious.test.tsx
@@ -0,0 +1,91 @@
+import { useMemoWithPrevious } from './useMemoWithPrevious';
+import { renderHook, act } from '@testing-library/react';
+
+describe('useMemoWithPrevious Hook', () => {
+  it('should initialize with initialPrev', () => {
+    const factory = jest.fn(() => 'current value');
+    const { result } = renderHook(() =>
+      useMemoWithPrevious(factory, [], {
+        initialPrev: 'initial previous value',
+      }),
+    );
+
+    expect(result.current[0].current).toBe('current value');
+    expect(result.current[0].previous).toBe('initial previous value');
+    expect(factory).toHaveBeenCalledTimes(1);
+  });
+
+  it('should update current and previous when dependencies change', () => {
+    let dep = 1;
+    const factory = jest.fn(() => `current value ${dep}`);
+
+    const { result, rerender } = renderHook(() =>
+      useMemoWithPrevious(factory, [dep]),
+    );
+
+    // Initial render
+    expect(result.current[0].current).toBe('current value 1');
+    expect(result.current[0].previous).toBeUndefined();
+    expect(factory).toHaveBeenCalledTimes(1);
+
+    // Update dep and rerender
+    // act(() => {
+    dep = 2;
+    rerender();
+    // });
+
+    // After dependency change
+    expect(result.current[0].current).toBe('current value 2');
+    expect(result.current[0].previous).toBe('current value 1');
+    expect(factory).toHaveBeenCalledTimes(2);
+  });
+
+  it('should reset previous when resetPrevious is called', () => {
+    let dep = 1;
+    const factory = jest.fn(() => `current value ${dep}`);
+
+    const { result, rerender } = renderHook(() =>
+      useMemoWithPrevious(factory, [dep], {
+        initialPrev: 'initial previous value',
+      }),
+    );
+
+    // Initial render
+    expect(result.current[0].current).toBe('current value 1');
+    expect(result.current[0].previous).toBe('initial previous value');
+
+    // Update dep and rerender
+    dep = 2;
+    rerender();
+
+    expect(result.current[0].current).toBe('current value 2');
+    expect(result.current[0].previous).toBe('current value 1');
+
+    // Call resetPrevious
+    act(() => {
+      result.current[1].resetPrevious();
+    });
+
+    expect(result.current[0].current).toBe('current value 2');
+    expect(result.current[0].previous).toBe('initial previous value');
+    expect(factory).toHaveBeenCalledTimes(2);
+  });
+
+  it('should not update previous if dependencies do not change', () => {
+    const factory = jest.fn(() => 'current value');
+    const { result, rerender } = renderHook(() =>
+      useMemoWithPrevious(factory, []),
+    );
+
+    // Initial render
+    expect(result.current[0].current).toBe('current value');
+    expect(result.current[0].previous).toBeUndefined();
+
+    // Rerender without changing dependencies
+    rerender();
+
+    expect(result.current[0].current).toBe('current value');
+    expect(result.current[0].previous).toBeUndefined();
+    expect(factory).toHaveBeenCalledTimes(1);
+  });
+});