Skip to content

Commit fd524fe

Browse files
emily8rownhoxyq
andauthored
[DevTools] hotkey to start/stop profiling (#35160)
## Summary The built-in browser profiler supports starting/stopping with Cmd+E. For Symmetry this adds the same hotkey for react devtools profiler. ## How did you test this change? yarn build:\<browser name\> yarn run test:\<browser name\> <img width="483" height="135" alt="Screenshot 2025-11-17 at 14 30 34" src="https://github.com/user-attachments/assets/426939aa-15da-4c21-87a4-e949e6949482" /> firefox: https://github.com/user-attachments/assets/6f225b90-828f-4e79-a364-59d6bc942f83 edge: https://github.com/user-attachments/assets/5b2e9242-f0e8-481b-99a2-2dd78099f3ac chrome: https://github.com/user-attachments/assets/790aab02-2867-4499-aec1-e32e38c763f9 --------- Co-authored-by: Ruslan Lesiutin <[email protected]>
1 parent 40b4a5b commit fd524fe

File tree

3 files changed

+113
-3
lines changed

3 files changed

+113
-3
lines changed

packages/react-devtools-shared/src/__tests__/profilerContext-test.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,4 +584,75 @@ describe('ProfilerContext', () => {
584584
await utils.actAsync(() => context.selectFiber(childID, 'Child'));
585585
expect(inspectedElementID).toBe(parentID);
586586
});
587+
588+
it('should toggle profiling when the keyboard shortcut is pressed', async () => {
589+
// Context providers
590+
const Profiler =
591+
require('react-devtools-shared/src/devtools/views/Profiler/Profiler').default;
592+
const {
593+
TimelineContextController,
594+
} = require('react-devtools-timeline/src/TimelineContext');
595+
const {
596+
SettingsContextController,
597+
} = require('react-devtools-shared/src/devtools/views/Settings/SettingsContext');
598+
const {
599+
ModalDialogContextController,
600+
} = require('react-devtools-shared/src/devtools/views/ModalDialog');
601+
602+
// Dom component for profiling to be enabled
603+
const Component = () => null;
604+
utils.act(() => render(<Component />));
605+
606+
const profilerContainer = document.createElement('div');
607+
document.body.appendChild(profilerContainer);
608+
609+
// Create a root for the profiler
610+
const profilerRoot = ReactDOMClient.createRoot(profilerContainer);
611+
612+
// Render the profiler
613+
utils.act(() => {
614+
profilerRoot.render(
615+
<Contexts>
616+
<SettingsContextController browserTheme="light">
617+
<ModalDialogContextController>
618+
<TimelineContextController>
619+
<Profiler />
620+
</TimelineContextController>
621+
</ModalDialogContextController>
622+
</SettingsContextController>
623+
</Contexts>,
624+
);
625+
});
626+
627+
// Verify that the profiler is not profiling.
628+
expect(store.profilerStore.isProfilingBasedOnUserInput).toBe(false);
629+
630+
// Trigger the keyboard shortcut.
631+
const ownerWindow = profilerContainer.ownerDocument.defaultView;
632+
const isMac =
633+
typeof navigator !== 'undefined' &&
634+
navigator.platform.toUpperCase().indexOf('MAC') >= 0;
635+
636+
const keyEvent = new KeyboardEvent('keydown', {
637+
key: 'e',
638+
metaKey: isMac,
639+
ctrlKey: !isMac,
640+
bubbles: true,
641+
});
642+
643+
// Dispatch keyboard event to toggle profiling on
644+
// Try utils.actAsync with recursivelyFlush=false
645+
await utils.actAsync(() => {
646+
ownerWindow.dispatchEvent(keyEvent);
647+
}, false);
648+
expect(store.profilerStore.isProfilingBasedOnUserInput).toBe(true);
649+
650+
// Dispatch keyboard event to toggle profiling off
651+
await utils.actAsync(() => {
652+
ownerWindow.dispatchEvent(keyEvent);
653+
}, false);
654+
expect(store.profilerStore.isProfilingBasedOnUserInput).toBe(false);
655+
656+
document.body.removeChild(profilerContainer);
657+
});
587658
});

packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import * as React from 'react';
11-
import {Fragment, useContext} from 'react';
11+
import {Fragment, useContext, useEffect, useRef, useEffectEvent} from 'react';
1212
import {ModalDialog} from '../ModalDialog';
1313
import {ProfilerContext} from './ProfilerContext';
1414
import TabBar from '../TabBar';
@@ -38,6 +38,11 @@ import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext';
3838
import styles from './Profiler.css';
3939

4040
function Profiler(_: {}) {
41+
const profilerRef = useRef<HTMLDivElement | null>(null);
42+
const isMac =
43+
typeof navigator !== 'undefined' &&
44+
navigator.platform.toUpperCase().indexOf('MAC') >= 0;
45+
4146
const {
4247
didRecordCommits,
4348
isProcessingData,
@@ -47,6 +52,8 @@ function Profiler(_: {}) {
4752
selectedTabID,
4853
selectTab,
4954
supportsProfiling,
55+
startProfiling,
56+
stopProfiling,
5057
} = useContext(ProfilerContext);
5158

5259
const {file: timelineTraceEventData, searchInputContainerRef} =
@@ -56,6 +63,32 @@ function Profiler(_: {}) {
5663

5764
const isLegacyProfilerSelected = selectedTabID !== 'timeline';
5865

66+
// Cmd+E to start/stop profiler recording
67+
const handleKeyDown = useEffectEvent((event: KeyboardEvent) => {
68+
const correctModifier = isMac ? event.metaKey : event.ctrlKey;
69+
if (correctModifier && event.key === 'e') {
70+
if (isProfiling) {
71+
stopProfiling();
72+
} else {
73+
startProfiling();
74+
}
75+
event.preventDefault();
76+
event.stopPropagation();
77+
}
78+
});
79+
80+
useEffect(() => {
81+
const div = profilerRef.current;
82+
if (!div) {
83+
return;
84+
}
85+
const ownerWindow = div.ownerDocument.defaultView;
86+
ownerWindow.addEventListener('keydown', handleKeyDown);
87+
return () => {
88+
ownerWindow.removeEventListener('keydown', handleKeyDown);
89+
};
90+
}, []);
91+
5992
let view = null;
6093
if (didRecordCommits || selectedTabID === 'timeline') {
6194
switch (selectedTabID) {
@@ -112,7 +145,7 @@ function Profiler(_: {}) {
112145

113146
return (
114147
<SettingsModalContextController>
115-
<div className={styles.Profiler}>
148+
<div ref={profilerRef} className={styles.Profiler}>
116149
<div className={styles.LeftColumn}>
117150
<div className={styles.Toolbar}>
118151
<RecordToggle disabled={!supportsProfiling} />

packages/react-devtools-shared/src/devtools/views/Profiler/RecordToggle.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,19 @@ export default function RecordToggle({disabled}: Props): React.Node {
3030
className = styles.ActiveRecordToggle;
3131
}
3232

33+
const isMac =
34+
typeof navigator !== 'undefined' &&
35+
navigator.platform.toUpperCase().indexOf('MAC') >= 0;
36+
const shortcut = isMac ? '⌘E' : 'Ctrl+E';
37+
const title = `${isProfiling ? 'Stop' : 'Start'} profiling - ${shortcut}`;
38+
3339
return (
3440
<Button
3541
className={className}
3642
disabled={disabled}
3743
onClick={isProfiling ? stopProfiling : startProfiling}
3844
testName="ProfilerToggleButton"
39-
title={isProfiling ? 'Stop profiling' : 'Start profiling'}>
45+
title={title}>
4046
<ButtonIcon type="record" />
4147
</Button>
4248
);

0 commit comments

Comments
 (0)