Skip to content

Commit a487c19

Browse files
authored
feat: reuse zustand range caching on notes page (#466)
1 parent 890016a commit a487c19

6 files changed

Lines changed: 438 additions & 47 deletions

File tree

.github/copilot-instructions.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
When generating a commit message, follow the Conventional Commits format. The commit message should be structured as follows:
2+
3+
```text
4+
<type>[optional scope]: <description>
5+
6+
[optional body]
7+
```
8+
9+
The type should be one of the following:
10+
11+
```json
12+
{
13+
"types": {
14+
"feat": {
15+
"description": "A new feature",
16+
"title": "Features"
17+
},
18+
"fix": {
19+
"description": "A bug fix",
20+
"title": "Bug Fixes"
21+
},
22+
"docs": {
23+
"description": "Documentation only changes",
24+
"title": "Documentation"
25+
},
26+
"style": {
27+
"description": "Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)",
28+
"title": "Styles"
29+
},
30+
"refactor": {
31+
"description": "A code change that neither fixes a bug nor adds a feature",
32+
"title": "Code Refactoring"
33+
},
34+
"perf": {
35+
"description": "A code change that improves performance",
36+
"title": "Performance Improvements"
37+
},
38+
"test": {
39+
"description": "Adding missing tests or correcting existing tests",
40+
"title": "Tests"
41+
},
42+
"build": {
43+
"description": "Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)",
44+
"title": "Builds"
45+
},
46+
"ci": {
47+
"description": "Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)",
48+
"title": "Continuous Integrations"
49+
},
50+
"chore": {
51+
"description": "Other changes that don't modify src or test files",
52+
"title": "Chores"
53+
},
54+
"revert": {
55+
"description": "Reverts a previous commit",
56+
"title": "Reverts"
57+
}
58+
}
59+
}
60+
```
61+
62+
The scope should be the name of the feature affected by the change (e.g. `calendar`, `auth`, `habit`, `note`, `layout`, etc.). The scope can also be omitted if the change affects multiple features.
63+
64+
The body should consist of a short description of the change (up to three sentences) and a list of any breaking changes.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
2+
import { it, vi, expect, describe, beforeEach } from 'vitest';
3+
4+
import type { NoteWithHabit } from '@models';
5+
import { useBoundStore } from '@stores';
6+
7+
import NotesList from './NotesList';
8+
9+
const listAllNotes = vi.hoisted(() => {
10+
return vi.fn();
11+
});
12+
const rollbarError = vi.hoisted(() => {
13+
return vi.fn();
14+
});
15+
16+
vi.mock('@services', () => {
17+
return { listAllNotes };
18+
});
19+
vi.mock('@rollbar/react', () => {
20+
return {
21+
useRollbar: () => {
22+
return { error: rollbarError };
23+
},
24+
};
25+
});
26+
27+
vi.stubGlobal(
28+
'ResizeObserver',
29+
class {
30+
disconnect = vi.fn();
31+
observe = vi.fn();
32+
unobserve = vi.fn();
33+
}
34+
);
35+
36+
const makeNote = (id: string, createdAt: string): NoteWithHabit => {
37+
return {
38+
content: `Content ${id}`,
39+
createdAt,
40+
id,
41+
periodDate: '2026-07-10',
42+
periodKind: 'day',
43+
updatedAt: null,
44+
userId: 'user-id',
45+
};
46+
};
47+
48+
describe('NotesList', () => {
49+
beforeEach(() => {
50+
listAllNotes.mockReset();
51+
rollbarError.mockReset();
52+
useBoundStore.getState().noteActions.clearNotes();
53+
});
54+
55+
it('renders calendar notes from the store before the first page resolves', async () => {
56+
let resolvePage: ((notes: NoteWithHabit[]) => void) | undefined;
57+
listAllNotes.mockReturnValue(
58+
new Promise((resolve) => {
59+
resolvePage = resolve;
60+
})
61+
);
62+
const cachedNote = makeNote('cached', '2026-07-10T10:00:00.000Z');
63+
64+
useBoundStore.setState({ notes: { [cachedNote.id]: cachedNote } });
65+
render(<NotesList />);
66+
67+
expect(screen.getByText('Content cached')).toBeInTheDocument();
68+
expect(listAllNotes).toHaveBeenCalledWith({ limit: 20, page: 0 });
69+
70+
resolvePage?.([]);
71+
72+
await waitFor(() => {
73+
expect(useBoundStore.getState().notesListIsLoading).toBe(false);
74+
});
75+
});
76+
77+
it('populates the Zustand store when the page is opened directly', async () => {
78+
const fetchedNote = makeNote('fetched', '2026-07-10T10:00:00.000Z');
79+
listAllNotes.mockResolvedValue([fetchedNote]);
80+
81+
render(<NotesList />);
82+
83+
expect(await screen.findByText('Content fetched')).toBeInTheDocument();
84+
expect(useBoundStore.getState().notes[fetchedNote.id]).toEqual(fetchedNote);
85+
});
86+
87+
it('fetches and caches subsequent pages while scrolling', async () => {
88+
const firstPage = Array.from({ length: 20 }, (_, index) => {
89+
return makeNote(
90+
`page-0-${index}`,
91+
`2026-07-10T10:${String(index).padStart(2, '0')}:00.000Z`
92+
);
93+
});
94+
const nextPageNote = makeNote('page-1-0', '2026-07-09T10:00:00.000Z');
95+
listAllNotes
96+
.mockResolvedValueOnce(firstPage)
97+
.mockResolvedValueOnce([nextPageNote]);
98+
99+
const { container } = render(<NotesList />);
100+
101+
expect(await screen.findByText('Content page-0-0')).toBeInTheDocument();
102+
103+
const scrollContainer = container.querySelector('.overflow-y-auto');
104+
105+
expect(scrollContainer).not.toBeNull();
106+
Object.defineProperties(scrollContainer!, {
107+
clientHeight: { configurable: true, value: 100 },
108+
scrollHeight: { configurable: true, value: 100 },
109+
scrollTop: { configurable: true, value: 0 },
110+
});
111+
fireEvent.scroll(scrollContainer!);
112+
113+
expect(await screen.findByText('Content page-1-0')).toBeInTheDocument();
114+
expect(listAllNotes).toHaveBeenLastCalledWith({ limit: 20, page: 1 });
115+
expect(useBoundStore.getState().notes[nextPageNote.id]).toEqual(
116+
nextPageNote
117+
);
118+
});
119+
});

src/components/note/NotesList.tsx

Lines changed: 17 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,24 @@
11
import { ScrollShadow } from '@heroui/react';
2+
import { useRollbar } from '@rollbar/react';
23
import React from 'react';
34

45
import { NoteListItem, InfinityLoader } from '@components';
5-
import type { NoteWithHabit } from '@models';
6-
import { listAllNotes } from '@services';
7-
8-
const PAGE_SIZE = 20;
6+
import { useNotesList, useNoteActions, useNotesListState } from '@stores';
7+
import { getErrorMessage } from '@utils';
98

109
const NotesList = () => {
11-
const [notes, setNotes] = React.useState<NoteWithHabit[]>([]);
12-
const [page, setPage] = React.useState(0);
13-
const [hasMore, setHasMore] = React.useState(true);
14-
const [isLoading, setIsLoading] = React.useState(false);
10+
const notes = useNotesList();
11+
const { fetchNextNotesPage, initializeNotesList } = useNoteActions();
12+
const { hasMore, isLoading } = useNotesListState();
1513
const containerRef = React.useRef<HTMLDivElement>(null);
16-
17-
const fetchNotes = React.useCallback(
18-
async (pageToFetch: number) => {
19-
if (isLoading) {
20-
return;
21-
}
22-
23-
setIsLoading(true);
24-
25-
try {
26-
const fetchedNotes = await listAllNotes({
27-
limit: PAGE_SIZE,
28-
page: pageToFetch,
29-
});
30-
31-
if (fetchedNotes.length < PAGE_SIZE) {
32-
setHasMore(false);
33-
}
34-
35-
setNotes((prev) => {
36-
return pageToFetch === 0 ? fetchedNotes : [...prev, ...fetchedNotes];
37-
});
38-
} catch (error) {
39-
console.error('Failed to fetch notes:', error);
40-
} finally {
41-
setIsLoading(false);
42-
}
43-
},
44-
[isLoading]
45-
);
14+
const rollbar = useRollbar();
4615

4716
React.useEffect(() => {
48-
void fetchNotes(0);
49-
// eslint-disable-next-line react-hooks/exhaustive-deps
50-
}, []);
17+
void initializeNotesList().catch((error: unknown) => {
18+
rollbar.error('Failed to initialize notes list', getErrorMessage(error));
19+
console.error('Failed to fetch notes:', error);
20+
});
21+
}, [initializeNotesList, rollbar]);
5122

5223
const handleScroll = React.useCallback(
5324
(event: React.UIEvent<HTMLDivElement>) => {
@@ -56,12 +27,13 @@ const NotesList = () => {
5627
target.scrollHeight - target.scrollTop <= target.clientHeight + 50;
5728

5829
if (scrolledToBottom && hasMore && !isLoading) {
59-
const nextPage = page + 1;
60-
setPage(nextPage);
61-
void fetchNotes(nextPage);
30+
void fetchNextNotesPage().catch((error: unknown) => {
31+
rollbar.error('Failed to fetch notes', getErrorMessage(error));
32+
console.error('Failed to fetch notes:', error);
33+
});
6234
}
6335
},
64-
[hasMore, isLoading, page, fetchNotes]
36+
[fetchNextNotesPage, hasMore, isLoading, rollbar]
6537
);
6638

6739
const renderEndMessage = () => {

0 commit comments

Comments
 (0)