Skip to content

Commit 4ea12a1

Browse files
authored
[DevTools] Make Element Inspection Feel Snappy (facebook#30555)
There's two problems. The biggest one is that it turns out that Chrome is throttling looping timers that we're using both while polling and for batching bridge traffic. This means that bridge traffic a lot of the time just slows down to 1 second at a time. No wonder it feels sluggish. The only solution is to not use timers for this. Even when it doesn't like in Firefox the batching into 100ms still feels too sluggish. The fix I use is to batch using a microtask instead so we can still batch multiple commands sent in a single event but we never artificially slow down an interaction. I don't think we've reevaluated this for a long time since this was in the initial commit of DevTools to this repo. If it causes other issues we can follow up on those. We really shouldn't use timers for debouncing and such. In fact, React itself recommends against it because we have a better technique with scheduling in Concurrent Mode. The correct way to implement this in the bridge is using a form of back-pressure where we don't keep sending messages until we get a message back and only send the last one that matters. E.g. when moving the cursor over a the elements tab we shouldn't let the backend one-by-one move the DOM node to each one we have ever passed. We should just move to the last one we're currently hovering over. But this can't be done at the bridge layer since it doesn't know if it's a last-one-wins or imperative operation where each one needs to be sent. It needs to be done higher. I'm not currently seeing any perf problems with this new approach but I'm curious on React Native or some thing. RN might need the back-pressure approach. That can be a follow up if we ever find a test case. Finally, the other problem is that we use a Suspense boundary around the Element Inspection. Suspense boundaries are for things that are expected to take a long time to load. This shows a loading state immediately. To avoid flashing when it ends up being fast, React throttles the reveal to 200ms. This means that we take a minimum of 200ms to show the props. The way to show fast async data in React is using a Transition (either using startTransition or useDeferredValue). This lets the old value remaining in place while we're loading the next one. We already implement this using `inspectedElementID` which is the async one. It would be more idiomatic to implement this with useDeferredValue rather than the reducer we have now but same principle. We were just using the wrong ID in a few places so when it synchronously updated they suspended. So I just made them use the inspectedElementID instead. Then I can simply remove the Suspense boundary. Now the selection updates in the tree view synchronously and the sidebar lags a frame or two but it feels instant. It doesn't flash to white between which is key.
1 parent 88ee14f commit 4ea12a1

File tree

6 files changed

+44
-53
lines changed

6 files changed

+44
-53
lines changed

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

+5-6
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,11 @@ describe('InspectedElement', () => {
117117
<SettingsContextController>
118118
<TreeContextController
119119
defaultSelectedElementID={defaultSelectedElementID}
120-
defaultSelectedElementIndex={defaultSelectedElementIndex}>
121-
<React.Suspense fallback="Loading...">
122-
<InspectedElementContextController>
123-
{children}
124-
</InspectedElementContextController>
125-
</React.Suspense>
120+
defaultSelectedElementIndex={defaultSelectedElementIndex}
121+
defaultInspectedElementID={defaultSelectedElementID}>
122+
<InspectedElementContextController>
123+
{children}
124+
</InspectedElementContextController>
126125
</TreeContextController>
127126
</SettingsContextController>
128127
</StoreContext.Provider>

packages/react-devtools-shared/src/__tests__/setupTests.js

+5
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ beforeEach(() => {
128128
// Fake timers let us flush Bridge operations between setup and assertions.
129129
jest.useFakeTimers();
130130

131+
// We use fake timers heavily in tests but the bridge batching now uses microtasks.
132+
global.devtoolsJestTestScheduler = callback => {
133+
setTimeout(callback, 0);
134+
};
135+
131136
// Use utils.js#withErrorsOrWarningsIgnored instead of directly mutating this array.
132137
global._ignoredErrorOrWarningMessages = [
133138
'react-test-renderer is deprecated.',

packages/react-devtools-shared/src/bridge.js

+24-26
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ import type {
1919
} from 'react-devtools-shared/src/backend/types';
2020
import type {StyleAndLayout as StyleAndLayoutPayload} from 'react-devtools-shared/src/backend/NativeStyleEditor/types';
2121

22-
const BATCH_DURATION = 100;
23-
2422
// This message specifies the version of the DevTools protocol currently supported by the backend,
2523
// as well as the earliest NPM version (e.g. "4.13.0") that protocol is supported by on the frontend.
2624
// This enables an older frontend to display an upgrade message to users for a newer, unsupported backend.
@@ -276,7 +274,7 @@ class Bridge<
276274
}> {
277275
_isShutdown: boolean = false;
278276
_messageQueue: Array<any> = [];
279-
_timeoutID: TimeoutID | null = null;
277+
_scheduledFlush: boolean = false;
280278
_wall: Wall;
281279
_wallUnlisten: Function | null = null;
282280

@@ -324,8 +322,19 @@ class Bridge<
324322
// (or we're waiting for our setTimeout-0 to fire), then _timeoutID will
325323
// be set, and we'll simply add to the queue and wait for that
326324
this._messageQueue.push(event, payload);
327-
if (!this._timeoutID) {
328-
this._timeoutID = setTimeout(this._flush, 0);
325+
if (!this._scheduledFlush) {
326+
this._scheduledFlush = true;
327+
// $FlowFixMe
328+
if (typeof devtoolsJestTestScheduler === 'function') {
329+
// This exists just for our own jest tests.
330+
// They're written in such a way that we can neither mock queueMicrotask
331+
// because then we break React DOM and we can't not mock it because then
332+
// we can't synchronously flush it. So they need to be rewritten.
333+
// $FlowFixMe
334+
devtoolsJestTestScheduler(this._flush); // eslint-disable-line no-undef
335+
} else {
336+
queueMicrotask(this._flush);
337+
}
329338
}
330339
}
331340

@@ -363,34 +372,23 @@ class Bridge<
363372
do {
364373
this._flush();
365374
} while (this._messageQueue.length);
366-
367-
// Make sure once again that there is no dangling timer.
368-
if (this._timeoutID !== null) {
369-
clearTimeout(this._timeoutID);
370-
this._timeoutID = null;
371-
}
372375
}
373376

374377
_flush: () => void = () => {
375378
// This method is used after the bridge is marked as destroyed in shutdown sequence,
376379
// so we do not bail out if the bridge marked as destroyed.
377380
// It is a private method that the bridge ensures is only called at the right times.
378-
379-
if (this._timeoutID !== null) {
380-
clearTimeout(this._timeoutID);
381-
this._timeoutID = null;
382-
}
383-
384-
if (this._messageQueue.length) {
385-
for (let i = 0; i < this._messageQueue.length; i += 2) {
386-
this._wall.send(this._messageQueue[i], ...this._messageQueue[i + 1]);
381+
try {
382+
if (this._messageQueue.length) {
383+
for (let i = 0; i < this._messageQueue.length; i += 2) {
384+
this._wall.send(this._messageQueue[i], ...this._messageQueue[i + 1]);
385+
}
386+
this._messageQueue.length = 0;
387387
}
388-
this._messageQueue.length = 0;
389-
390-
// Check again for queued messages in BATCH_DURATION ms. This will keep
391-
// flushing in a loop as long as messages continue to be added. Once no
392-
// more are, the timer expires.
393-
this._timeoutID = setTimeout(this._flush, BATCH_DURATION);
388+
} finally {
389+
// We set this at the end in case new messages are added synchronously above.
390+
// They're already handled so they shouldn't queue more flushes.
391+
this._scheduledFlush = false;
394392
}
395393
};
396394

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

+4-17
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,7 @@
88
*/
99

1010
import * as React from 'react';
11-
import {
12-
Fragment,
13-
Suspense,
14-
useEffect,
15-
useLayoutEffect,
16-
useReducer,
17-
useRef,
18-
} from 'react';
11+
import {Fragment, useEffect, useLayoutEffect, useReducer, useRef} from 'react';
1912
import Tree from './Tree';
2013
import {OwnersListContextController} from './OwnersListContext';
2114
import portaledContent from '../portaledContent';
@@ -169,11 +162,9 @@ function Components(_: {}) {
169162
<div className={styles.InspectedElementWrapper}>
170163
<NativeStyleContextController>
171164
<InspectedElementErrorBoundary>
172-
<Suspense fallback={<Loading />}>
173-
<InspectedElementContextController>
174-
<InspectedElement />
175-
</InspectedElementContextController>
176-
</Suspense>
165+
<InspectedElementContextController>
166+
<InspectedElement />
167+
</InspectedElementContextController>
177168
</InspectedElementErrorBoundary>
178169
</NativeStyleContextController>
179170
</div>
@@ -186,10 +177,6 @@ function Components(_: {}) {
186177
);
187178
}
188179

189-
function Loading() {
190-
return <div className={styles.Loading}>Loading...</div>;
191-
}
192-
193180
const LOCAL_STORAGE_KEY = 'React::DevTools::createResizeReducer';
194181
const VERTICAL_MODE_MAX_WIDTH = 600;
195182
const MINIMUM_SIZE = 50;

packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export type Props = {
6666
export function InspectedElementContextController({
6767
children,
6868
}: Props): React.Node {
69-
const {selectedElementID} = useContext(TreeStateContext);
69+
const {inspectedElementID} = useContext(TreeStateContext);
7070
const fetchFileWithCaching = useContext(FetchFileWithCachingContext);
7171
const bridge = useContext(BridgeContext);
7272
const store = useContext(StoreContext);
@@ -93,7 +93,9 @@ export function InspectedElementContextController({
9393
});
9494

9595
const element =
96-
selectedElementID !== null ? store.getElementByID(selectedElementID) : null;
96+
inspectedElementID !== null
97+
? store.getElementByID(inspectedElementID)
98+
: null;
9799

98100
const alreadyLoadedHookNames =
99101
element != null && hasAlreadyLoadedHookNames(element);

packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorBoundary.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default function InspectedElementErrorBoundaryWrapper({
2727
}: WrapperProps): React.Node {
2828
// Key on the selected element ID so that changing the selected element automatically hides the boundary.
2929
// This seems best since an error inspecting one element isn't likely to be relevant to another element.
30-
const {selectedElementID} = useContext(TreeStateContext);
30+
const {inspectedElementID} = useContext(TreeStateContext);
3131

3232
const refresh = useCacheRefresh();
3333
const handleDsmiss = useCallback(() => {
@@ -37,7 +37,7 @@ export default function InspectedElementErrorBoundaryWrapper({
3737
return (
3838
<div className={styles.Wrapper}>
3939
<ErrorBoundary
40-
key={selectedElementID}
40+
key={inspectedElementID}
4141
canDismiss={true}
4242
onBeforeDismissCallback={handleDsmiss}>
4343
{children}

0 commit comments

Comments
 (0)