Skip to content

Commit d85cf3e

Browse files
authored
DevTools: refactor NativeStyleEditor, don't use custom cache implementation (#32298)
We have this really old (5+ years) feature for inspecting native styles of React Native Host components. We also have a custom Cache implementation in React DevTools, which was forked from React at some point. We know that this should be removed, but it spans through critical parts of the application, like fetching and caching inspected element. Before this PR, this was also used for caching native style and layouts of RN Host components. This approach is out of date, and was based on the presence of Suspense boundary around inspected element View, which we have removed to speed up element inspection - #30555. Looks like I've introduced a regression in #31956: - Custom Cache implementation will throw thenables and suspend. - Because of this, some descendant Suspense boundaries will not resolve for a long time, and React will throw an error https://react.dev/errors/482. I've switched from a usage of this custom Cache implementation to a naive fetching in effect and keeping the layout and style in a local state of a Context, which will be propagated downwards. The race should be impossible, this is guaranteed by the mechanism for queueing messages through microtasks queue. The only downside is the UI. If you quickly switch between 2 elements, and one of them has native style, while the other doesn't, UI will feel jumpy. We can address this later with a Suspense boundary, if needed.
1 parent 32b4114 commit d85cf3e

File tree

8 files changed

+67
-172
lines changed

8 files changed

+67
-172
lines changed

packages/react-devtools-inline/__tests__/__e2e__/components.test.js

+14-20
Original file line numberDiff line numberDiff line change
@@ -212,17 +212,19 @@ test.describe('Components', () => {
212212
});
213213

214214
test('should allow searching for component by name', async () => {
215-
async function getComponentSearchResultsCount() {
216-
return await page.evaluate(() => {
215+
async function waitForComponentSearchResultsCount(text) {
216+
return await page.waitForFunction(expectedElementText => {
217217
const {createTestNameSelector, findAllNodes} =
218218
window.REACT_DOM_DEVTOOLS;
219219
const container = document.getElementById('devtools');
220220

221221
const element = findAllNodes(container, [
222222
createTestNameSelector('ComponentSearchInput-ResultsCount'),
223223
])[0];
224-
return element.innerText;
225-
});
224+
return element !== undefined
225+
? element.innerText === expectedElementText
226+
: false;
227+
}, text);
226228
}
227229

228230
async function focusComponentSearch() {
@@ -238,35 +240,27 @@ test.describe('Components', () => {
238240

239241
await focusComponentSearch();
240242
await page.keyboard.insertText('List');
241-
let count = await getComponentSearchResultsCount();
242-
expect(count).toBe('1 | 4');
243+
await waitForComponentSearchResultsCount('1 | 4');
243244

244245
await page.keyboard.insertText('Item');
245-
count = await getComponentSearchResultsCount();
246-
expect(count).toBe('1 | 3');
246+
await waitForComponentSearchResultsCount('1 | 3');
247247

248248
await page.keyboard.press('Enter');
249-
count = await getComponentSearchResultsCount();
250-
expect(count).toBe('2 | 3');
249+
await waitForComponentSearchResultsCount('2 | 3');
251250

252251
await page.keyboard.press('Enter');
253-
count = await getComponentSearchResultsCount();
254-
expect(count).toBe('3 | 3');
252+
await waitForComponentSearchResultsCount('3 | 3');
255253

256254
await page.keyboard.press('Enter');
257-
count = await getComponentSearchResultsCount();
258-
expect(count).toBe('1 | 3');
255+
await waitForComponentSearchResultsCount('1 | 3');
259256

260257
await page.keyboard.press('Shift+Enter');
261-
count = await getComponentSearchResultsCount();
262-
expect(count).toBe('3 | 3');
258+
await waitForComponentSearchResultsCount('3 | 3');
263259

264260
await page.keyboard.press('Shift+Enter');
265-
count = await getComponentSearchResultsCount();
266-
expect(count).toBe('2 | 3');
261+
await waitForComponentSearchResultsCount('2 | 3');
267262

268263
await page.keyboard.press('Shift+Enter');
269-
count = await getComponentSearchResultsCount();
270-
expect(count).toBe('1 | 3');
264+
await waitForComponentSearchResultsCount('1 | 3');
271265
});
272266
});

packages/react-devtools-shared/src/devtools/views/Components/NativeStyleEditor/LayoutViewer.css

-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
.LayoutViewer {
22
padding: 0.25rem;
3-
border-top: 1px solid var(--color-border);
43
font-family: var(--font-family-monospace);
54
font-size: var(--font-size-monospace-small);
65
}

packages/react-devtools-shared/src/devtools/views/Components/NativeStyleEditor/StyleEditor.css

-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
font-family: var(--font-family-monospace);
33
font-size: var(--font-size-monospace-normal);
44
padding: 0.25rem;
5-
border-top: 1px solid var(--color-border);
65
}
76

87
.HeaderRow {

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export default function StyleEditor({id, style}: Props): React.Node {
8181
{keys.length > 0 &&
8282
keys.map(attribute => (
8383
<Row
84-
key={attribute}
84+
key={`${attribute}/${style[attribute]}`}
8585
attribute={attribute}
8686
changeAttribute={changeAttribute}
8787
changeValue={changeValue}

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

+21-121
Original file line numberDiff line numberDiff line change
@@ -10,148 +10,49 @@
1010
import type {ReactContext} from 'shared/ReactTypes';
1111

1212
import * as React from 'react';
13-
import {
14-
createContext,
15-
useCallback,
16-
useContext,
17-
useEffect,
18-
useMemo,
19-
useState,
20-
} from 'react';
21-
import {createResource} from 'react-devtools-shared/src/devtools/cache';
13+
import {createContext, useContext, useEffect, useState} from 'react';
2214
import {
2315
BridgeContext,
2416
StoreContext,
2517
} from 'react-devtools-shared/src/devtools/views/context';
26-
import {TreeStateContext} from '../TreeContext';
18+
import {TreeStateContext} from 'react-devtools-shared/src/devtools/views/Components/TreeContext';
2719

28-
import type {StateContext} from '../TreeContext';
20+
import type {StateContext} from 'react-devtools-shared/src/devtools/views/Components/TreeContext';
2921
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
3022
import type Store from 'react-devtools-shared/src/devtools/store';
3123
import type {StyleAndLayout as StyleAndLayoutBackend} from 'react-devtools-shared/src/backend/NativeStyleEditor/types';
3224
import type {StyleAndLayout as StyleAndLayoutFrontend} from './types';
33-
import type {Element} from 'react-devtools-shared/src/frontend/types';
34-
import type {
35-
Resource,
36-
Thenable,
37-
} from 'react-devtools-shared/src/devtools/cache';
38-
39-
export type GetStyleAndLayout = (id: number) => StyleAndLayoutFrontend | null;
4025

41-
type Context = {
42-
getStyleAndLayout: GetStyleAndLayout,
43-
};
26+
type Context = StyleAndLayoutFrontend | null;
4427

4528
const NativeStyleContext: ReactContext<Context> = createContext<Context>(
4629
((null: any): Context),
4730
);
4831
NativeStyleContext.displayName = 'NativeStyleContext';
4932

50-
type ResolveFn = (styleAndLayout: StyleAndLayoutFrontend) => void;
51-
type InProgressRequest = {
52-
promise: Thenable<StyleAndLayoutFrontend>,
53-
resolveFn: ResolveFn,
54-
};
55-
56-
const inProgressRequests: WeakMap<Element, InProgressRequest> = new WeakMap();
57-
const resource: Resource<Element, Element, StyleAndLayoutFrontend> =
58-
createResource(
59-
(element: Element) => {
60-
const request = inProgressRequests.get(element);
61-
if (request != null) {
62-
return request.promise;
63-
}
64-
65-
let resolveFn:
66-
| ResolveFn
67-
| ((
68-
result: Promise<StyleAndLayoutFrontend> | StyleAndLayoutFrontend,
69-
) => void) = ((null: any): ResolveFn);
70-
const promise = new Promise(resolve => {
71-
resolveFn = resolve;
72-
});
73-
74-
inProgressRequests.set(element, ({promise, resolveFn}: $FlowFixMe));
75-
76-
return (promise: $FlowFixMe);
77-
},
78-
(element: Element) => element,
79-
{useWeakMap: true},
80-
);
81-
8233
type Props = {
8334
children: React$Node,
8435
};
8536

8637
function NativeStyleContextController({children}: Props): React.Node {
8738
const bridge = useContext<FrontendBridge>(BridgeContext);
8839
const store = useContext<Store>(StoreContext);
89-
90-
const getStyleAndLayout = useCallback<GetStyleAndLayout>(
91-
(id: number) => {
92-
const element = store.getElementByID(id);
93-
if (element !== null) {
94-
return resource.read(element);
95-
} else {
96-
return null;
97-
}
98-
},
99-
[store],
100-
);
101-
102-
// It's very important that this context consumes inspectedElementID and not NativeStyleID.
103-
// Otherwise the effect that sends the "inspect" message across the bridge-
104-
// would itself be blocked by the same render that suspends (waiting for the data).
10540
const {inspectedElementID} = useContext<StateContext>(TreeStateContext);
10641

10742
const [currentStyleAndLayout, setCurrentStyleAndLayout] =
10843
useState<StyleAndLayoutFrontend | null>(null);
10944

110-
// This effect handler invalidates the suspense cache and schedules rendering updates with React.
111-
useEffect(() => {
112-
const onStyleAndLayout = ({id, layout, style}: StyleAndLayoutBackend) => {
113-
const element = store.getElementByID(id);
114-
if (element !== null) {
115-
const styleAndLayout: StyleAndLayoutFrontend = {
116-
layout,
117-
style,
118-
};
119-
const request = inProgressRequests.get(element);
120-
if (request != null) {
121-
inProgressRequests.delete(element);
122-
request.resolveFn(styleAndLayout);
123-
setCurrentStyleAndLayout(styleAndLayout);
124-
} else {
125-
resource.write(element, styleAndLayout);
126-
127-
// Schedule update with React if the currently-selected element has been invalidated.
128-
if (id === inspectedElementID) {
129-
setCurrentStyleAndLayout(styleAndLayout);
130-
}
131-
}
132-
}
133-
};
134-
135-
bridge.addListener('NativeStyleEditor_styleAndLayout', onStyleAndLayout);
136-
return () =>
137-
bridge.removeListener(
138-
'NativeStyleEditor_styleAndLayout',
139-
onStyleAndLayout,
140-
);
141-
}, [bridge, currentStyleAndLayout, inspectedElementID, store]);
142-
14345
// This effect handler polls for updates on the currently selected element.
14446
useEffect(() => {
14547
if (inspectedElementID === null) {
48+
setCurrentStyleAndLayout(null);
14649
return () => {};
14750
}
14851

149-
const rendererID = store.getRendererIDForElement(inspectedElementID);
150-
151-
let timeoutID: TimeoutID | null = null;
152-
52+
let requestTimeoutId: TimeoutID | null = null;
15353
const sendRequest = () => {
154-
timeoutID = null;
54+
requestTimeoutId = null;
55+
const rendererID = store.getRendererIDForElement(inspectedElementID);
15556

15657
if (rendererID !== null) {
15758
bridge.send('NativeStyleEditor_measure', {
@@ -165,38 +66,37 @@ function NativeStyleContextController({children}: Props): React.Node {
16566
// We'll poll for an update in the response handler below.
16667
sendRequest();
16768

168-
const onStyleAndLayout = ({id}: StyleAndLayoutBackend) => {
69+
const onStyleAndLayout = ({id, layout, style}: StyleAndLayoutBackend) => {
16970
// If this is the element we requested, wait a little bit and then ask for another update.
17071
if (id === inspectedElementID) {
171-
if (timeoutID !== null) {
172-
clearTimeout(timeoutID);
72+
if (requestTimeoutId !== null) {
73+
clearTimeout(requestTimeoutId);
17374
}
174-
timeoutID = setTimeout(sendRequest, 1000);
75+
requestTimeoutId = setTimeout(sendRequest, 1000);
17576
}
77+
78+
const styleAndLayout: StyleAndLayoutFrontend = {
79+
layout,
80+
style,
81+
};
82+
setCurrentStyleAndLayout(styleAndLayout);
17683
};
17784

17885
bridge.addListener('NativeStyleEditor_styleAndLayout', onStyleAndLayout);
179-
18086
return () => {
18187
bridge.removeListener(
18288
'NativeStyleEditor_styleAndLayout',
18389
onStyleAndLayout,
18490
);
18591

186-
if (timeoutID !== null) {
187-
clearTimeout(timeoutID);
92+
if (requestTimeoutId !== null) {
93+
clearTimeout(requestTimeoutId);
18894
}
18995
};
19096
}, [bridge, inspectedElementID, store]);
19197

192-
const value = useMemo(
193-
() => ({getStyleAndLayout}),
194-
// NativeStyle is used to invalidate the cache and schedule an update with React.
195-
[currentStyleAndLayout, getStyleAndLayout],
196-
);
197-
19898
return (
199-
<NativeStyleContext.Provider value={value}>
99+
<NativeStyleContext.Provider value={currentStyleAndLayout}>
200100
{children}
201101
</NativeStyleContext.Provider>
202102
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.Stack > *:not(:first-child) {
2+
border-top: 1px solid var(--color-border);
3+
}

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

+17-22
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,19 @@
88
*/
99

1010
import * as React from 'react';
11-
import {Fragment, useContext, useMemo} from 'react';
11+
import {useContext, useMemo} from 'react';
12+
1213
import {StoreContext} from 'react-devtools-shared/src/devtools/views/context';
1314
import {useSubscription} from 'react-devtools-shared/src/devtools/views/hooks';
15+
import {TreeStateContext} from 'react-devtools-shared/src/devtools/views/Components/TreeContext';
16+
1417
import {NativeStyleContext} from './context';
1518
import LayoutViewer from './LayoutViewer';
1619
import StyleEditor from './StyleEditor';
17-
import {TreeStateContext} from '../TreeContext';
18-
19-
type Props = {};
20+
import styles from './index.css';
2021

21-
export default function NativeStyleEditorWrapper(_: Props): React.Node {
22+
export default function NativeStyleEditorWrapper(): React.Node {
2223
const store = useContext(StoreContext);
23-
2424
const subscription = useMemo(
2525
() => ({
2626
getCurrentValue: () => store.supportsNativeStyleEditor,
@@ -33,41 +33,36 @@ export default function NativeStyleEditorWrapper(_: Props): React.Node {
3333
}),
3434
[store],
3535
);
36-
const supportsNativeStyleEditor = useSubscription<boolean>(subscription);
3736

37+
const supportsNativeStyleEditor = useSubscription<boolean>(subscription);
3838
if (!supportsNativeStyleEditor) {
3939
return null;
4040
}
4141

4242
return <NativeStyleEditor />;
4343
}
4444

45-
function NativeStyleEditor(_: Props) {
46-
const {getStyleAndLayout} = useContext(NativeStyleContext);
47-
45+
function NativeStyleEditor() {
4846
const {inspectedElementID} = useContext(TreeStateContext);
47+
const inspectedElementStyleAndLayout = useContext(NativeStyleContext);
4948
if (inspectedElementID === null) {
5049
return null;
5150
}
52-
53-
const maybeStyleAndLayout = getStyleAndLayout(inspectedElementID);
54-
if (maybeStyleAndLayout === null) {
51+
if (inspectedElementStyleAndLayout === null) {
5552
return null;
5653
}
5754

58-
const {layout, style} = maybeStyleAndLayout;
55+
const {layout, style} = inspectedElementStyleAndLayout;
56+
if (layout === null && style === null) {
57+
return null;
58+
}
5959

6060
return (
61-
<Fragment>
61+
<div className={styles.Stack}>
6262
{layout !== null && (
6363
<LayoutViewer id={inspectedElementID} layout={layout} />
6464
)}
65-
{style !== null && (
66-
<StyleEditor
67-
id={inspectedElementID}
68-
style={style !== null ? style : {}}
69-
/>
70-
)}
71-
</Fragment>
65+
{style !== null && <StyleEditor id={inspectedElementID} style={style} />}
66+
</div>
7267
);
7368
}

0 commit comments

Comments
 (0)