@samitouri / QOS-React / commits / 281616f462

Support highlighting of all DOM elements of Fragments, not just first

Fixes https://github.com/bvaughn/react-devtools-experimental/issues/131 Uses the new function `findAllCurrentHostFibers`. Removes dependency on React's `renderer.findHostInstanceByFiber` function which used to highlight only the first DOM element of a Fragment. Reworked `src/backend/views/Overlay` to support highlighting more than one element rectangle annotated with one tooltip. Fixed minor issues with the tooltip position calculation.

Ivan Babak committed Apr 21, 2019 at 01:06 UTC 281616f462866f382c0fead366c7b2b5172a1ce5
5 files changed +250 -100
src/backend/agent.js
+6 -5
@@ -276,18 +276,19 @@ export default class Agent extends EventEmitter {
276 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
277 }
278
279 - let node: HTMLElement | null = null;
279 + let nodes: ?Array<HTMLElement> = null;
280 if (renderer !== null) {
281 - node = ((renderer.findNativeByFiberID(id): any): HTMLElement);
281 + nodes = ((renderer.findNativeByFiberID(id): any): ?Array<HTMLElement>);
282 }
283
284 - if (node != null) {
284 + if (nodes != null && nodes[0] != null) {
285 + const node = nodes[0];
286 if (scrollIntoView && typeof node.scrollIntoView === 'function') {
287 // If the node isn't visible show it before highlighting it.
288 // We may want to reconsider this; it might be a little disruptive.
289 node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
290 }
290 - showOverlay(((node: any): HTMLElement), displayName, hideAfterTimeout);
291 + showOverlay(nodes, displayName, hideAfterTimeout);
292 if (openNativeElementsPanel) {
293 window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = node;
294 this._bridge.send('syncSelectionToNativeElementsPanel');
@@ -585,7 +586,7 @@ export default class Agent extends EventEmitter {
586
587 // Don't pass the name explicitly.
588 // It will be inferred from DOM tag and Fiber owner.
588 - showOverlay(target, null, false);
589 + showOverlay([target], null, false);
590
591 this._selectFiberForNode(target);
592 };
src/backend/renderer.js
+43 -9
@@ -1258,24 +1258,58 @@ export function attach(
1258 currentRootID = -1;
1259 }
1260
1261 + function findAllCurrentHostFibers(parent: Fiber): $ReadOnlyArray<Fiber> {
1262 + const fibers = [];
1263 + const currentParent = findCurrentFiberUsingSlowPath(parent);
1264 + if (!currentParent) {
1265 + return fibers;
1266 + }
1267 +
1268 + // Next we'll drill down this component to find all HostComponent/Text.
1269 + let node: Fiber = currentParent;
1270 + while (true) {
1271 + if (node.tag === HostComponent || node.tag === HostText) {
1272 + fibers.push(node);
1273 + } else if (node.child) {
1274 + node.child.return = node;
1275 + node = node.child;
1276 + continue;
1277 + }
1278 + if (node === currentParent) {
1279 + return fibers;
1280 + }
1281 + while (!node.sibling) {
1282 + if (!node.return || node.return === currentParent) {
1283 + return fibers;
1284 + }
1285 + node = node.return;
1286 + }
1287 + node.sibling.return = node.return;
1288 + node = node.sibling;
1289 + }
1290 + // Flow needs the return here, but ESLint complains about it.
1291 + // eslint-disable-next-line no-unreachable
1292 + return fibers;
1293 + }
1294 +
1295 function findNativeByFiberID(id: number) {
1296 try {
1263 - const fiber = findCurrentFiberUsingSlowPath(idToFiberMap.get(id));
1297 + let fiber = findCurrentFiberUsingSlowPath(idToFiberMap.get(id));
1298 if (fiber === null) {
1299 return null;
1300 }
1301 + // Special case for a timed-out Suspense.
1302 const isTimedOutSuspense =
1303 fiber.tag === SuspenseComponent && fiber.memoizedState !== null;
1269 - if (!isTimedOutSuspense) {
1270 - // Normal case.
1271 - return renderer.findHostInstanceByFiber(fiber);
1272 - } else {
1304 + if (isTimedOutSuspense) {
1305 // A timed-out Suspense's findDOMNode is useless.
1306 // Try our best to find the fallback directly.
1307 const maybeFallbackFiber =
1308 (fiber.child && fiber.child.sibling) || fiber;
1277 - return renderer.findHostInstanceByFiber(maybeFallbackFiber);
1309 + fiber = maybeFallbackFiber;
1310 }
1311 + const hostFibers = findAllCurrentHostFibers(fiber);
1312 + return hostFibers.map(hostFiber => hostFiber.stateNode).filter(Boolean);
1313 } catch (err) {
1314 // The fiber might have unmounted by now.
1315 return null;
@@ -1715,9 +1749,9 @@ export function attach(
1749 if (result.hooks !== null) {
1750 console.log('Hooks:', result.hooks);
1751 }
1718 - const nativeNode = findNativeByFiberID(id);
1719 - if (nativeNode !== null) {
1720 - console.log('Node:', nativeNode);
1752 + const nativeNodes = findNativeByFiberID(id);
1753 + if (nativeNodes !== null) {
1754 + console.log('Nodes:', nativeNodes);
1755 }
1756 if (window.chrome || /firefox/i.test(navigator.userAgent)) {
1757 console.log(
src/backend/types.js
+1 -2
@@ -25,7 +25,6 @@ export type RendererID = number;
25 type Dispatcher = any;
26
27 export type ReactRenderer = {
28 - findHostInstanceByFiber: (fiber: Object) => ?NativeType,
28 findFiberByHostInstance: (hostInstance: NativeType) => ?Fiber,
29 version: string,
30 bundleType: BundleType,
@@ -103,7 +102,7 @@ export type PathMatch = {|
102
103 export type RendererInterface = {
104 cleanup: () => void,
106 - findNativeByFiberID: (id: number) => ?NativeType,
105 + findNativeByFiberID: (id: number) => ?Array<NativeType>,
106 flushInitialOperations: () => void,
107 getBestMatchForTrackedPath: () => PathMatch | null,
108 getCommitDetails: (rootID: number, commitIndex: number) => CommitDetails,
src/backend/views/Highlighter.js
+3 -3
@@ -17,7 +17,7 @@ export function hideOverlay() {
17 }
18
19 export function showOverlay(
20 - element: HTMLElement | null,
20 + elements: Array<HTMLElement> | null,
21 componentName: string | null,
22 hideAfterTimeout: boolean
23 ) {
@@ -25,7 +25,7 @@ export function showOverlay(
25 clearTimeout(timeoutID);
26 }
27
28 - if (element == null) {
28 + if (elements == null) {
29 return;
30 }
31
@@ -33,7 +33,7 @@ export function showOverlay(
33 overlay = new Overlay();
34 }
35
36 - overlay.inspect(element, componentName);
36 + overlay.inspect(elements, componentName);
37
38 if (hideAfterTimeout) {
39 timeoutID = setTimeout(hideOverlay, SHOW_DURATION);
src/backend/views/Overlay.js
+197 -81
@@ -11,30 +11,17 @@ type Rect = {
11 width: number,
12 };
13
14 -// Note that this component is not affected by the active Theme,
15 -// because it highlights elements in the main Chrome window (outside of devtools).
14 +// Note that the Overlay components are not affected by the active Theme,
15 +// because they highlight elements in the main Chrome window (outside of devtools).
16 // The colors below were chosen to roughly match those used by Chrome devtools.
17 -export default class Overlay {
18 - window: window;
19 - container: HTMLElement;
17 +
18 +class OverlayRect {
19 node: HTMLElement;
20 border: HTMLElement;
21 padding: HTMLElement;
22 content: HTMLElement;
24 - tip: HTMLElement;
25 - nameSpan: HTMLElement;
26 - dimSpan: HTMLElement;
27 -
28 - constructor() {
29 - // Find the root window, because overlays are positioned relative to it.
30 - let currentWindow = window;
31 - while (currentWindow !== currentWindow.parent) {
32 - currentWindow = currentWindow.parent;
33 - }
23
35 - const doc = currentWindow.document;
36 - this.window = currentWindow;
37 - this.container = doc.createElement('div');
24 + constructor(doc, container) {
25 this.node = doc.createElement('div');
26 this.border = doc.createElement('div');
27 this.padding = doc.createElement('div');
@@ -50,8 +37,59 @@ export default class Overlay {
37 position: 'fixed',
38 });
39
40 + this.node.style.zIndex = '10000000';
41 +
42 + this.node.appendChild(this.border);
43 + this.border.appendChild(this.padding);
44 + this.padding.appendChild(this.content);
45 + container.appendChild(this.node);
46 + }
47 +
48 + remove() {
49 + if (this.node.parentNode) {
50 + this.node.parentNode.removeChild(this.node);
51 + }
52 + }
53 +
54 + update(box, dims) {
55 + boxWrap(dims, 'margin', this.node);
56 + boxWrap(dims, 'border', this.border);
57 + boxWrap(dims, 'padding', this.padding);
58 +
59 + assign(this.content.style, {
60 + height:
61 + box.height -
62 + dims.borderTop -
63 + dims.borderBottom -
64 + dims.paddingTop -
65 + dims.paddingBottom +
66 + 'px',
67 + width:
68 + box.width -
69 + dims.borderLeft -
70 + dims.borderRight -
71 + dims.paddingLeft -
72 + dims.paddingRight +
73 + 'px',
74 + });
75 +
76 + assign(this.node.style, {
77 + top: box.top - dims.marginTop + 'px',
78 + left: box.left - dims.marginLeft + 'px',
79 + });
80 + }
81 +}
82 +
83 +class OverlayTip {
84 + tip: HTMLElement;
85 + nameSpan: HTMLElement;
86 + dimSpan: HTMLElement;
87 +
88 + constructor(doc, container) {
89 this.tip = doc.createElement('div');
90 assign(this.tip.style, {
91 + display: 'flex',
92 + flexFlow: 'row nowrap',
93 backgroundColor: '#333740',
94 borderRadius: '2px',
95 fontFamily:
@@ -61,6 +99,7 @@ export default class Overlay {
99 pointerEvents: 'none',
100 position: 'fixed',
101 fontSize: '12px',
102 + whiteSpace: 'nowrap',
103 });
104
105 this.nameSpan = doc.createElement('span');
@@ -77,80 +116,151 @@ export default class Overlay {
116 color: '#d7d7d7',
117 });
118
80 - this.container.style.zIndex = '10000000';
81 - this.node.style.zIndex = '10000000';
119 this.tip.style.zIndex = '10000000';
83 - this.container.appendChild(this.node);
84 - this.container.appendChild(this.tip);
85 - this.node.appendChild(this.border);
86 - this.border.appendChild(this.padding);
87 - this.padding.appendChild(this.content);
120 + container.appendChild(this.tip);
121 + }
122 +
123 + remove() {
124 + if (this.tip.parentNode) {
125 + this.tip.parentNode.removeChild(this.tip);
126 + }
127 + }
128 +
129 + updateText(name, width, height) {
130 + this.nameSpan.textContent = name;
131 + this.dimSpan.textContent =
132 + Math.round(width) + 'px × ' + Math.round(height) + 'px';
133 + }
134 +
135 + updatePosition(dims, bounds) {
136 + const tipRect = this.tip.getBoundingClientRect();
137 + const tipPos = findTipPos(dims, bounds, {
138 + width: tipRect.width,
139 + height: tipRect.height,
140 + });
141 + assign(this.tip.style, tipPos.style);
142 + }
143 +}
144 +
145 +export default class Overlay {
146 + window: window;
147 + tipBoundsWindow: window;
148 + container: HTMLElement;
149 + tip: OverlayTip;
150 + rects: Array<OverlayRect>;
151 +
152 + constructor() {
153 + // Find the root window, because overlays are positioned relative to it.
154 + let currentWindow = window;
155 + while (currentWindow !== currentWindow.parent) {
156 + currentWindow = currentWindow.parent;
157 + }
158 + this.window = currentWindow;
159 +
160 + // When opened in shells/dev, the tooltip should be bound by the app iframe, not by the topmost window.
161 + let tipBoundsWindow = window;
162 + while (
163 + tipBoundsWindow !== tipBoundsWindow.parent &&
164 + !tipBoundsWindow.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')
165 + ) {
166 + tipBoundsWindow = tipBoundsWindow.parent;
167 + }
168 + this.tipBoundsWindow = tipBoundsWindow;
169 +
170 + const doc = currentWindow.document;
171 + this.container = doc.createElement('div');
172 + this.container.style.zIndex = '10000000';
173 +
174 + this.tip = new OverlayTip(doc, this.container);
175 + this.rects = [];
176 +
177 doc.body.appendChild(this.container);
178 }
179
180 remove() {
181 + this.tip.remove();
182 + this.rects.forEach(rect => {
183 + rect.remove();
184 + });
185 + this.rects.length = 0;
186 if (this.container.parentNode) {
187 this.container.parentNode.removeChild(this.container);
188 }
189 }
190
97 - inspect(node: HTMLElement, name?: ?string) {
191 + inspect(nodes: Array<HTMLElement>, name?: ?string) {
192 // We can't get the size of text nodes or comment nodes. React as of v15
193 // heavily uses comment nodes to delimit text.
100 - if (node.nodeType !== Node.ELEMENT_NODE) {
194 + const elements = nodes.filter(node => node.nodeType === Node.ELEMENT_NODE);
195 +
196 + while (this.rects.length > elements.length) {
197 + const rect = this.rects.pop();
198 + rect.remove();
199 + }
200 + if (elements.length === 0) {
201 return;
202 }
103 - const box = getNestedBoundingClientRect(node, this.window);
104 - const dims = getElementDimensions(node);
203
106 - boxWrap(dims, 'margin', this.node);
107 - boxWrap(dims, 'border', this.border);
108 - boxWrap(dims, 'padding', this.padding);
109 -
110 - assign(this.content.style, {
111 - height:
112 - box.height -
113 - dims.borderTop -
114 - dims.borderBottom -
115 - dims.paddingTop -
116 - dims.paddingBottom +
117 - 'px',
118 - width:
119 - box.width -
120 - dims.borderLeft -
121 - dims.borderRight -
122 - dims.paddingLeft -
123 - dims.paddingRight +
124 - 'px',
125 - });
204 + while (this.rects.length < elements.length) {
205 + this.rects.push(new OverlayRect(this.window.document, this.container));
206 + }
207
127 - assign(this.node.style, {
128 - top: box.top - dims.marginTop + 'px',
129 - left: box.left - dims.marginLeft + 'px',
208 + const outerBox = {
209 + top: Number.POSITIVE_INFINITY,
210 + right: Number.NEGATIVE_INFINITY,
211 + bottom: Number.NEGATIVE_INFINITY,
212 + left: Number.POSITIVE_INFINITY,
213 + };
214 + elements.forEach((element, index) => {
215 + const box = getNestedBoundingClientRect(element, this.window);
216 + const dims = getElementDimensions(element);
217 +
218 + outerBox.top = Math.min(outerBox.top, box.top - dims.marginTop);
219 + outerBox.right = Math.max(
220 + outerBox.right,
221 + box.left + box.width + dims.marginRight
222 + );
223 + outerBox.bottom = Math.max(
224 + outerBox.bottom,
225 + box.top + box.height + dims.marginBottom
226 + );
227 + outerBox.left = Math.min(outerBox.left, box.left - dims.marginLeft);
228 +
229 + const rect = this.rects[index];
230 + rect.update(box, dims);
231 });
232
233 if (!name) {
133 - name = node.nodeName.toLowerCase();
134 - const ownerName = getOwnerDisplayName(node);
234 + name = elements[0].nodeName.toLowerCase();
235 + const ownerName = getOwnerDisplayName(elements[0]);
236 if (ownerName) {
237 name += ' (in ' + ownerName + ')';
238 }
239 }
240
140 - this.nameSpan.textContent = name;
141 - this.dimSpan.textContent =
142 - Math.round(box.width) + 'px × ' + Math.round(box.height) + 'px';
143 -
144 - const tipPos = findTipPos(
241 + this.tip.updateText(
242 + name,
243 + outerBox.right - outerBox.left,
244 + outerBox.bottom - outerBox.top
245 + );
246 + const tipBounds = getNestedBoundingClientRect(
247 + this.tipBoundsWindow.document.documentElement,
248 + this.window
249 + );
250 + this.tip.updatePosition(
251 {
146 - top: box.top - dims.marginTop,
147 - left: box.left - dims.marginLeft,
148 - height: box.height + dims.marginTop + dims.marginBottom,
149 - width: box.width + dims.marginLeft + dims.marginRight,
252 + top: outerBox.top,
253 + left: outerBox.left,
254 + height: outerBox.bottom - outerBox.top,
255 + width: outerBox.right - outerBox.left,
256 },
151 - this.window
257 + {
258 + top: tipBounds.top + this.tipBoundsWindow.scrollY,
259 + left: tipBounds.left + this.tipBoundsWindow.scrollX,
260 + height: this.tipBoundsWindow.innerHeight,
261 + width: this.tipBoundsWindow.innerWidth,
262 + }
263 );
153 - assign(this.tip.style, tipPos);
264 }
265 }
266
@@ -185,35 +295,41 @@ function getFiber(node) {
295 return null;
296 }
297
188 -function findTipPos(dims, win) {
189 - const tipHeight = 20;
298 +function findTipPos(dims, bounds, tipSize) {
299 + const tipHeight = Math.max(tipSize.height, 20);
300 + const tipWidth = Math.max(tipSize.width, 60);
301 const margin = 5;
302 +
303 let top;
192 - if (dims.top + dims.height + tipHeight <= win.innerHeight) {
193 - if (dims.top + dims.height < 0) {
194 - top = margin;
304 + if (dims.top + dims.height + tipHeight <= bounds.top + bounds.height) {
305 + if (dims.top + dims.height < bounds.top + 0) {
306 + top = bounds.top + margin;
307 } else {
308 top = dims.top + dims.height + margin;
309 }
198 - } else if (dims.top - tipHeight <= win.innerHeight) {
199 - if (dims.top - tipHeight - margin < margin) {
200 - top = margin;
310 + } else if (dims.top - tipHeight <= bounds.top + bounds.height) {
311 + if (dims.top - tipHeight - margin < bounds.top + margin) {
312 + top = bounds.top + margin;
313 } else {
314 top = dims.top - tipHeight - margin;
315 }
316 } else {
205 - top = win.innerHeight - tipHeight - margin;
317 + top = bounds.top + bounds.height - tipHeight - margin;
318 }
319
208 - top += 'px';
209 -
210 - if (dims.left < 0) {
211 - return { top, left: margin };
320 + let left = dims.left + margin;
321 + if (dims.left < bounds.left) {
322 + left = bounds.left + margin;
323 }
213 - if (dims.left + 200 > win.innerWidth) {
214 - return { top, right: margin };
324 + if (dims.left + tipWidth > bounds.left + bounds.width) {
325 + left = bounds.left + bounds.width - tipWidth - margin;
326 }
216 - return { top, left: dims.left + margin + 'px' };
327 +
328 + top += 'px';
329 + left += 'px';
330 + return {
331 + style: { top, left },
332 + };
333 }
334
335 export function getElementDimensions(domElement: Element) {