From bdbf6e9ca6de99aca2d96ff7845cf2132de1d37e Mon Sep 17 00:00:00 2001
From: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
Date: Sun, 15 Sep 2024 15:10:52 -0700
Subject: [PATCH] [Maintenance] Reduce budget table re-renders (#3448)

* Reduce budget table re-renders

* Release notes
---
 .../src/components/mobile/budget/index.tsx    | 498 ++++++++++--------
 upcoming-release-notes/3448.md                |   6 +
 2 files changed, 279 insertions(+), 225 deletions(-)
 create mode 100644 upcoming-release-notes/3448.md

diff --git a/packages/desktop-client/src/components/mobile/budget/index.tsx b/packages/desktop-client/src/components/mobile/budget/index.tsx
index 65eb9543f..cb8970708 100644
--- a/packages/desktop-client/src/components/mobile/budget/index.tsx
+++ b/packages/desktop-client/src/components/mobile/budget/index.tsx
@@ -1,5 +1,5 @@
 // @ts-strict-ignore
-import React, { useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
 import { useDispatch } from 'react-redux';
 
 import {
@@ -20,10 +20,6 @@ import {
 import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
 import { send, listen } from 'loot-core/src/platform/client/fetch';
 import * as monthUtils from 'loot-core/src/shared/months';
-import {
-  type CategoryEntity,
-  type CategoryGroupEntity,
-} from 'loot-core/src/types/models';
 
 import { useCategories } from '../../../hooks/useCategories';
 import { useLocalPref } from '../../../hooks/useLocalPref';
@@ -43,15 +39,13 @@ function isBudgetType(input?: string): input is 'rollover' | 'report' {
   return ['rollover', 'report'].includes(input);
 }
 
-type BudgetInnerProps = {
-  categories: CategoryEntity[];
-  categoryGroups: CategoryGroupEntity[];
-  budgetType: 'rollover' | 'report';
-  spreadsheet: ReturnType<typeof useSpreadsheet>;
-};
+export function Budget() {
+  useSetThemeColor(theme.mobileViewTheme);
 
-function BudgetInner(props: BudgetInnerProps) {
-  const { categoryGroups, categories, budgetType, spreadsheet } = props;
+  const { list: categories, grouped: categoryGroups } = useCategories();
+  const [budgetTypePref] = useMetadataPref('budgetType');
+  const budgetType = isBudgetType(budgetTypePref) ? budgetTypePref : 'rollover';
+  const spreadsheet = useSpreadsheet();
 
   const currMonth = monthUtils.currentMonth();
   const [startMonth = currMonth, setStartMonthPref] =
@@ -60,9 +54,8 @@ function BudgetInner(props: BudgetInnerProps) {
     start: startMonth,
     end: startMonth,
   });
-  const [initialized, setInitialized] = useState(false);
   // const [editMode, setEditMode] = useState(false);
-
+  const [initialized, setInitialized] = useState(false);
   const [_numberFormat] = useSyncedPref('numberFormat');
   const numberFormat = _numberFormat || 'comma-dot';
   const [hideFraction] = useSyncedPref('hideFraction');
@@ -95,11 +88,14 @@ function BudgetInner(props: BudgetInnerProps) {
     return () => unlisten();
   }, [budgetType, startMonth, dispatch, spreadsheet]);
 
-  const onBudgetAction = async (month, type, args) => {
-    dispatch(applyBudgetAction(month, type, args));
-  };
+  const onBudgetAction = useCallback(
+    async (month, type, args) => {
+      dispatch(applyBudgetAction(month, type, args));
+    },
+    [dispatch],
+  );
 
-  const onShowBudgetSummary = () => {
+  const onShowBudgetSummary = useCallback(() => {
     if (budgetType === 'report') {
       dispatch(
         pushModal('report-budget-summary', {
@@ -114,9 +110,9 @@ function BudgetInner(props: BudgetInnerProps) {
         }),
       );
     }
-  };
+  }, [budgetType, dispatch, onBudgetAction, startMonth]);
 
-  const onOpenNewCategoryGroupModal = () => {
+  const onOpenNewCategoryGroupModal = useCallback(() => {
     dispatch(
       pushModal('new-category-group', {
         onValidate: name => (!name ? 'Name is required.' : null),
@@ -126,152 +122,180 @@ function BudgetInner(props: BudgetInnerProps) {
         },
       }),
     );
-  };
-
-  const onOpenNewCategoryModal = (groupId, isIncome) => {
-    dispatch(
-      pushModal('new-category', {
-        onValidate: name => (!name ? 'Name is required.' : null),
-        onSubmit: async name => {
-          dispatch(collapseModals('category-group-menu'));
-          dispatch(createCategory(name, groupId, isIncome, false));
-        },
-      }),
-    );
-  };
-
-  const onSaveGroup = group => {
-    dispatch(updateGroup(group));
-  };
-
-  const onDeleteGroup = async groupId => {
-    const group = categoryGroups?.find(g => g.id === groupId);
-
-    if (!group) {
-      return;
-    }
-
-    let mustTransfer = false;
-    for (const category of group.categories ?? []) {
-      if (await send('must-category-transfer', { id: category.id })) {
-        mustTransfer = true;
-        break;
-      }
-    }
+  }, [dispatch]);
 
-    if (mustTransfer) {
+  const onOpenNewCategoryModal = useCallback(
+    (groupId, isIncome) => {
       dispatch(
-        pushModal('confirm-category-delete', {
-          group: groupId,
-          onDelete: transferCategory => {
+        pushModal('new-category', {
+          onValidate: name => (!name ? 'Name is required.' : null),
+          onSubmit: async name => {
             dispatch(collapseModals('category-group-menu'));
-            dispatch(deleteGroup(groupId, transferCategory));
+            dispatch(createCategory(name, groupId, isIncome, false));
           },
         }),
       );
-    } else {
-      dispatch(collapseModals('category-group-menu'));
-      dispatch(deleteGroup(groupId));
-    }
-  };
-
-  const onToggleGroupVisibility = groupId => {
-    const group = categoryGroups.find(g => g.id === groupId);
-    onSaveGroup({
-      ...group,
-      hidden: !!!group.hidden,
-    });
-    dispatch(collapseModals('category-group-menu'));
-  };
+    },
+    [dispatch],
+  );
 
-  const onSaveCategory = category => {
-    dispatch(updateCategory(category));
-  };
+  const onSaveGroup = useCallback(
+    group => {
+      dispatch(updateGroup(group));
+    },
+    [dispatch],
+  );
 
-  const onDeleteCategory = async categoryId => {
-    const mustTransfer = await send('must-category-transfer', {
-      id: categoryId,
-    });
+  const onDeleteGroup = useCallback(
+    async groupId => {
+      const group = categoryGroups?.find(g => g.id === groupId);
 
-    if (mustTransfer) {
-      dispatch(
-        pushModal('confirm-category-delete', {
-          category: categoryId,
-          onDelete: transferCategory => {
-            if (categoryId !== transferCategory) {
-              dispatch(collapseModals('category-menu'));
-              dispatch(deleteCategory(categoryId, transferCategory));
-            }
-          },
-        }),
-      );
-    } else {
-      dispatch(collapseModals('category-menu'));
-      dispatch(deleteCategory(categoryId));
-    }
-  };
+      if (!group) {
+        return;
+      }
 
-  const onToggleCategoryVisibility = categoryId => {
-    const category = categories.find(c => c.id === categoryId);
-    onSaveCategory({
-      ...category,
-      hidden: !!!category.hidden,
-    });
-    dispatch(collapseModals('category-menu'));
-  };
+      let mustTransfer = false;
+      for (const category of group.categories ?? []) {
+        if (await send('must-category-transfer', { id: category.id })) {
+          mustTransfer = true;
+          break;
+        }
+      }
 
-  const onReorderCategory = (id, { inGroup, aroundCategory }) => {
-    let groupId, targetId;
+      if (mustTransfer) {
+        dispatch(
+          pushModal('confirm-category-delete', {
+            group: groupId,
+            onDelete: transferCategory => {
+              dispatch(collapseModals('category-group-menu'));
+              dispatch(deleteGroup(groupId, transferCategory));
+            },
+          }),
+        );
+      } else {
+        dispatch(collapseModals('category-group-menu'));
+        dispatch(deleteGroup(groupId));
+      }
+    },
+    [categoryGroups, dispatch],
+  );
 
-    if (inGroup) {
-      groupId = inGroup;
-    } else if (aroundCategory) {
-      const { id: originalCatId, position } = aroundCategory;
+  const onToggleGroupVisibility = useCallback(
+    groupId => {
+      const group = categoryGroups.find(g => g.id === groupId);
+      onSaveGroup({
+        ...group,
+        hidden: !!!group.hidden,
+      });
+      dispatch(collapseModals('category-group-menu'));
+    },
+    [categoryGroups, dispatch, onSaveGroup],
+  );
 
-      let catId = originalCatId;
-      const group = categoryGroups.find(group =>
-        group.categories?.find(cat => cat.id === catId),
-      );
+  const onSaveCategory = useCallback(
+    category => {
+      dispatch(updateCategory(category));
+    },
+    [dispatch],
+  );
 
-      if (position === 'bottom') {
-        const idx = group?.categories?.findIndex(cat => cat.id === catId) ?? -1;
-        catId = group?.categories
-          ? idx < group.categories.length - 1
-            ? group.categories[idx + 1].id
-            : null
-          : null;
+  const onDeleteCategory = useCallback(
+    async categoryId => {
+      const mustTransfer = await send('must-category-transfer', {
+        id: categoryId,
+      });
+
+      if (mustTransfer) {
+        dispatch(
+          pushModal('confirm-category-delete', {
+            category: categoryId,
+            onDelete: transferCategory => {
+              if (categoryId !== transferCategory) {
+                dispatch(collapseModals('category-menu'));
+                dispatch(deleteCategory(categoryId, transferCategory));
+              }
+            },
+          }),
+        );
+      } else {
+        dispatch(collapseModals('category-menu'));
+        dispatch(deleteCategory(categoryId));
       }
+    },
+    [dispatch],
+  );
 
-      groupId = group?.id;
-      targetId = catId;
-    }
+  const onToggleCategoryVisibility = useCallback(
+    categoryId => {
+      const category = categories.find(c => c.id === categoryId);
+      onSaveCategory({
+        ...category,
+        hidden: !!!category.hidden,
+      });
+      dispatch(collapseModals('category-menu'));
+    },
+    [categories, dispatch, onSaveCategory],
+  );
 
-    dispatch(moveCategory(id, groupId, targetId));
-  };
+  const onReorderCategory = useCallback(
+    (id, { inGroup, aroundCategory }) => {
+      let groupId, targetId;
+
+      if (inGroup) {
+        groupId = inGroup;
+      } else if (aroundCategory) {
+        const { id: originalCatId, position } = aroundCategory;
+
+        let catId = originalCatId;
+        const group = categoryGroups.find(group =>
+          group.categories?.find(cat => cat.id === catId),
+        );
+
+        if (position === 'bottom') {
+          const idx =
+            group?.categories?.findIndex(cat => cat.id === catId) ?? -1;
+          catId = group?.categories
+            ? idx < group.categories.length - 1
+              ? group.categories[idx + 1].id
+              : null
+            : null;
+        }
+
+        groupId = group?.id;
+        targetId = catId;
+      }
 
-  const onReorderGroup = (id, targetId, position) => {
-    if (position === 'bottom') {
-      const idx = categoryGroups.findIndex(group => group.id === targetId);
-      targetId =
-        idx < categoryGroups.length - 1 ? categoryGroups[idx + 1].id : null;
-    }
+      dispatch(moveCategory(id, groupId, targetId));
+    },
+    [categoryGroups, dispatch],
+  );
 
-    dispatch(moveCategoryGroup(id, targetId));
-  };
+  const onReorderGroup = useCallback(
+    (id, targetId, position) => {
+      if (position === 'bottom') {
+        const idx = categoryGroups.findIndex(group => group.id === targetId);
+        targetId =
+          idx < categoryGroups.length - 1 ? categoryGroups[idx + 1].id : null;
+      }
+
+      dispatch(moveCategoryGroup(id, targetId));
+    },
+    [categoryGroups, dispatch],
+  );
 
-  const onPrevMonth = async () => {
+  const onPrevMonth = useCallback(async () => {
     const month = monthUtils.subMonths(startMonth, 1);
     await prewarmMonth(budgetType, spreadsheet, month);
     setStartMonthPref(month);
     setInitialized(true);
-  };
+  }, [budgetType, setStartMonthPref, spreadsheet, startMonth]);
 
-  const onNextMonth = async () => {
+  const onNextMonth = useCallback(async () => {
     const month = monthUtils.addMonths(startMonth, 1);
     await prewarmMonth(budgetType, spreadsheet, month);
     setStartMonthPref(month);
     setInitialized(true);
-  };
+  }, [budgetType, setStartMonthPref, spreadsheet, startMonth]);
 
   // const onOpenMonthActionMenu = () => {
   //   const options = [
@@ -312,94 +336,128 @@ function BudgetInner(props: BudgetInnerProps) {
   //   );
   // };
 
-  const onSaveNotes = async (id, notes) => {
+  const onSaveNotes = useCallback(async (id, notes) => {
     await send('notes-save', { id, note: notes });
-  };
+  }, []);
 
-  const onOpenCategoryGroupNotesModal = id => {
-    const group = categoryGroups.find(g => g.id === id);
-    dispatch(
-      pushModal('notes', {
-        id,
-        name: group.name,
-        onSave: onSaveNotes,
-      }),
-    );
-  };
+  const onOpenCategoryGroupNotesModal = useCallback(
+    id => {
+      const group = categoryGroups.find(g => g.id === id);
+      dispatch(
+        pushModal('notes', {
+          id,
+          name: group.name,
+          onSave: onSaveNotes,
+        }),
+      );
+    },
+    [categoryGroups, dispatch, onSaveNotes],
+  );
 
-  const onOpenCategoryNotesModal = id => {
-    const category = categories.find(c => c.id === id);
-    dispatch(
-      pushModal('notes', {
-        id,
-        name: category.name,
-        onSave: onSaveNotes,
-      }),
-    );
-  };
+  const onOpenCategoryNotesModal = useCallback(
+    id => {
+      const category = categories.find(c => c.id === id);
+      dispatch(
+        pushModal('notes', {
+          id,
+          name: category.name,
+          onSave: onSaveNotes,
+        }),
+      );
+    },
+    [categories, dispatch, onSaveNotes],
+  );
 
-  const onOpenCategoryGroupMenuModal = id => {
-    const group = categoryGroups.find(g => g.id === id);
-    dispatch(
-      pushModal('category-group-menu', {
-        groupId: group.id,
-        onSave: onSaveGroup,
-        onAddCategory: onOpenNewCategoryModal,
-        onEditNotes: onOpenCategoryGroupNotesModal,
-        onDelete: onDeleteGroup,
-        onToggleVisibility: onToggleGroupVisibility,
-      }),
-    );
-  };
+  const onOpenCategoryGroupMenuModal = useCallback(
+    id => {
+      const group = categoryGroups.find(g => g.id === id);
+      dispatch(
+        pushModal('category-group-menu', {
+          groupId: group.id,
+          onSave: onSaveGroup,
+          onAddCategory: onOpenNewCategoryModal,
+          onEditNotes: onOpenCategoryGroupNotesModal,
+          onDelete: onDeleteGroup,
+          onToggleVisibility: onToggleGroupVisibility,
+        }),
+      );
+    },
+    [
+      categoryGroups,
+      dispatch,
+      onDeleteGroup,
+      onOpenCategoryGroupNotesModal,
+      onOpenNewCategoryModal,
+      onSaveGroup,
+      onToggleGroupVisibility,
+    ],
+  );
 
-  const onOpenCategoryMenuModal = id => {
-    const category = categories.find(c => c.id === id);
-    dispatch(
-      pushModal('category-menu', {
-        categoryId: category.id,
-        onSave: onSaveCategory,
-        onEditNotes: onOpenCategoryNotesModal,
-        onDelete: onDeleteCategory,
-        onToggleVisibility: onToggleCategoryVisibility,
-        onBudgetAction,
-      }),
-    );
-  };
+  const onOpenCategoryMenuModal = useCallback(
+    id => {
+      const category = categories.find(c => c.id === id);
+      dispatch(
+        pushModal('category-menu', {
+          categoryId: category.id,
+          onSave: onSaveCategory,
+          onEditNotes: onOpenCategoryNotesModal,
+          onDelete: onDeleteCategory,
+          onToggleVisibility: onToggleCategoryVisibility,
+          onBudgetAction,
+        }),
+      );
+    },
+    [
+      categories,
+      dispatch,
+      onBudgetAction,
+      onDeleteCategory,
+      onOpenCategoryNotesModal,
+      onSaveCategory,
+      onToggleCategoryVisibility,
+    ],
+  );
 
   const [showHiddenCategories, setShowHiddenCategoriesPref] = useLocalPref(
     'budget.showHiddenCategories',
   );
 
-  const onToggleHiddenCategories = () => {
+  const onToggleHiddenCategories = useCallback(() => {
     setShowHiddenCategoriesPref(!showHiddenCategories);
     dispatch(collapseModals('budget-page-menu'));
-  };
+  }, [dispatch, setShowHiddenCategoriesPref, showHiddenCategories]);
 
-  const onOpenBudgetMonthNotesModal = month => {
-    dispatch(
-      pushModal('notes', {
-        id: `budget-${month}`,
-        name: monthUtils.format(month, 'MMMM ‘yy'),
-        onSave: onSaveNotes,
-      }),
-    );
-  };
+  const onOpenBudgetMonthNotesModal = useCallback(
+    month => {
+      dispatch(
+        pushModal('notes', {
+          id: `budget-${month}`,
+          name: monthUtils.format(month, 'MMMM ‘yy'),
+          onSave: onSaveNotes,
+        }),
+      );
+    },
+    [dispatch, onSaveNotes],
+  );
 
-  const onSwitchBudgetFile = () => {
+  const onSwitchBudgetFile = useCallback(() => {
     dispatch(pushModal('budget-list'));
-  };
+  }, [dispatch]);
 
-  const onOpenBudgetMonthMenu = month => {
-    dispatch(
-      pushModal(`${budgetType}-budget-month-menu`, {
-        month,
-        onBudgetAction,
-        onEditNotes: onOpenBudgetMonthNotesModal,
-      }),
-    );
-  };
+  const onOpenBudgetMonthMenu = useCallback(
+    month => {
+      dispatch(
+        pushModal(`${budgetType}-budget-month-menu`, {
+          month,
+          onBudgetAction,
+          onEditNotes: onOpenBudgetMonthNotesModal,
+        }),
+      );
+    },
+    [budgetType, dispatch, onBudgetAction, onOpenBudgetMonthNotesModal],
+  );
 
-  const onOpenBudgetPageMenu = () => {
+  const onOpenBudgetPageMenu = useCallback(() => {
     dispatch(
       pushModal('budget-page-menu', {
         onAddCategoryGroup: onOpenNewCategoryGroupModal,
@@ -407,7 +465,12 @@ function BudgetInner(props: BudgetInnerProps) {
         onSwitchBudgetFile,
       }),
     );
-  };
+  }, [
+    dispatch,
+    onOpenNewCategoryGroupModal,
+    onSwitchBudgetFile,
+    onToggleHiddenCategories,
+  ]);
 
   if (!categoryGroups || !initialized) {
     return (
@@ -464,18 +527,3 @@ function BudgetInner(props: BudgetInnerProps) {
     </NamespaceContext.Provider>
   );
 }
-
-export function Budget() {
-  const { list: categories, grouped: categoryGroups } = useCategories();
-  const [budgetType] = useMetadataPref('budgetType');
-  const spreadsheet = useSpreadsheet();
-  useSetThemeColor(theme.mobileViewTheme);
-  return (
-    <BudgetInner
-      categoryGroups={categoryGroups}
-      categories={categories}
-      budgetType={isBudgetType(budgetType) ? budgetType : 'rollover'}
-      spreadsheet={spreadsheet}
-    />
-  );
-}
diff --git a/upcoming-release-notes/3448.md b/upcoming-release-notes/3448.md
new file mode 100644
index 000000000..e475df111
--- /dev/null
+++ b/upcoming-release-notes/3448.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [joel-jeremy]
+---
+
+Reduce budget table re-renders
-- 
GitLab