Skip to content

Commit 6afac4a

Browse files
evangraykmeta-codesync[bot]
authored andcommitted
Use local state for commit context menu state to reduce rerenders
Summary: This is an alternate version of D116326998 that is a bit simpler / more targeted. This should give the same perf win while also simplifying the code a bit. The underlying issue was that actioningCommit was global state that every commit subscribed to, so context menu triggered every commit to rerender. WIth giant dags this can be noticeable. By making this state local to the component (useState instead of jotai atom), we only rerender the current atom. Well, that would be true but we also need to update the dismissal path, which luckily we can do with useContextMenu. Reviewed By: stbfb Differential Revision: D116696808 fbshipit-source-id: d8a6a8cd39dba03c2f8df7b40af7e90bd7a9cb4f
1 parent 6528a6a commit 6afac4a

5 files changed

Lines changed: 92 additions & 43 deletions

File tree

addons/isl/src/Commit.tsx

Lines changed: 23 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ import {Icon} from 'isl-components/Icon';
1717
import {Subtle} from 'isl-components/Subtle';
1818
import {Tooltip} from 'isl-components/Tooltip';
1919
import {atom, useAtomValue, useSetAtom} from 'jotai';
20-
import React, {memo, useEffect} from 'react';
20+
import React, {memo, useState} from 'react';
2121
import {ComparisonType} from 'shared/Comparison';
22-
import {contextMenuState, useContextMenu} from 'shared/ContextMenu';
22+
import {useContextMenu} from 'shared/ContextMenu';
2323
import {MS_PER_DAY} from 'shared/constants';
2424
import {useAutofocusRef} from 'shared/hooks';
2525
import {notEmpty, nullthrows} from 'shared/utils';
@@ -83,7 +83,6 @@ import {RelativeDate, relativeDate} from './relativeDate';
8383
import {repoRelativeCwd, useIsIrrelevantToCwd} from './repositoryData';
8484
import {commitTreeWidth} from './responsive';
8585
import {
86-
actioningCommit,
8786
selectedCommitInfos,
8887
selectedCommitInfosInDagOrder,
8988
selectedCommits,
@@ -182,14 +181,7 @@ export const Commit = memo(
182181
const {isSelected, onDoubleClickToShowDrawer} = useCommitCallbacks(commit);
183182
const actionsPrevented = previewPreventsActions(previewType);
184183

185-
const isActioning = useAtomValue(actioningCommit) === commit.hash;
186-
const isContextMenuOpen = useAtomValue(contextMenuState) != null;
187-
188-
useEffect(() => {
189-
if (!isContextMenuOpen && isActioning) {
190-
writeAtom(actioningCommit, null);
191-
}
192-
}, [isContextMenuOpen, isActioning]);
184+
const [isActioning, setIsActioning] = useState(false);
193185

194186
const inConflicts = useAtomValue(inMergeConflicts);
195187

@@ -591,22 +583,25 @@ export const Commit = memo(
591583
return items;
592584
};
593585

594-
const contextMenu = useContextMenu((): Array<ContextMenuItem> => {
595-
return makeContextMenuOptions().map((item: ContextMenuItem & {loggingLabel?: string}) => {
596-
if (item.type == null && notEmpty(item.loggingLabel)) {
597-
return {
598-
...item,
599-
onClick: () => {
600-
tracker.track('CommitContextMenuItemClick', {
601-
extras: {choice: item.loggingLabel},
602-
});
603-
item.onClick?.();
604-
},
605-
};
606-
}
607-
return item;
608-
});
609-
});
586+
const contextMenu = useContextMenu(
587+
(): Array<ContextMenuItem> => {
588+
return makeContextMenuOptions().map((item: ContextMenuItem & {loggingLabel?: string}) => {
589+
if (item.type == null && notEmpty(item.loggingLabel)) {
590+
return {
591+
...item,
592+
onClick: () => {
593+
tracker.track('CommitContextMenuItemClick', {
594+
extras: {choice: item.loggingLabel},
595+
});
596+
item.onClick?.();
597+
},
598+
};
599+
}
600+
return item;
601+
});
602+
},
603+
{onOpen: () => setIsActioning(true), onDismiss: () => setIsActioning(false)},
604+
);
610605

611606
const inlineCommitActions = [];
612607
const floatingCommitActions = [];
@@ -720,10 +715,7 @@ export const Commit = memo(
720715
(commit.successorInfo != null ? ' obsolete' : '') +
721716
(isIrrelevantToCwd ? ' irrelevant' : '')
722717
}
723-
onContextMenu={e => {
724-
writeAtom(actioningCommit, commit.hash);
725-
contextMenu(e);
726-
}}
718+
onContextMenu={contextMenu}
727719
data-testid={`commit-${commit.hash}`}>
728720
<div
729721
className={'commit-rows' + (isActioning ? ' commit-row-actioning' : '')}

addons/isl/src/__tests__/CommitTreeList.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,34 @@ describe('CommitTreeList', () => {
9191
});
9292
});
9393

94+
it('highlights only the commit with an open context menu', () => {
95+
render(<App />);
96+
act(() => {
97+
closeCommitInfoSidebar();
98+
simulateCommits({
99+
value: [
100+
COMMIT('a', 'Commit A', '1'),
101+
COMMIT('b', 'Commit B', 'a', {isDot: true}),
102+
COMMIT('1', 'some public base', '0', {phase: 'public'}),
103+
],
104+
});
105+
});
106+
107+
const commitA = screen.getByTestId('commit-a');
108+
const commitB = screen.getByTestId('commit-b');
109+
110+
fireEvent.contextMenu(commitA);
111+
expect(commitA.querySelector('.commit-rows')).toHaveClass('commit-row-actioning');
112+
expect(commitB.querySelector('.commit-rows')).not.toHaveClass('commit-row-actioning');
113+
114+
fireEvent.contextMenu(commitB);
115+
expect(commitA.querySelector('.commit-rows')).not.toHaveClass('commit-row-actioning');
116+
expect(commitB.querySelector('.commit-rows')).toHaveClass('commit-row-actioning');
117+
118+
fireEvent.keyUp(window, {key: 'Escape'});
119+
expect(commitB.querySelector('.commit-rows')).not.toHaveClass('commit-row-actioning');
120+
});
121+
94122
it('clears the top bar height when it unmounts', () => {
95123
const {unmount} = render(<App />);
96124
act(() => {

addons/isl/src/selection.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,6 @@ export const individualToggleKey: 'metaKey' | 'ctrlKey' = isMac ? 'metaKey' : 'c
4646
*/
4747
export const selectedCommits = atom(new Set<Hash>());
4848

49-
/**
50-
* Commit that is currently being actioned on (i.e., from the context menu).
51-
* This is a temporary visual state separate from selection.
52-
*/
53-
export const actioningCommit = atom<Hash | null>(null);
5449
registerCleanup(
5550
selectedCommits,
5651
successionTracker.onSuccessions(successions => {

addons/shared/ContextMenu.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import './ContextMenu.css';
2828
*/
2929
export function useContextMenu<T>(
3030
creator: () => Array<ContextMenuItem>,
31+
lifecycle?: {onOpen?: () => void; onDismiss?: () => void},
3132
): React.MouseEventHandler<T> {
3233
const setState = useSetAtom(contextMenuState);
3334
return e => {
@@ -36,14 +37,25 @@ export function useContextMenu<T>(
3637
if (items.length === 0) {
3738
return;
3839
}
39-
setState({x: e.clientX / zoom, y: e.clientY / zoom, items});
40+
setState({
41+
x: e.clientX / zoom,
42+
y: e.clientY / zoom,
43+
items,
44+
onDismiss: lifecycle?.onDismiss,
45+
});
46+
lifecycle?.onOpen?.();
4047

4148
e.preventDefault();
4249
e.stopPropagation();
4350
};
4451
}
4552

46-
type ContextMenuData = {x: number; y: number; items: Array<ContextMenuItem>};
53+
type ContextMenuData = {
54+
x: number;
55+
y: number;
56+
items: Array<ContextMenuItem>;
57+
onDismiss?: () => void;
58+
};
4759
export type ContextMenuItem =
4860
| {
4961
type?: undefined;
@@ -58,7 +70,14 @@ export type ContextMenuItem =
5870
}
5971
| {type: 'divider'};
6072

61-
export const contextMenuState = atom<null | ContextMenuData>(null);
73+
const contextMenuData = atom<null | ContextMenuData>(null);
74+
export const contextMenuState = atom(
75+
get => get(contextMenuData),
76+
(get, set, value: ContextMenuData | null) => {
77+
get(contextMenuData)?.onDismiss?.();
78+
set(contextMenuData, value);
79+
},
80+
);
6281

6382
/**
6483
* Compute the absolute placement for the context menu overlay from the click

addons/shared/__tests__/ContextMenu.test.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ import {computeContextMenuStyle, ContextMenus, useContextMenu} from '../ContextM
1414

1515
const onClick1 = jest.fn();
1616
const onClick2 = jest.fn();
17+
const onOpen = jest.fn();
18+
const onDismiss = jest.fn();
1719

1820
function TestComponent() {
19-
const menu = useContextMenu(() => [
20-
{label: 'Context item 1', onClick: onClick1},
21-
{label: 'Context item 2', onClick: onClick2},
22-
]);
21+
const menu = useContextMenu(
22+
() => [
23+
{label: 'Context item 1', onClick: onClick1},
24+
{label: 'Context item 2', onClick: onClick2},
25+
],
26+
{onOpen, onDismiss},
27+
);
2328
return (
2429
<div data-testid="test-component" onContextMenu={menu}>
2530
Hello click me
@@ -43,6 +48,11 @@ function rightClick(el: HTMLElement) {
4348
}
4449

4550
describe('Context Menu', () => {
51+
beforeEach(() => {
52+
onOpen.mockClear();
53+
onDismiss.mockClear();
54+
});
55+
4656
it('shows context menu items on right click', () => {
4757
render(<TestApp />);
4858

@@ -53,6 +63,8 @@ describe('Context Menu', () => {
5363

5464
expect(screen.getByText('Context item 1')).toBeInTheDocument();
5565
expect(screen.getByText('Context item 2')).toBeInTheDocument();
66+
expect(onOpen).toHaveBeenCalledTimes(1);
67+
expect(onDismiss).not.toHaveBeenCalled();
5668
});
5769

5870
it('runs callbacks on clicking an item', () => {
@@ -68,6 +80,7 @@ describe('Context Menu', () => {
6880
});
6981
expect(onClick1).toHaveBeenCalled();
7082
expect(onClick2).not.toHaveBeenCalled();
83+
expect(onDismiss).toHaveBeenCalledTimes(1);
7184
});
7285

7386
it('dismisses on escape key', () => {
@@ -84,6 +97,7 @@ describe('Context Menu', () => {
8497

8598
expect(screen.queryByText('Context item 1')).not.toBeInTheDocument();
8699
expect(screen.queryByText('Context item 2')).not.toBeInTheDocument();
100+
expect(onDismiss).toHaveBeenCalledTimes(1);
87101
});
88102

89103
it('dismisses on click outside', () => {
@@ -100,6 +114,7 @@ describe('Context Menu', () => {
100114

101115
expect(screen.queryByText('Context item 1')).not.toBeInTheDocument();
102116
expect(screen.queryByText('Context item 2')).not.toBeInTheDocument();
117+
expect(onDismiss).toHaveBeenCalledTimes(1);
103118
});
104119
});
105120

0 commit comments

Comments
 (0)