Skip to content

Commit 8a4f5bc

Browse files
committed
perf: virtualize the notification list
One repository can hold hundreds of notifications, so accounts, repository groups and rows are flattened into a single item sequence and windowed across group boundaries. Headers no longer own their rows, so collapse and exit-animation state moves up to the list and survives rows unmounting on scroll.
1 parent aa79c60 commit 8a4f5bc

18 files changed

Lines changed: 1035 additions & 2893 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@
106106
"@primer/react": "38.39.0",
107107
"@tailwindcss/vite": "4.3.3",
108108
"@tanstack/react-query": "5.102.8",
109+
"@tanstack/react-virtual": "3.14.13",
109110
"@testing-library/jest-dom": "7.0.1",
110111
"@testing-library/react": "16.3.3",
111112
"@testing-library/user-event": "14.6.7",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/renderer/components/layout/Contents.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { FC, ReactNode } from 'react';
1+
import type { FC, ReactNode, Ref } from 'react';
22

33
import { cn } from 'cn';
44

@@ -7,6 +7,7 @@ interface IContents {
77
paddingHorizontal?: boolean;
88
paddingBottom?: boolean;
99
scrollFade?: boolean;
10+
ref?: Ref<HTMLDivElement>;
1011
}
1112

1213
/**
@@ -18,6 +19,7 @@ export const Contents: FC<IContents> = ({
1819
paddingHorizontal = true,
1920
paddingBottom = false,
2021
scrollFade = false,
22+
ref,
2123
}) => {
2224
return (
2325
<div
@@ -27,6 +29,7 @@ export const Contents: FC<IContents> = ({
2729
paddingBottom && 'pb-2',
2830
scrollFade && 'gitify-scroll-fade',
2931
)}
32+
ref={ref}
3033
>
3134
{children}
3235
</div>
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
4+
import { renderWithProviders } from '../../__helpers__/test-utils';
5+
import { mockGitHubCloudAccount } from '../../__mocks__/account-mocks';
6+
7+
import * as links from '../../utils/system/links';
8+
import { AccountHeader, type AccountHeaderProps } from './AccountHeader';
9+
10+
describe('renderer/components/notifications/AccountHeader.tsx', () => {
11+
const props: AccountHeaderProps = {
12+
account: mockGitHubCloudAccount,
13+
error: null,
14+
notificationCount: 3,
15+
isCollapsed: false,
16+
onToggle: vi.fn(),
17+
};
18+
19+
it('renders the managed GitHub account identity', () => {
20+
renderWithProviders(
21+
<AccountHeader
22+
{...props}
23+
account={{
24+
...mockGitHubCloudAccount,
25+
user: {
26+
...mockGitHubCloudAccount.user!,
27+
login: 'octocat_gitify',
28+
name: 'Mona Lisa Octocat',
29+
},
30+
}}
31+
/>,
32+
);
33+
34+
expect(screen.getByText('octocat_gitify')).toBeInTheDocument();
35+
expect(screen.getByAltText('octocat_gitify')).toBeInTheDocument();
36+
});
37+
38+
it('should open profile when clicked', async () => {
39+
const openAccountProfileSpy = vi.spyOn(links, 'openAccountProfile').mockImplementation(vi.fn());
40+
const onToggle = vi.fn();
41+
42+
renderWithProviders(<AccountHeader {...props} onToggle={onToggle} />);
43+
44+
await userEvent.click(screen.getByTestId('account-profile'));
45+
46+
expect(openAccountProfileSpy).toHaveBeenCalledWith(mockGitHubCloudAccount);
47+
// The header's own click toggles collapse; the profile button must not.
48+
expect(onToggle).not.toHaveBeenCalled();
49+
});
50+
51+
it('should open my issues when clicked', async () => {
52+
const openHostIssuesSpy = vi.spyOn(links, 'openHostIssues').mockImplementation(vi.fn());
53+
54+
renderWithProviders(<AccountHeader {...props} />);
55+
56+
await userEvent.click(screen.getByTestId('account-issues'));
57+
58+
expect(openHostIssuesSpy).toHaveBeenCalledWith(mockGitHubCloudAccount);
59+
});
60+
61+
it('should open my pull requests when clicked', async () => {
62+
const openHostPullsSpy = vi.spyOn(links, 'openHostPulls').mockImplementation(vi.fn());
63+
64+
renderWithProviders(<AccountHeader {...props} />);
65+
66+
await userEvent.click(screen.getByTestId('account-pull-requests'));
67+
68+
expect(openHostPullsSpy).toHaveBeenCalledWith(mockGitHubCloudAccount);
69+
});
70+
71+
it('should request a collapse toggle when toggled', async () => {
72+
const onToggle = vi.fn();
73+
74+
renderWithProviders(<AccountHeader {...props} onToggle={onToggle} />);
75+
76+
await userEvent.click(screen.getByTestId('account-toggle'));
77+
78+
expect(onToggle).toHaveBeenCalledTimes(1);
79+
});
80+
81+
it('should label the toggle by collapsed state', () => {
82+
const { unmount } = renderWithProviders(<AccountHeader {...props} />)!;
83+
84+
expect(screen.getByTestId('account-toggle')).toHaveAttribute(
85+
'title',
86+
'Hide account notifications',
87+
);
88+
89+
unmount();
90+
renderWithProviders(<AccountHeader {...props} isCollapsed />);
91+
92+
expect(screen.getByTestId('account-toggle')).toHaveAttribute(
93+
'title',
94+
'Show account notifications',
95+
);
96+
});
97+
98+
it('should render an error background when the account errored', () => {
99+
const tree = renderWithProviders(
100+
<AccountHeader
101+
{...props}
102+
error={{ title: 'Error title', descriptions: ['Error description'], emojis: ['🔥'] }}
103+
notificationCount={0}
104+
/>,
105+
);
106+
107+
expect(tree!.container).toMatchSnapshot();
108+
});
109+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { type FC, type MouseEvent } from 'react';
2+
3+
import { GitPullRequestIcon, IssueOpenedIcon } from '@primer/octicons-react';
4+
import { Button, Stack } from '@primer/react';
5+
6+
import { cn } from 'cn';
7+
8+
import { HoverButton } from '../primitives/HoverButton';
9+
import { HoverGroup } from '../primitives/HoverGroup';
10+
11+
import { type Account, type GitifyError, Size } from '../../types';
12+
13+
import { getAdapter } from '../../utils/forges/registry';
14+
import { openAccountProfile, openHostIssues, openHostPulls } from '../../utils/system/links';
15+
import { getChevronDetails } from '../../utils/ui/display';
16+
import { AvatarWithFallback } from '../avatars/AvatarWithFallback';
17+
18+
export interface AccountHeaderProps {
19+
account: Account;
20+
error: GitifyError | null;
21+
notificationCount: number;
22+
isCollapsed: boolean;
23+
onToggle: () => void;
24+
}
25+
26+
export const AccountHeader: FC<AccountHeaderProps> = ({
27+
account,
28+
error,
29+
notificationCount,
30+
isCollapsed,
31+
onToggle,
32+
}) => {
33+
const Chevron = getChevronDetails(notificationCount > 0, !isCollapsed, 'account');
34+
35+
return (
36+
<Stack
37+
className={cn(
38+
'group relative pr-1 py-0.5',
39+
error ? 'bg-gitify-account-error' : 'bg-gitify-account-rest',
40+
)}
41+
direction="horizontal"
42+
onClick={onToggle}
43+
>
44+
<Button
45+
alignContent="center"
46+
count={notificationCount}
47+
data-testid="account-profile"
48+
onClick={(event: MouseEvent<HTMLElement>) => {
49+
// Don't trigger onClick of parent element.
50+
event.stopPropagation();
51+
openAccountProfile(account);
52+
}}
53+
title="Open account profile"
54+
variant="invisible"
55+
>
56+
<AvatarWithFallback
57+
alt={getAdapter(account).formatUserLogin(account.user!.login)}
58+
name={getAdapter(account).formatUserLogin(account.user!.login)}
59+
size={Size.MEDIUM}
60+
src={account.user!.avatar ?? undefined}
61+
/>
62+
</Button>
63+
64+
<HoverGroup
65+
bgColor={
66+
error ? 'group-hover:bg-gitify-account-error' : 'group-hover:bg-gitify-account-rest'
67+
}
68+
>
69+
<HoverButton
70+
action={() => openHostIssues(account)}
71+
icon={IssueOpenedIcon}
72+
label="My issues ↗"
73+
testid="account-issues"
74+
/>
75+
76+
<HoverButton
77+
action={() => openHostPulls(account)}
78+
icon={GitPullRequestIcon}
79+
label="My pull requests ↗"
80+
testid="account-pull-requests"
81+
/>
82+
83+
<HoverButton
84+
action={onToggle}
85+
icon={Chevron.icon}
86+
label={Chevron.label}
87+
testid="account-toggle"
88+
/>
89+
</HoverGroup>
90+
</Stack>
91+
);
92+
};

0 commit comments

Comments
 (0)