From 8da96f54ed8102e11a6d3050d989304f06ca9910 Mon Sep 17 00:00:00 2001
From: Humaira Ambreen <181621420+humairaambreen@users.noreply.github.com>
Date: Fri, 25 Sep 2026 19:44:12 +0530
Subject: [PATCH] fix(home): reuse cache after form cancel
Refs #85
---
client/src/__test__/views/Home.test.tsx | 5 +-
.../__test__/views/HomeNavigation.test.tsx | 201 ++++++++++++++++++
client/src/utils/HomeCache.ts | 75 +++++++
client/src/views/Home/index.tsx | 108 +++++++---
client/src/views/NoteAdd/index.tsx | 2 +-
client/src/views/TaskAdd/index.tsx | 4 +-
6 files changed, 357 insertions(+), 38 deletions(-)
create mode 100644 client/src/__test__/views/HomeNavigation.test.tsx
create mode 100644 client/src/utils/HomeCache.ts
diff --git a/client/src/__test__/views/Home.test.tsx b/client/src/__test__/views/Home.test.tsx
index 04db8a7c..e0510e9a 100644
--- a/client/src/__test__/views/Home.test.tsx
+++ b/client/src/__test__/views/Home.test.tsx
@@ -8,6 +8,7 @@ import api from '../../api-service/api';
import { TaskResponse } from '../../types/TaskResponse';
import { NoteResponse } from '../../types/NoteResponse';
import Home from '../../views/Home';
+import { clearHomeCache } from '../../utils/HomeCache';
// filepath: client/src/views/Home/index.test.tsx
@@ -40,6 +41,7 @@ vi.mock('../../utils/TranslatorUtils', () => ({
}));
vi.mock('react-router', () => ({
+ useLocation: () => ({ state: null }),
NavLink: ({ to, children }: { to: string, children: React.ReactNode }) => (
{children}
)
@@ -227,6 +229,7 @@ describe('Home Component', () => {
beforeEach(() => {
// Reset mocks and setup default responses
+ clearHomeCache();
vi.clearAllMocks();
localStorage.clear();
(api.getJSON as any).mockImplementation((url: string) => {
@@ -984,4 +987,4 @@ describe('Home Component', () => {
// First note should have preview with the first two lines
expect(noteCards[0].textContent).toBe('Line 1\nLine 2');
});*/
-});
\ No newline at end of file
+});
diff --git a/client/src/__test__/views/HomeNavigation.test.tsx b/client/src/__test__/views/HomeNavigation.test.tsx
new file mode 100644
index 00000000..109a5fe7
--- /dev/null
+++ b/client/src/__test__/views/HomeNavigation.test.tsx
@@ -0,0 +1,201 @@
+import React from 'react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
+import AuthContext from '../../context/AuthContext';
+import FilterProvider from '../../context/FilterProvider';
+import SidebarContext from '../../context/SidebarContext';
+import api from '../../api-service/api';
+import Home from '../../views/Home';
+import {
+ cacheHomeItems,
+ clearHomeCache,
+ getCachedHomeItems
+} from '../../utils/HomeCache';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ i18n: { language: 'en', changeLanguage: vi.fn() }
+ })
+}));
+
+vi.mock('../../api-service/api', () => ({
+ default: {
+ getJSON: vi.fn(),
+ postJSON: vi.fn(),
+ patchJSON: vi.fn(),
+ putJSON: vi.fn(),
+ deleteNoContent: vi.fn()
+ }
+}));
+
+vi.mock('../../lang-service/LangHandler', () => ({
+ handleDefaultLang: vi.fn()
+}));
+
+vi.mock('../../utils/TranslatorUtils', () => ({
+ translateServerResponse: (text: string) => text,
+ translateTaskResponse: (tasks: unknown[]) => tasks
+}));
+
+const user = {
+ userId: 9,
+ name: 'Test User',
+ email: 'test@example.com',
+ admin: false,
+ createdAt: new Date(),
+ gravatarImageUrl: '',
+ lang: 'en',
+ lastLogin: '',
+ theme: 'light'
+};
+
+const authValue = {
+ signed: true,
+ loading: false,
+ user,
+ checkCurrentAuthUser: vi.fn(),
+ signIn: vi.fn(),
+ signOut: vi.fn(),
+ register: vi.fn(),
+ isAdmin: false,
+ updateUser: vi.fn()
+};
+
+const task = (id: number, description: string) => ({
+ id,
+ description,
+ completed: false,
+ urls: [],
+ tags: [],
+ lastUpdate: '2026-09-25',
+ highPriority: false,
+ dueDateFmt: '',
+ dueDate: ''
+});
+
+function FormRoute() {
+ const navigate = useNavigate();
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function TestApp() {
+ return (
+
+
+
+
+
+
+ } />
+ } />
+
+
+
+
+
+ );
+}
+
+function NavigateToForm() {
+ const navigate = useNavigate();
+ return ;
+}
+
+const getItemCalls = () => (api.getJSON as ReturnType).mock.calls
+ .filter(([url]) => String(url).includes('/home/items'));
+
+const getTagCalls = () => (api.getJSON as ReturnType).mock.calls
+ .filter(([url]) => String(url).includes('/tasks/tags'));
+
+describe('Home navigation cache', () => {
+ beforeEach(() => {
+ clearHomeCache();
+ localStorage.clear();
+ vi.clearAllMocks();
+ (api.getJSON as ReturnType).mockImplementation((url: string) => {
+ if (url.includes('/tasks/tags')) {
+ return Promise.resolve(['work']);
+ }
+ if (url.includes('/home/items')) {
+ return Promise.resolve({ tasks: [task(1, 'Saved from server')], notes: [] });
+ }
+ return Promise.resolve([]);
+ });
+ });
+
+ afterEach(() => {
+ clearHomeCache();
+ });
+
+ test('keeps cached responses separate from the API and Home view', () => {
+ const response = { tasks: [task(1, 'Cached')], notes: [] };
+ cacheHomeItems(user.userId, '/home/items', response);
+
+ response.tasks[0].lastUpdate = 'changed by Home';
+ response.tasks[0].tags.push('changed by API');
+
+ const firstRead = getCachedHomeItems(user.userId, '/home/items');
+ expect(firstRead?.tasks[0].lastUpdate).toBe('2026-09-25');
+ expect(firstRead?.tasks[0].tags).toEqual([]);
+
+ firstRead!.tasks[0].lastUpdate = 'changed by Home';
+ firstRead!.tasks[0].urls.push('changed by Home');
+
+ const secondRead = getCachedHomeItems(user.userId, '/home/items');
+ expect(secondRead?.tasks[0].lastUpdate).toBe('2026-09-25');
+ expect(secondRead?.tasks[0].urls).toEqual([]);
+ });
+
+ test('returns to cached home data without fetching when a form is cancelled', async () => {
+ render();
+
+ await waitFor(() => expect(getItemCalls()).toHaveLength(1));
+ await waitFor(() => expect(getTagCalls()).toHaveLength(1));
+ expect(await screen.findByText('Saved from server')).toBeDefined();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add task' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ expect(await screen.findByText('Saved from server')).toBeDefined();
+ expect(getItemCalls()).toHaveLength(1);
+ expect(getTagCalls()).toHaveLength(1);
+ });
+
+ test('refreshes home items and tags after a form is saved', async () => {
+ let itemRequest = 0;
+ (api.getJSON as ReturnType).mockImplementation((url: string) => {
+ if (url.includes('/tasks/tags')) {
+ return Promise.resolve(itemRequest === 0 ? ['work'] : ['work', 'new']);
+ }
+ if (url.includes('/home/items')) {
+ itemRequest += 1;
+ return Promise.resolve({
+ tasks: [task(1, itemRequest === 1 ? 'Before save' : 'After save')],
+ notes: []
+ });
+ }
+ return Promise.resolve([]);
+ });
+
+ render();
+ expect(await screen.findByText('Before save')).toBeDefined();
+ await waitFor(() => expect(getTagCalls()).toHaveLength(1));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add task' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(await screen.findByText('After save')).toBeDefined();
+ await waitFor(() => {
+ expect(getItemCalls()).toHaveLength(2);
+ expect(getTagCalls()).toHaveLength(2);
+ });
+ });
+});
diff --git a/client/src/utils/HomeCache.ts b/client/src/utils/HomeCache.ts
new file mode 100644
index 00000000..932462e6
--- /dev/null
+++ b/client/src/utils/HomeCache.ts
@@ -0,0 +1,75 @@
+import { HomeItemsResponse } from '../types/HomeItemsResponse';
+
+const itemsByUser = new Map();
+const tagsByUser = new Map();
+
+const copyHomeItems = (items: HomeItemsResponse): HomeItemsResponse => ({
+ tasks: items.tasks.map(task => ({ ...task, tags: [...task.tags], urls: [...task.urls] })),
+ notes: items.notes.map(note => ({ ...note, tags: [...note.tags] }))
+});
+
+/**
+ * Return the last home response for this user and filter, if one is available.
+ *
+ * @param {number} userId - The signed-in user's ID.
+ * @param {string} itemsUrl - The URL identifying the current filter.
+ * @returns {HomeItemsResponse | undefined} A cached response, if available.
+ */
+export const getCachedHomeItems = (
+ userId: number,
+ itemsUrl: string
+): HomeItemsResponse | undefined => {
+ const cached = itemsByUser.get(userId);
+ return cached?.itemsUrl === itemsUrl
+ ? copyHomeItems(cached.items)
+ : undefined;
+};
+
+/**
+ * Store the latest home response for this user and filter.
+ *
+ * @param {number} userId - The signed-in user's ID.
+ * @param {string} itemsUrl - The URL identifying the current filter.
+ * @param {HomeItemsResponse} items - The response returned by the home API.
+ */
+export const cacheHomeItems = (
+ userId: number,
+ itemsUrl: string,
+ items: HomeItemsResponse
+): void => {
+ itemsByUser.set(userId, {
+ itemsUrl,
+ items: copyHomeItems(items)
+ });
+};
+
+/**
+ * Return the last tag list loaded for this user, if one is available.
+ *
+ * @param {number} userId - The signed-in user's ID.
+ * @returns {string[] | undefined} A cached tag list, if available.
+ */
+export const getCachedHomeTags = (userId: number): string[] | undefined => {
+ const tags = tagsByUser.get(userId);
+ return tags ? [...tags] : undefined;
+};
+
+/**
+ * Store the latest tag list loaded for this user.
+ *
+ * @param {number} userId - The signed-in user's ID.
+ * @param {string[]} tags - The tags returned by the home API.
+ */
+export const cacheHomeTags = (userId: number, tags: string[]): void => {
+ tagsByUser.set(userId, [...tags]);
+};
+
+/**
+ * Clear the in-memory home data cache.
+ *
+ * This is exported so tests can start with an empty cache.
+ */
+export const clearHomeCache = (): void => {
+ itemsByUser.clear();
+ tagsByUser.clear();
+};
diff --git a/client/src/views/Home/index.tsx b/client/src/views/Home/index.tsx
index 79a107e1..fa8f4183 100644
--- a/client/src/views/Home/index.tsx
+++ b/client/src/views/Home/index.tsx
@@ -24,12 +24,18 @@ import FilterContext from '../../context/FilterContext';
import ContentHeader from '../../components/ContentHeader';
import AlertError from '../../components/AlertError';
import { CheckSquare, JournalText, ThreeDotsVertical } from 'react-bootstrap-icons';
-import { NavLink } from 'react-router';
+import { NavLink, useLocation } from 'react-router';
import ModalMarkdown from '../../components/ModalMarkdown';
import TaskTitle from '../../components/TaskTitle';
import TaskTimeLeft from '../../components/TaskTimeLeft';
import TaskTag from '../../components/TaskTag';
import NoteTitle from '../../components/NoteTitle';
+import {
+ cacheHomeItems,
+ cacheHomeTags,
+ getCachedHomeItems,
+ getCachedHomeTags
+} from '../../utils/HomeCache';
const OPEN_NOTE_ID_KEY = 'OPEN_NOTE_ID';
const SEARCH_DEBOUNCE_MS = 300;
@@ -45,6 +51,9 @@ function Home(): React.ReactNode {
const { user } = useContext(AuthContext);
const { filterText, selectedOption, setFilterText, setSelectedOption } = useContext(FilterContext);
const { i18n, t } = useTranslation();
+ const location = useLocation();
+ const refreshAfterSave
+ = (location.state as { refreshHome?: boolean } | null)?.refreshHome === true;
const [errorMessage, setErrorMessage] = useState('');
const [tags, setTags] = useState([]);
const [name, setName] = useState(user?.name ? user?.name : 'User');
@@ -72,10 +81,21 @@ function Home(): React.ReactNode {
}
};
- const loadTags = async (): Promise => {
+ const loadTags = async (forceRefresh = false): Promise => {
+ if (!forceRefresh && user) {
+ const cachedTags = getCachedHomeTags(user.userId);
+ if (cachedTags) {
+ setTags(cachedTags);
+ return;
+ }
+ }
+
try {
const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`);
setTags(response);
+ if (user) {
+ cacheHomeTags(user.userId, response);
+ }
}
catch (e) {
handleError(e);
@@ -106,7 +126,7 @@ function Home(): React.ReactNode {
try {
await api.deleteNoContent(`${ApiConfig.tasksUrl}/${taskIdParam}`);
await loadItems(filterText, selectedOption);
- await loadTags();
+ await loadTags(true);
}
catch (e) {
handleError(e);
@@ -122,7 +142,7 @@ function Home(): React.ReactNode {
try {
await api.deleteNoContent(`${ApiConfig.notesUrl}/${noteIdParam}`);
await loadItems(filterText, selectedOption);
- await loadTags();
+ await loadTags(true);
}
catch (e) {
handleError(e);
@@ -275,40 +295,60 @@ function Home(): React.ReactNode {
* @param {string} text - The search text.
* @param {string | undefined} option - The selected filter option.
*/
- const loadItems = async (text: string, option: string | undefined): Promise => {
+ const loadItems = async (
+ text: string,
+ option: string | undefined,
+ forceRefresh = true
+ ): Promise => {
+ const itemsUrl = buildItemsUrl(text, option);
+ if (!forceRefresh && user) {
+ const cachedItems = getCachedHomeItems(user.userId, itemsUrl);
+ if (cachedItems) {
+ updateHomeItems(cachedItems);
+ return;
+ }
+ }
+
try {
- const response: HomeItemsResponse = await api.getJSON(buildItemsUrl(text, option));
-
- const translatedTasks = translateTaskResponse(response.tasks ?? [], i18n.language);
- translatedTasks.sort((t1, t2) => {
- if (t1.completed === t2.completed) {
- if (t1.highPriority === t2.highPriority) {
- return 0;
- }
- if (t1.highPriority) {
- return -1;
- }
- return 1;
+ const response: HomeItemsResponse = await api.getJSON(itemsUrl);
+ if (user) {
+ cacheHomeItems(user.userId, itemsUrl, response);
+ }
+
+ updateHomeItems(response);
+ }
+ catch (e) {
+ handleError(e);
+ }
+ };
+
+ const updateHomeItems = (response: HomeItemsResponse): void => {
+ const translatedTasks = translateTaskResponse(response.tasks ?? [], i18n.language);
+ translatedTasks.sort((t1, t2) => {
+ if (t1.completed === t2.completed) {
+ if (t1.highPriority === t2.highPriority) {
+ return 0;
}
- if (t1.completed) {
+ if (t1.highPriority) {
return -1;
}
return 1;
- });
+ }
+ if (t1.completed) {
+ return -1;
+ }
+ return 1;
+ });
- const fetchedNotes = [...(response.notes ?? [])];
- fetchedNotes.sort((n1, n2) => (n1.id > n2.id) ? -1 : 1);
+ const fetchedNotes = [...(response.notes ?? [])];
+ fetchedNotes.sort((n1, n2) => (n1.id > n2.id) ? -1 : 1);
- setTasks(translatedTasks.filter((task: TaskResponse) => !task.completed));
- setCompletedTasks(translatedTasks.filter((task: TaskResponse) => task.completed));
+ setTasks(translatedTasks.filter((task: TaskResponse) => !task.completed));
+ setCompletedTasks(translatedTasks.filter((task: TaskResponse) => task.completed));
- const { active, archived } = partitionNotes(fetchedNotes);
- setNotes(active);
- setArchivedNotes(archived);
- }
- catch (e) {
- handleError(e);
- }
+ const { active, archived } = partitionNotes(fetchedNotes);
+ setNotes(active);
+ setArchivedNotes(archived);
};
const cleanText = (text: string): string => {
@@ -426,15 +466,15 @@ function Home(): React.ReactNode {
useEffect(() => {
handleDefaultLang(user?.lang);
setName(user?.name ?? 'User');
- loadTags();
- }, [user]);
+ void loadTags(refreshAfterSave);
+ }, [user, refreshAfterSave]);
useEffect(() => {
const timer = setTimeout(() => {
- void loadItems(filterText, selectedOption);
+ void loadItems(filterText, selectedOption, refreshAfterSave);
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
- }, [filterText, selectedOption, user]);
+ }, [filterText, selectedOption, user, refreshAfterSave]);
useEffect(() => {
const openNoteId = localStorage.getItem(OPEN_NOTE_ID_KEY);
diff --git a/client/src/views/NoteAdd/index.tsx b/client/src/views/NoteAdd/index.tsx
index f1bcaf25..3a7c4ad6 100644
--- a/client/src/views/NoteAdd/index.tsx
+++ b/client/src/views/NoteAdd/index.tsx
@@ -384,7 +384,7 @@ function NoteAdd(): React.ReactNode {
if (saved) {
clearDraft();
resetInputs();
- navigate('/home');
+ navigate('/home', { state: { refreshHome: true } });
}
return saved;
diff --git a/client/src/views/TaskAdd/index.tsx b/client/src/views/TaskAdd/index.tsx
index 3f0d6a86..603eee05 100644
--- a/client/src/views/TaskAdd/index.tsx
+++ b/client/src/views/TaskAdd/index.tsx
@@ -273,7 +273,7 @@ function TaskAdd(): React.ReactNode {
clearDraft();
form.reset();
resetInputs();
- navigate('/home');
+ navigate('/home', { state: { refreshHome: true } });
}
}
else if (action === 'edit') {
@@ -294,7 +294,7 @@ function TaskAdd(): React.ReactNode {
clearDraft();
form.reset();
resetInputs();
- navigate('/home');
+ navigate('/home', { state: { refreshHome: true } });
}
}
};