@samitouri / QOS-React-1 / commits / d763f3131e

[Devtools] Navigating commits performance panel hotkey (#35238)

## Summary Add keyboard shortcuts (Cmd/Ctrl + Left/Right arrow keys) to navigate between commits in the Profiler's snapshot view. Moved `filteredCommitIndices` management and commit navigation logic (`selectNextCommitIndex`, `selectPrevCommitIndex`) from `SnapshotSelector` into `useCommitFilteringAndNavigation` used by `ProfilerContext` to enable keyboard shortcuts from the top-level Profiler component. ## How did you test this change? - New tests in ProfilerContext-tests - Built browser extension: `yarn build:<browser name>` - tested in browser: `yarn run test:<browser name>` - Manually verified Left/Right arrow navigation cycles through commits - Verified navigation respects commit duration filter - Verified reload-and-profile button unaffected Chrome: https://github.com/user-attachments/assets/01d2a749-13dc-4d08-8bcb-3d4d45a5f97c Edge with duration filter: https://github.com/user-attachments/assets/a7f76ff7-2a0b-4b9c-a0ce-d4449373308b firefox mixing hotkey with clicking arrow buttons: https://github.com/user-attachments/assets/48912d68-7c75-40f2-a203-5e6d7e6b2d99

emily8rown committed Dec 10, 2025 at 13:07 UTC d763f3131e689d077a93fd45c1fdf220be796279
5 files changed +595 -93
packages/react-devtools-shared/src/__tests__/profilerContext-test.js
+284
@@ -655,4 +655,288 @@ describe('ProfilerContext', () => {
655
656 document.body.removeChild(profilerContainer);
657 });
658 +
659 + it('should navigate between commits when the keyboard shortcut is pressed', async () => {
660 + const Parent = () => <Child />;
661 + const Child = () => null;
662 +
663 + const container = document.createElement('div');
664 + const root = ReactDOMClient.createRoot(container);
665 + utils.act(() => root.render(<Parent />));
666 +
667 + // Profile and record multiple commits
668 + await utils.actAsync(() => store.profilerStore.startProfiling());
669 + await utils.actAsync(() => root.render(<Parent />)); // Commit 1
670 + await utils.actAsync(() => root.render(<Parent />)); // Commit 2
671 + await utils.actAsync(() => root.render(<Parent />)); // Commit 3
672 + await utils.actAsync(() => store.profilerStore.stopProfiling());
673 +
674 + const Profiler =
675 + require('react-devtools-shared/src/devtools/views/Profiler/Profiler').default;
676 + const {
677 + TimelineContextController,
678 + } = require('react-devtools-timeline/src/TimelineContext');
679 + const {
680 + SettingsContextController,
681 + } = require('react-devtools-shared/src/devtools/views/Settings/SettingsContext');
682 + const {
683 + ModalDialogContextController,
684 + } = require('react-devtools-shared/src/devtools/views/ModalDialog');
685 +
686 + let context: Context = ((null: any): Context);
687 + function ContextReader() {
688 + context = React.useContext(ProfilerContext);
689 + return null;
690 + }
691 +
692 + const profilerContainer = document.createElement('div');
693 + document.body.appendChild(profilerContainer);
694 +
695 + const profilerRoot = ReactDOMClient.createRoot(profilerContainer);
696 +
697 + await utils.actAsync(() => {
698 + profilerRoot.render(
699 + <Contexts>
700 + <SettingsContextController browserTheme="light">
701 + <ModalDialogContextController>
702 + <TimelineContextController>
703 + <Profiler />
704 + <ContextReader />
705 + </TimelineContextController>
706 + </ModalDialogContextController>
707 + </SettingsContextController>
708 + </Contexts>,
709 + );
710 + });
711 +
712 + // Verify we have profiling data with 3 commits
713 + expect(context.didRecordCommits).toBe(true);
714 + expect(context.profilingData).not.toBeNull();
715 + const rootID = context.rootID;
716 + expect(rootID).not.toBeNull();
717 + const dataForRoot = context.profilingData.dataForRoots.get(rootID);
718 + expect(dataForRoot.commitData.length).toBe(3);
719 + // Should start at the first commit
720 + expect(context.selectedCommitIndex).toBe(0);
721 +
722 + const ownerWindow = profilerContainer.ownerDocument.defaultView;
723 + const isMac =
724 + typeof navigator !== 'undefined' &&
725 + navigator.platform.toUpperCase().indexOf('MAC') >= 0;
726 +
727 + // Test ArrowRight navigation (forward) with correct modifier
728 + const arrowRightEvent = new KeyboardEvent('keydown', {
729 + key: 'ArrowRight',
730 + metaKey: isMac,
731 + ctrlKey: !isMac,
732 + bubbles: true,
733 + });
734 +
735 + await utils.actAsync(() => {
736 + ownerWindow.dispatchEvent(arrowRightEvent);
737 + }, false);
738 + expect(context.selectedCommitIndex).toBe(1);
739 +
740 + await utils.actAsync(() => {
741 + ownerWindow.dispatchEvent(arrowRightEvent);
742 + }, false);
743 + expect(context.selectedCommitIndex).toBe(2);
744 +
745 + // Test wrap-around (last -> first)
746 + await utils.actAsync(() => {
747 + ownerWindow.dispatchEvent(arrowRightEvent);
748 + }, false);
749 + expect(context.selectedCommitIndex).toBe(0);
750 +
751 + // Test ArrowLeft navigation (backward) with correct modifier
752 + const arrowLeftEvent = new KeyboardEvent('keydown', {
753 + key: 'ArrowLeft',
754 + metaKey: isMac,
755 + ctrlKey: !isMac,
756 + bubbles: true,
757 + });
758 +
759 + await utils.actAsync(() => {
760 + ownerWindow.dispatchEvent(arrowLeftEvent);
761 + }, false);
762 + expect(context.selectedCommitIndex).toBe(2);
763 +
764 + await utils.actAsync(() => {
765 + ownerWindow.dispatchEvent(arrowLeftEvent);
766 + }, false);
767 + expect(context.selectedCommitIndex).toBe(1);
768 +
769 + await utils.actAsync(() => {
770 + ownerWindow.dispatchEvent(arrowLeftEvent);
771 + }, false);
772 + expect(context.selectedCommitIndex).toBe(0);
773 +
774 + // Cleanup
775 + await utils.actAsync(() => profilerRoot.unmount());
776 + document.body.removeChild(profilerContainer);
777 + });
778 +
779 + it('should handle commit selection edge cases when filtering commits', async () => {
780 + const Scheduler = require('scheduler');
781 +
782 + // Create components that do varying amounts of work to generate different commit durations
783 + const Parent = ({count}) => {
784 + Scheduler.unstable_advanceTime(10);
785 + const items = [];
786 + for (let i = 0; i < count; i++) {
787 + items.push(<Child key={i} duration={i} />);
788 + }
789 + return <div>{items}</div>;
790 + };
791 + const Child = ({duration}) => {
792 + Scheduler.unstable_advanceTime(duration);
793 + return <span>{duration}</span>;
794 + };
795 +
796 + const container = document.createElement('div');
797 + const root = ReactDOMClient.createRoot(container);
798 + utils.act(() => root.render(<Parent count={1} />));
799 +
800 + // Profile and record multiple commits with different amounts of work
801 + await utils.actAsync(() => store.profilerStore.startProfiling());
802 + await utils.actAsync(() => root.render(<Parent count={5} />)); // Commit 1 - 20ms
803 + await utils.actAsync(() => root.render(<Parent count={20} />)); // Commit 2 - 200ms
804 + await utils.actAsync(() => root.render(<Parent count={50} />)); // Commit 3 - 1235ms
805 + await utils.actAsync(() => root.render(<Parent count={10} />)); // Commit 4 - 55ms
806 + await utils.actAsync(() => store.profilerStore.stopProfiling());
807 +
808 + // Context providers
809 + const Profiler =
810 + require('react-devtools-shared/src/devtools/views/Profiler/Profiler').default;
811 + const {
812 + TimelineContextController,
813 + } = require('react-devtools-timeline/src/TimelineContext');
814 + const {
815 + SettingsContextController,
816 + } = require('react-devtools-shared/src/devtools/views/Settings/SettingsContext');
817 + const {
818 + ModalDialogContextController,
819 + } = require('react-devtools-shared/src/devtools/views/ModalDialog');
820 +
821 + let context: Context = ((null: any): Context);
822 + function ContextReader() {
823 + context = React.useContext(ProfilerContext);
824 + return null;
825 + }
826 +
827 + const profilerContainer = document.createElement('div');
828 + document.body.appendChild(profilerContainer);
829 +
830 + const profilerRoot = ReactDOMClient.createRoot(profilerContainer);
831 +
832 + await utils.actAsync(() => {
833 + profilerRoot.render(
834 + <Contexts>
835 + <SettingsContextController browserTheme="light">
836 + <ModalDialogContextController>
837 + <TimelineContextController>
838 + <Profiler />
839 + <ContextReader />
840 + </TimelineContextController>
841 + </ModalDialogContextController>
842 + </SettingsContextController>
843 + </Contexts>,
844 + );
845 + });
846 +
847 + // Verify we have profiling data with 4 commits
848 + expect(context.didRecordCommits).toBe(true);
849 + expect(context.profilingData).not.toBeNull();
850 + const rootID = context.rootID;
851 + expect(rootID).not.toBeNull();
852 + const dataForRoot = context.profilingData.dataForRoots.get(rootID);
853 + expect(dataForRoot.commitData.length).toBe(4);
854 + // Edge case 1: Should start at the first commit
855 + expect(context.selectedCommitIndex).toBe(0);
856 +
857 + const ownerWindow = profilerContainer.ownerDocument.defaultView;
858 + const isMac =
859 + typeof navigator !== 'undefined' &&
860 + navigator.platform.toUpperCase().indexOf('MAC') >= 0;
861 +
862 + const arrowRightEvent = new KeyboardEvent('keydown', {
863 + key: 'ArrowRight',
864 + metaKey: isMac,
865 + ctrlKey: !isMac,
866 + bubbles: true,
867 + });
868 +
869 + await utils.actAsync(() => {
870 + ownerWindow.dispatchEvent(arrowRightEvent);
871 + }, false);
872 + expect(context.selectedCommitIndex).toBe(1);
873 +
874 + await utils.actAsync(() => {
875 + context.setIsCommitFilterEnabled(true);
876 + });
877 +
878 + // Edge case 2: When filtering is enabled, selected commit should remain if it's still visible
879 + expect(context.filteredCommitIndices.length).toBe(4);
880 + expect(context.selectedCommitIndex).toBe(1);
881 + expect(context.selectedFilteredCommitIndex).toBe(1);
882 +
883 + await utils.actAsync(() => {
884 + context.setMinCommitDuration(1000000);
885 + });
886 +
887 + // Edge case 3: When all commits are filtered out, selection should be null
888 + expect(context.filteredCommitIndices).toEqual([]);
889 + expect(context.selectedCommitIndex).toBe(null);
890 + expect(context.selectedFilteredCommitIndex).toBe(null);
891 +
892 + await utils.actAsync(() => {
893 + context.setMinCommitDuration(0);
894 + });
895 +
896 + // Edge case 4: After restoring commits, first commit should be auto-selected
897 + expect(context.filteredCommitIndices.length).toBe(4);
898 + expect(context.selectedCommitIndex).toBe(0);
899 + expect(context.selectedFilteredCommitIndex).toBe(0);
900 +
901 + await utils.actAsync(() => {
902 + ownerWindow.dispatchEvent(arrowRightEvent);
903 + }, false);
904 + expect(context.selectedCommitIndex).toBe(1);
905 +
906 + await utils.actAsync(() => {
907 + ownerWindow.dispatchEvent(arrowRightEvent);
908 + }, false);
909 + expect(context.selectedCommitIndex).toBe(2);
910 +
911 + await utils.actAsync(() => {
912 + ownerWindow.dispatchEvent(arrowRightEvent);
913 + }, false);
914 + expect(context.selectedCommitIndex).toBe(3);
915 +
916 + // Filter out the currently selected commit using actual commit data
917 + const commitDurations = dataForRoot.commitData.map(
918 + commit => commit.duration,
919 + );
920 + const selectedCommitDuration = commitDurations[3];
921 + const filterThreshold = selectedCommitDuration + 0.001;
922 + await utils.actAsync(() => {
923 + context.setMinCommitDuration(filterThreshold);
924 + });
925 +
926 + // Edge case 5: Should auto-select first available commit when current one is filtered
927 + expect(context.selectedCommitIndex).not.toBe(null);
928 + expect(context.selectedFilteredCommitIndex).toBe(1);
929 +
930 + await utils.actAsync(() => {
931 + context.setIsCommitFilterEnabled(false);
932 + });
933 +
934 + // Edge case 6: When filtering is disabled, selected commit should remain
935 + expect(context.filteredCommitIndices.length).toBe(4);
936 + expect(context.selectedCommitIndex).toBe(2);
937 + expect(context.selectedFilteredCommitIndex).toBe(2);
938 +
939 + await utils.actAsync(() => profilerRoot.unmount());
940 + document.body.removeChild(profilerContainer);
941 + });
942 });
packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js
+21 -1
@@ -54,6 +54,8 @@ function Profiler(_: {}) {
54 supportsProfiling,
55 startProfiling,
56 stopProfiling,
57 + selectPrevCommitIndex,
58 + selectNextCommitIndex,
59 } = useContext(ProfilerContext);
60
61 const {file: timelineTraceEventData, searchInputContainerRef} =
@@ -63,9 +65,9 @@ function Profiler(_: {}) {
65
66 const isLegacyProfilerSelected = selectedTabID !== 'timeline';
67
66 - // Cmd+E to start/stop profiler recording
68 const handleKeyDown = useEffectEvent((event: KeyboardEvent) => {
69 const correctModifier = isMac ? event.metaKey : event.ctrlKey;
70 + // Cmd+E to start/stop profiler recording
71 if (correctModifier && event.key === 'e') {
72 if (isProfiling) {
73 stopProfiling();
@@ -74,6 +76,24 @@ function Profiler(_: {}) {
76 }
77 event.preventDefault();
78 event.stopPropagation();
79 + } else if (
80 + isLegacyProfilerSelected &&
81 + didRecordCommits &&
82 + selectedCommitIndex !== null
83 + ) {
84 + // Cmd+Left/Right (Mac) or Ctrl+Left/Right (Windows/Linux) to navigate commits
85 + if (
86 + correctModifier &&
87 + (event.key === 'ArrowLeft' || event.key === 'ArrowRight')
88 + ) {
89 + if (event.key === 'ArrowLeft') {
90 + selectPrevCommitIndex();
91 + } else {
92 + selectNextCommitIndex();
93 + }
94 + event.preventDefault();
95 + event.stopPropagation();
96 + }
97 }
98 });
99
packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerContext.js
+78 -27
@@ -10,7 +10,14 @@
10 import type {ReactContext} from 'shared/ReactTypes';
11
12 import * as React from 'react';
13 -import {createContext, useCallback, useContext, useMemo, useState} from 'react';
13 +import {
14 + createContext,
15 + useCallback,
16 + useContext,
17 + useMemo,
18 + useState,
19 + useEffect,
20 +} from 'react';
21 import {useLocalStorage, useSubscription} from '../hooks';
22 import {
23 TreeDispatcherContext,
@@ -18,8 +25,9 @@ import {
25 } from '../Components/TreeContext';
26 import {StoreContext} from '../context';
27 import {logEvent} from 'react-devtools-shared/src/Logger';
28 +import {useCommitFilteringAndNavigation} from './useCommitFilteringAndNavigation';
29
22 -import type {ProfilingDataFrontend} from './types';
30 +import type {CommitDataFrontend, ProfilingDataFrontend} from './types';
31
32 export type TabID = 'flame-chart' | 'ranked-chart' | 'timeline';
33
@@ -61,6 +69,12 @@ export type Context = {
69 // It impacts the flame graph and ranked charts.
70 selectedCommitIndex: number | null,
71 selectCommitIndex: (value: number | null) => void,
72 + selectNextCommitIndex(): void,
73 + selectPrevCommitIndex(): void,
74 +
75 + // Which commits are currently filtered by duration?
76 + filteredCommitIndices: Array<number>,
77 + selectedFilteredCommitIndex: number | null,
78
79 // Which fiber is currently selected in the Ranked or Flamegraph charts?
80 selectedFiberID: number | null,
@@ -164,6 +178,7 @@ function ProfilerContextController({children}: Props): React.Node {
178 [setRootID, selectFiber],
179 );
180
181 + // Sync rootID with profilingData changes.
182 if (prevProfilingData !== profilingData) {
183 setPrevProfilingData(profilingData);
184
@@ -189,16 +204,6 @@ function ProfilerContextController({children}: Props): React.Node {
204 }
205 }
206
192 - const [isCommitFilterEnabled, setIsCommitFilterEnabled] =
193 - useLocalStorage<boolean>('React::DevTools::isCommitFilterEnabled', false);
194 - const [minCommitDuration, setMinCommitDuration] = useLocalStorage<number>(
195 - 'minCommitDuration',
196 - 0,
197 - );
198 -
199 - const [selectedCommitIndex, selectCommitIndex] = useState<number | null>(
200 - null,
201 - );
207 const [selectedTabID, selectTab] = useLocalStorage<TabID>(
208 'React::DevTools::Profiler::defaultTab',
209 'flame-chart',
@@ -212,27 +217,66 @@ function ProfilerContextController({children}: Props): React.Node {
217 },
218 );
219
215 - const startProfiling = useCallback(() => {
216 - logEvent({
217 - event_name: 'profiling-start',
218 - metadata: {current_tab: selectedTabID},
219 - });
220 - store.profilerStore.startProfiling();
221 - }, [store, selectedTabID]);
220 const stopProfiling = useCallback(
221 () => store.profilerStore.stopProfiling(),
222 [store],
223 );
224
227 - if (isProfiling) {
228 - if (selectedCommitIndex !== null) {
229 - selectCommitIndex(null);
225 + // Get commit data for the current root
226 + // NOTE: Unlike profilerStore.getDataForRoot() which uses Suspense (throws when data unavailable),
227 + // this uses subscription pattern and returns [] when data isn't ready.
228 + // Always check didRecordCommits before using commitData or filteredCommitIndices.
229 + const commitData = useMemo(() => {
230 + if (!didRecordCommits || rootID === null || profilingData === null) {
231 + return ([]: Array<CommitDataFrontend>);
232 }
231 - if (selectedFiberID !== null) {
232 - selectFiberID(null);
233 - selectFiberName(null);
233 + const dataForRoot = profilingData.dataForRoots.get(rootID);
234 + return dataForRoot
235 + ? dataForRoot.commitData
236 + : ([]: Array<CommitDataFrontend>);
237 + }, [didRecordCommits, rootID, profilingData]);
238 +
239 + // Commit filtering and navigation
240 + const {
241 + isCommitFilterEnabled,
242 + setIsCommitFilterEnabled,
243 + minCommitDuration,
244 + setMinCommitDuration,
245 + selectedCommitIndex,
246 + selectCommitIndex,
247 + filteredCommitIndices,
248 + selectedFilteredCommitIndex,
249 + selectNextCommitIndex,
250 + selectPrevCommitIndex,
251 + } = useCommitFilteringAndNavigation(commitData);
252 +
253 + const startProfiling = useCallback(() => {
254 + logEvent({
255 + event_name: 'profiling-start',
256 + metadata: {current_tab: selectedTabID},
257 + });
258 +
259 + // Clear selections when starting a new profiling session
260 + selectCommitIndex(null);
261 + selectFiberID(null);
262 + selectFiberName(null);
263 +
264 + store.profilerStore.startProfiling();
265 + }, [store, selectedTabID, selectCommitIndex]);
266 +
267 + // Auto-select first commit when profiling data becomes available and no commit is selected.
268 + useEffect(() => {
269 + if (
270 + profilingData !== null &&
271 + selectedCommitIndex === null &&
272 + rootID !== null
273 + ) {
274 + const dataForRoot = profilingData.dataForRoots.get(rootID);
275 + if (dataForRoot && dataForRoot.commitData.length > 0) {
276 + selectCommitIndex(0);
277 + }
278 }
235 - }
279 + }, [profilingData, rootID, selectCommitIndex]);
280
281 const value = useMemo(
282 () => ({
@@ -257,6 +301,10 @@ function ProfilerContextController({children}: Props): React.Node {
301
302 selectedCommitIndex,
303 selectCommitIndex,
304 + selectNextCommitIndex,
305 + selectPrevCommitIndex,
306 + filteredCommitIndices,
307 + selectedFilteredCommitIndex,
308
309 selectedFiberID,
310 selectedFiberName,
@@ -275,7 +323,6 @@ function ProfilerContextController({children}: Props): React.Node {
323 supportsProfiling,
324
325 rootID,
278 - setRootID,
326 setRootIDAndClearFiber,
327
328 isCommitFilterEnabled,
@@ -285,6 +332,10 @@ function ProfilerContextController({children}: Props): React.Node {
332
333 selectedCommitIndex,
334 selectCommitIndex,
335 + selectNextCommitIndex,
336 + selectPrevCommitIndex,
337 + filteredCommitIndices,
338 + selectedFilteredCommitIndex,
339
340 selectedFiberID,
341 selectedFiberName,
packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotSelector.js
+13 -65
@@ -8,7 +8,7 @@
8 */
9
10 import * as React from 'react';
11 -import {Fragment, useContext, useMemo} from 'react';
11 +import {Fragment, useContext} from 'react';
12 import Button from '../Button';
13 import ButtonIcon from '../ButtonIcon';
14 import {ProfilerContext} from './ProfilerContext';
@@ -22,11 +22,13 @@ export type Props = {};
22
23 export default function SnapshotSelector(_: Props): React.Node {
24 const {
25 - isCommitFilterEnabled,
26 - minCommitDuration,
25 rootID,
26 selectedCommitIndex,
27 selectCommitIndex,
28 + selectPrevCommitIndex,
29 + selectNextCommitIndex,
30 + filteredCommitIndices,
31 + selectedFilteredCommitIndex,
32 } = useContext(ProfilerContext);
33
34 const {profilerStore} = useContext(StoreContext);
@@ -43,47 +45,8 @@ export default function SnapshotSelector(_: Props): React.Node {
45 commitTimes.push(commitDatum.timestamp);
46 });
47
46 - const filteredCommitIndices = useMemo(
47 - () =>
48 - commitData.reduce((reduced: $FlowFixMe, commitDatum, index) => {
49 - if (
50 - !isCommitFilterEnabled ||
51 - commitDatum.duration >= minCommitDuration
52 - ) {
53 - reduced.push(index);
54 - }
55 - return reduced;
56 - }, []),
57 - [commitData, isCommitFilterEnabled, minCommitDuration],
58 - );
59 -
48 const numFilteredCommits = filteredCommitIndices.length;
49
62 - // Map the (unfiltered) selected commit index to an index within the filtered data.
63 - const selectedFilteredCommitIndex = useMemo(() => {
64 - if (selectedCommitIndex !== null) {
65 - for (let i = 0; i < filteredCommitIndices.length; i++) {
66 - if (filteredCommitIndices[i] === selectedCommitIndex) {
67 - return i;
68 - }
69 - }
70 - }
71 - return null;
72 - }, [filteredCommitIndices, selectedCommitIndex]);
73 -
74 - // TODO (ProfilerContext) This should be managed by the context controller (reducer).
75 - // It doesn't currently know about the filtered commits though (since it doesn't suspend).
76 - // Maybe this component should pass filteredCommitIndices up?
77 - if (selectedFilteredCommitIndex === null) {
78 - if (numFilteredCommits > 0) {
79 - selectCommitIndex(0);
80 - } else {
81 - selectCommitIndex(null);
82 - }
83 - } else if (selectedFilteredCommitIndex >= numFilteredCommits) {
84 - selectCommitIndex(numFilteredCommits === 0 ? null : numFilteredCommits - 1);
85 - }
86 -
50 let label = null;
51 if (numFilteredCommits > 0) {
52 // $FlowFixMe[missing-local-annot]
@@ -110,11 +73,11 @@ export default function SnapshotSelector(_: Props): React.Node {
73 const handleKeyDown = event => {
74 switch (event.key) {
75 case 'ArrowDown':
113 - viewPrevCommit();
76 + selectPrevCommitIndex();
77 event.stopPropagation();
78 break;
79 case 'ArrowUp':
117 - viewNextCommit();
80 + selectNextCommitIndex();
81 event.stopPropagation();
82 break;
83 default:
@@ -147,30 +110,15 @@ export default function SnapshotSelector(_: Props): React.Node {
110 );
111 }
112
150 - const viewNextCommit = () => {
151 - let nextCommitIndex = ((selectedFilteredCommitIndex: any): number) + 1;
152 - if (nextCommitIndex === filteredCommitIndices.length) {
153 - nextCommitIndex = 0;
154 - }
155 - selectCommitIndex(filteredCommitIndices[nextCommitIndex]);
156 - };
157 - const viewPrevCommit = () => {
158 - let nextCommitIndex = ((selectedFilteredCommitIndex: any): number) - 1;
159 - if (nextCommitIndex < 0) {
160 - nextCommitIndex = filteredCommitIndices.length - 1;
161 - }
162 - selectCommitIndex(filteredCommitIndices[nextCommitIndex]);
163 - };
164 -
113 // $FlowFixMe[missing-local-annot]
114 const handleKeyDown = event => {
115 switch (event.key) {
116 case 'ArrowLeft':
169 - viewPrevCommit();
117 + selectPrevCommitIndex();
118 event.stopPropagation();
119 break;
120 case 'ArrowRight':
173 - viewNextCommit();
121 + selectNextCommitIndex();
122 event.stopPropagation();
123 break;
124 default:
@@ -193,8 +141,8 @@ export default function SnapshotSelector(_: Props): React.Node {
141 className={styles.Button}
142 data-testname="SnapshotSelector-PreviousButton"
143 disabled={numFilteredCommits === 0}
196 - onClick={viewPrevCommit}
197 - title="Select previous commit">
144 + onClick={selectPrevCommitIndex}
145 + title="Select previous commit ←">
146 <ButtonIcon type="previous" />
147 </Button>
148 <div
@@ -227,8 +175,8 @@ export default function SnapshotSelector(_: Props): React.Node {
175 className={styles.Button}
176 data-testname="SnapshotSelector-NextButton"
177 disabled={numFilteredCommits === 0}
230 - onClick={viewNextCommit}
231 - title="Select next commit">
178 + onClick={selectNextCommitIndex}
179 + title="Select next commit →">
180 <ButtonIcon type="next" />
181 </Button>
182 </Fragment>
packages/react-devtools-shared/src/devtools/views/Profiler/useCommitFilteringAndNavigation.js new
+199
@@ -0,0 +1,199 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import {useCallback, useMemo, useState} from 'react';
11 +import {useLocalStorage} from '../hooks';
12 +
13 +import type {CommitDataFrontend} from './types';
14 +
15 +export type CommitFilteringAndNavigation = {
16 + isCommitFilterEnabled: boolean,
17 + setIsCommitFilterEnabled: (value: boolean) => void,
18 + minCommitDuration: number,
19 + setMinCommitDuration: (value: number) => void,
20 +
21 + // Selection state
22 + selectedCommitIndex: number | null,
23 + selectCommitIndex: (value: number | null) => void,
24 +
25 + // Filtered data
26 + filteredCommitIndices: Array<number>,
27 + selectedFilteredCommitIndex: number | null,
28 +
29 + // Navigation
30 + selectNextCommitIndex: () => void,
31 + selectPrevCommitIndex: () => void,
32 +};
33 +
34 +export function useCommitFilteringAndNavigation(
35 + commitData: Array<CommitDataFrontend>,
36 +): CommitFilteringAndNavigation {
37 + // Filter settings persisted to localStorage
38 + const [isCommitFilterEnabled, setIsCommitFilterEnabledValue] =
39 + useLocalStorage<boolean>('React::DevTools::isCommitFilterEnabled', false);
40 + const [minCommitDuration, setMinCommitDurationValue] =
41 + useLocalStorage<number>('minCommitDuration', 0);
42 +
43 + // Currently selected commit index (in the unfiltered list)
44 + const [selectedCommitIndex, selectCommitIndex] = useState<number | null>(
45 + null,
46 + );
47 +
48 + const calculateFilteredIndices = useCallback(
49 + (enabled: boolean, minDuration: number): Array<number> => {
50 + return commitData.reduce((reduced: Array<number>, commitDatum, index) => {
51 + if (!enabled || commitDatum.duration >= minDuration) {
52 + reduced.push(index);
53 + }
54 + return reduced;
55 + }, ([]: Array<number>));
56 + },
57 + [commitData],
58 + );
59 +
60 + const findFilteredIndex = useCallback(
61 + (commitIndex: number | null, filtered: Array<number>): number | null => {
62 + if (commitIndex === null) return null;
63 + for (let i = 0; i < filtered.length; i++) {
64 + if (filtered[i] === commitIndex) {
65 + return i;
66 + }
67 + }
68 + return null;
69 + },
70 + [],
71 + );
72 +
73 + // Adjust selection when filter settings change to keep a valid selection
74 + const adjustSelectionAfterFilterChange = useCallback(
75 + (newFilteredIndices: Array<number>) => {
76 + const currentSelectedIndex = selectedCommitIndex;
77 + const selectedFilteredIndex = findFilteredIndex(
78 + currentSelectedIndex,
79 + newFilteredIndices,
80 + );
81 +
82 + if (newFilteredIndices.length === 0) {
83 + // No commits pass the filter - clear selection
84 + selectCommitIndex(null);
85 + } else if (currentSelectedIndex === null) {
86 + // No commit was selected - select first available
87 + selectCommitIndex(newFilteredIndices[0]);
88 + } else if (selectedFilteredIndex === null) {
89 + // Currently selected commit was filtered out - find closest commit before it
90 + let closestBefore = null;
91 + for (let i = newFilteredIndices.length - 1; i >= 0; i--) {
92 + if (newFilteredIndices[i] < currentSelectedIndex) {
93 + closestBefore = newFilteredIndices[i];
94 + break;
95 + }
96 + }
97 + // If no commit before it, use the first available
98 + selectCommitIndex(
99 + closestBefore !== null ? closestBefore : newFilteredIndices[0],
100 + );
101 + } else if (selectedFilteredIndex >= newFilteredIndices.length) {
102 + // Filtered position is out of bounds - clamp to last available
103 + selectCommitIndex(newFilteredIndices[newFilteredIndices.length - 1]);
104 + }
105 + // Otherwise, the current selection is still valid in the filtered list, keep it
106 + },
107 + [findFilteredIndex, selectedCommitIndex, selectCommitIndex],
108 + );
109 +
110 + const filteredCommitIndices = useMemo(
111 + () => calculateFilteredIndices(isCommitFilterEnabled, minCommitDuration),
112 + [calculateFilteredIndices, isCommitFilterEnabled, minCommitDuration],
113 + );
114 +
115 + const selectedFilteredCommitIndex = useMemo(
116 + () => findFilteredIndex(selectedCommitIndex, filteredCommitIndices),
117 + [findFilteredIndex, selectedCommitIndex, filteredCommitIndices],
118 + );
119 +
120 + const selectNextCommitIndex = useCallback(() => {
121 + if (
122 + selectedFilteredCommitIndex === null ||
123 + filteredCommitIndices.length === 0
124 + ) {
125 + return;
126 + }
127 + let nextCommitIndex = selectedFilteredCommitIndex + 1;
128 + if (nextCommitIndex === filteredCommitIndices.length) {
129 + nextCommitIndex = 0;
130 + }
131 + selectCommitIndex(filteredCommitIndices[nextCommitIndex]);
132 + }, [selectedFilteredCommitIndex, filteredCommitIndices, selectCommitIndex]);
133 +
134 + const selectPrevCommitIndex = useCallback(() => {
135 + if (
136 + selectedFilteredCommitIndex === null ||
137 + filteredCommitIndices.length === 0
138 + ) {
139 + return;
140 + }
141 + let prevCommitIndex = selectedFilteredCommitIndex - 1;
142 + if (prevCommitIndex < 0) {
143 + prevCommitIndex = filteredCommitIndices.length - 1;
144 + }
145 + selectCommitIndex(filteredCommitIndices[prevCommitIndex]);
146 + }, [selectedFilteredCommitIndex, filteredCommitIndices, selectCommitIndex]);
147 +
148 + // Setters that also adjust selection when filter changes
149 + const setIsCommitFilterEnabled = useCallback(
150 + (value: boolean) => {
151 + setIsCommitFilterEnabledValue(value);
152 +
153 + const newFilteredIndices = calculateFilteredIndices(
154 + value,
155 + minCommitDuration,
156 + );
157 +
158 + adjustSelectionAfterFilterChange(newFilteredIndices);
159 + },
160 + [
161 + setIsCommitFilterEnabledValue,
162 + calculateFilteredIndices,
163 + minCommitDuration,
164 + adjustSelectionAfterFilterChange,
165 + ],
166 + );
167 +
168 + const setMinCommitDuration = useCallback(
169 + (value: number) => {
170 + setMinCommitDurationValue(value);
171 +
172 + const newFilteredIndices = calculateFilteredIndices(
173 + isCommitFilterEnabled,
174 + value,
175 + );
176 +
177 + adjustSelectionAfterFilterChange(newFilteredIndices);
178 + },
179 + [
180 + setMinCommitDurationValue,
181 + calculateFilteredIndices,
182 + isCommitFilterEnabled,
183 + adjustSelectionAfterFilterChange,
184 + ],
185 + );
186 +
187 + return {
188 + isCommitFilterEnabled,
189 + setIsCommitFilterEnabled,
190 + minCommitDuration,
191 + setMinCommitDuration,
192 + selectedCommitIndex,
193 + selectCommitIndex,
194 + filteredCommitIndices,
195 + selectedFilteredCommitIndex,
196 + selectNextCommitIndex,
197 + selectPrevCommitIndex,
198 + };
199 +}