main
js 501 lines 15 KB
Raw
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 Agent from 'react-devtools-shared/src/backend/agent';
11 import {hideOverlay, showOverlay} from './Highlighter';
12 import {isReactNativeEnvironment} from 'react-devtools-shared/src/backend/utils';
13
14 import type {HostInstance} from 'react-devtools-shared/src/backend/types';
15 import type {BackendBridge} from 'react-devtools-shared/src/bridge';
16 import type {RendererInterface} from '../../types';
17
18 // This plug-in provides in-page highlighting of the selected element.
19 // It is used by the browser extension and the standalone DevTools shell (when connected to a browser).
20 // It is not currently the mechanism used to highlight React Native views.
21 // That is done by the React Native Inspector component.
22
23 let iframesListeningTo: Set<HTMLIFrameElement> = new Set();
24 let inspectOnlySuspenseNodes = false;
25
26 export default function setupHighlighter(
27 bridge: BackendBridge,
28 agent: Agent,
29 ): void {
30 bridge.addListener('clearHostInstanceHighlight', clearHostInstanceHighlight);
31 bridge.addListener('highlightHostInstance', highlightHostInstance);
32 bridge.addListener('highlightHostInstances', highlightHostInstances);
33 bridge.addListener('scrollToHostInstance', scrollToHostInstance);
34 bridge.addListener('shutdown', stopInspectingHost);
35 bridge.addListener('startInspectingHost', startInspectingHost);
36 bridge.addListener('stopInspectingHost', stopInspectingHost);
37 bridge.addListener('scrollTo', scrollDocumentTo);
38 bridge.addListener('requestScrollPosition', sendScroll);
39
40 let applyingScroll = false;
41
42 function scrollDocumentTo({
43 left,
44 top,
45 right,
46 bottom,
47 }: {
48 left: number,
49 top: number,
50 right: number,
51 bottom: number,
52 }) {
53 if (isReactNativeEnvironment()) {
54 // Not implemented.
55 return;
56 }
57
58 if (
59 left === Math.round(window.scrollX) &&
60 top === Math.round(window.scrollY)
61 ) {
62 return;
63 }
64 applyingScroll = true;
65 window.scrollTo({
66 top: top,
67 left: left,
68 behavior: 'smooth',
69 });
70 }
71
72 let scrollTimer = null;
73 function sendScroll() {
74 if (isReactNativeEnvironment()) {
75 // Not implemented.
76 return;
77 }
78
79 if (scrollTimer) {
80 clearTimeout(scrollTimer);
81 scrollTimer = null;
82 }
83 if (applyingScroll) {
84 return;
85 }
86 const left = window.scrollX;
87 const top = window.scrollY;
88 const right = left + window.innerWidth;
89 const bottom = top + window.innerHeight;
90 bridge.send('scrollTo', {left, top, right, bottom});
91 }
92
93 function scrollEnd() {
94 // Upon scrollend send it immediately.
95 sendScroll();
96 applyingScroll = false;
97 }
98
99 if (
100 typeof document === 'object' &&
101 // $FlowFixMe[method-unbinding]
102 typeof document.addEventListener === 'function'
103 ) {
104 document.addEventListener('scroll', () => {
105 if (!scrollTimer) {
106 // Periodically synchronize the scroll while scrolling.
107 scrollTimer = setTimeout(sendScroll, 400);
108 }
109 });
110
111 document.addEventListener('scrollend', scrollEnd);
112 }
113
114 function startInspectingHost(onlySuspenseNodes: boolean) {
115 inspectOnlySuspenseNodes = onlySuspenseNodes;
116 registerListenersOnWindow(window);
117 }
118
119 function registerListenersOnWindow(window: any) {
120 // This plug-in may run in non-DOM environments (e.g. React Native).
121 if (window && typeof window.addEventListener === 'function') {
122 window.addEventListener('click', onClick, true);
123 window.addEventListener('mousedown', onMouseEvent, true);
124 window.addEventListener('mouseover', onMouseEvent, true);
125 window.addEventListener('mouseup', onMouseEvent, true);
126 window.addEventListener('pointerdown', onPointerDown, true);
127 window.addEventListener('pointermove', onPointerMove, true);
128 window.addEventListener('pointerup', onPointerUp, true);
129 } else {
130 agent.emit('startInspectingNative');
131 }
132 }
133
134 function stopInspectingHost() {
135 hideOverlay(agent);
136 removeListenersOnWindow(window);
137 iframesListeningTo.forEach(function (frame) {
138 try {
139 removeListenersOnWindow(frame.contentWindow);
140 } catch (error) {
141 // This can error when the iframe is on a cross-origin.
142 }
143 });
144 iframesListeningTo = new Set();
145 }
146
147 function removeListenersOnWindow(window: any) {
148 // This plug-in may run in non-DOM environments (e.g. React Native).
149 if (window && typeof window.removeEventListener === 'function') {
150 window.removeEventListener('click', onClick, true);
151 window.removeEventListener('mousedown', onMouseEvent, true);
152 window.removeEventListener('mouseover', onMouseEvent, true);
153 window.removeEventListener('mouseup', onMouseEvent, true);
154 window.removeEventListener('pointerdown', onPointerDown, true);
155 window.removeEventListener('pointermove', onPointerMove, true);
156 window.removeEventListener('pointerup', onPointerUp, true);
157 } else {
158 agent.emit('stopInspectingNative');
159 }
160 }
161
162 function clearHostInstanceHighlight() {
163 hideOverlay(agent);
164 }
165
166 function highlightHostInstance({
167 displayName,
168 hideAfterTimeout,
169 id,
170 openBuiltinElementsPanel,
171 rendererID,
172 scrollIntoView,
173 }: {
174 displayName: string | null,
175 hideAfterTimeout: boolean,
176 id: number,
177 openBuiltinElementsPanel: boolean,
178 rendererID: number,
179 scrollIntoView: boolean,
180 ...
181 }) {
182 const renderer = agent.rendererInterfaces[rendererID];
183 if (renderer == null) {
184 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
185
186 hideOverlay(agent);
187 return;
188 }
189
190 // In some cases fiber may already be unmounted
191 if (!renderer.hasElementWithId(id)) {
192 hideOverlay(agent);
193 return;
194 }
195
196 const nodes = renderer.findHostInstancesForElementID(id);
197 if (nodes != null) {
198 for (let i = 0; i < nodes.length; i++) {
199 const node = nodes[i];
200 if (node === null) {
201 continue;
202 }
203 const nodeRects =
204 // $FlowFixMe[method-unbinding]
205 typeof node.getClientRects === 'function'
206 ? node.getClientRects()
207 : [];
208 if (
209 typeof node.getClientRects === 'undefined' || // If Host doesn't implement getClientRects, try to show the overlay.
210 (nodeRects.length > 0 && // If this is currently display: none, then try another node.
211 (nodeRects.length > 2 || // This can happen when one of the host instances is a hoistable.
212 nodeRects[0].width > 0 ||
213 nodeRects[0].height > 0))
214 ) {
215 // $FlowFixMe[method-unbinding]
216 if (scrollIntoView && typeof node.scrollIntoView === 'function') {
217 if (scrollDelayTimer) {
218 clearTimeout(scrollDelayTimer);
219 scrollDelayTimer = null;
220 }
221 // If the node isn't visible show it before highlighting it.
222 // We may want to reconsider this; it might be a little disruptive.
223 node.scrollIntoView({block: 'nearest', inline: 'nearest'});
224 }
225
226 showOverlay(nodes, displayName, agent, hideAfterTimeout);
227
228 if (openBuiltinElementsPanel) {
229 window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = node;
230 bridge.send('syncSelectionToBuiltinElementsPanel');
231 }
232 return;
233 }
234 }
235 }
236
237 hideOverlay(agent);
238 }
239
240 function highlightHostInstances({
241 displayName,
242 hideAfterTimeout,
243 elements,
244 scrollIntoView,
245 }: {
246 displayName: string | null,
247 hideAfterTimeout: boolean,
248 elements: Array<{rendererID: number, id: number}>,
249 scrollIntoView: boolean,
250 }) {
251 const nodes: Array<HostInstance> = [];
252 for (let i = 0; i < elements.length; i++) {
253 const {id, rendererID} = elements[i];
254 const renderer = agent.rendererInterfaces[rendererID];
255 if (renderer == null) {
256 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
257 continue;
258 }
259
260 // In some cases fiber may already be unmounted
261 if (!renderer.hasElementWithId(id)) {
262 continue;
263 }
264
265 const hostInstances = renderer.findHostInstancesForElementID(id);
266 if (hostInstances !== null) {
267 for (let j = 0; j < hostInstances.length; j++) {
268 nodes.push(hostInstances[j]);
269 }
270 }
271 }
272
273 if (nodes.length > 0) {
274 const node = nodes[0];
275 // $FlowFixMe[method-unbinding]
276 if (scrollIntoView && typeof node.scrollIntoView === 'function') {
277 // If the node isn't visible show it before highlighting it.
278 // We may want to reconsider this; it might be a little disruptive.
279 node.scrollIntoView({block: 'nearest', inline: 'nearest'});
280 }
281 }
282
283 showOverlay(nodes, displayName, agent, hideAfterTimeout);
284 }
285
286 function attemptScrollToHostInstance(
287 renderer: RendererInterface,
288 id: number,
289 ) {
290 const nodes = renderer.findHostInstancesForElementID(id);
291 if (nodes != null) {
292 for (let i = 0; i < nodes.length; i++) {
293 const node = nodes[i];
294 if (node === null) {
295 continue;
296 }
297 const nodeRects =
298 // $FlowFixMe[method-unbinding]
299 typeof node.getClientRects === 'function'
300 ? node.getClientRects()
301 : [];
302 // If this is currently display: none, then try another node.
303 // This can happen when one of the host instances is a hoistable.
304 if (
305 nodeRects.length > 0 &&
306 (nodeRects.length > 2 ||
307 nodeRects[0].width > 0 ||
308 nodeRects[0].height > 0)
309 ) {
310 // $FlowFixMe[method-unbinding]
311 if (typeof node.scrollIntoView === 'function') {
312 node.scrollIntoView({
313 block: 'nearest',
314 inline: 'nearest',
315 behavior: 'smooth',
316 });
317 return true;
318 }
319 }
320 }
321 }
322 return false;
323 }
324
325 let scrollDelayTimer = null;
326 function scrollToHostInstance({
327 id,
328 rendererID,
329 }: {
330 id: number,
331 rendererID: number,
332 }) {
333 // Always hide the existing overlay so it doesn't obscure the element.
334 // If you wanted to show the overlay, highlightHostInstance should be used instead
335 // with the scrollIntoView option.
336 hideOverlay(agent);
337
338 if (isReactNativeEnvironment()) {
339 // Not implemented.
340 return;
341 }
342
343 if (scrollDelayTimer) {
344 clearTimeout(scrollDelayTimer);
345 scrollDelayTimer = null;
346 }
347
348 const renderer = agent.rendererInterfaces[rendererID];
349 if (renderer == null) {
350 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
351 return;
352 }
353
354 // In some cases fiber may already be unmounted
355 if (!renderer.hasElementWithId(id)) {
356 return;
357 }
358
359 if (attemptScrollToHostInstance(renderer, id)) {
360 return;
361 }
362
363 // It's possible that the current state of a Suspense boundary doesn't have a position
364 // in the tree. E.g. because it's not yet mounted in the state we're moving to.
365 // Such as if it's in a null tree or inside another boundary's hidden state.
366 // In this case we use the last known position and try to scroll to that.
367 const rects = renderer.findLastKnownRectsForID(id);
368 if (rects !== null && rects.length > 0) {
369 let x = Infinity;
370 let y = Infinity;
371 for (let i = 0; i < rects.length; i++) {
372 const rect = rects[i];
373 if (rect.x < x) {
374 x = rect.x;
375 }
376 if (rect.y < y) {
377 y = rect.y;
378 }
379 }
380 const element = document.documentElement;
381 if (!element) {
382 return;
383 }
384 // Check if the target corner is already in the viewport.
385 if (
386 x < window.scrollX ||
387 y < window.scrollY ||
388 x > window.scrollX + element.clientWidth ||
389 y > window.scrollY + element.clientHeight
390 ) {
391 window.scrollTo({
392 top: y,
393 left: x,
394 behavior: 'smooth',
395 });
396 }
397 // It's possible that after mount, we're able to scroll deeper once the new nodes
398 // have mounted. Let's try again after mount. Ideally we'd know which commit this
399 // is going to be but for now we just try after 100ms.
400 scrollDelayTimer = setTimeout(() => {
401 attemptScrollToHostInstance(renderer, id);
402 }, 100);
403 }
404 }
405
406 function onClick(event: MouseEvent) {
407 event.preventDefault();
408 event.stopPropagation();
409
410 stopInspectingHost();
411
412 bridge.send('stopInspectingHost', true);
413 }
414
415 function onMouseEvent(event: MouseEvent) {
416 event.preventDefault();
417 event.stopPropagation();
418 }
419
420 function onPointerDown(event: MouseEvent) {
421 event.preventDefault();
422 event.stopPropagation();
423
424 selectElementForNode(getEventTarget(event));
425 }
426
427 let lastHoveredNode: HTMLElement | null = null;
428 function onPointerMove(event: MouseEvent) {
429 event.preventDefault();
430 event.stopPropagation();
431
432 const target: HTMLElement = getEventTarget(event);
433 if (lastHoveredNode === target) return;
434 lastHoveredNode = target;
435
436 if (target.tagName === 'IFRAME') {
437 const iframe: HTMLIFrameElement = target as any;
438 try {
439 if (!iframesListeningTo.has(iframe)) {
440 const window = iframe.contentWindow;
441 registerListenersOnWindow(window);
442 iframesListeningTo.add(iframe);
443 }
444 } catch (error) {
445 // This can error when the iframe is on a cross-origin.
446 }
447 }
448
449 if (inspectOnlySuspenseNodes) {
450 // For Suspense nodes we want to highlight not the actual target but the nodes
451 // that are the root of the Suspense node.
452 // TODO: Consider if we should just do the same for other elements because the
453 // hovered node might just be one child of many in the Component.
454 const match = agent.getIDForHostInstance(
455 target,
456 inspectOnlySuspenseNodes,
457 );
458 if (match !== null) {
459 const renderer = agent.rendererInterfaces[match.rendererID];
460 if (renderer == null) {
461 console.warn(
462 `Invalid renderer id "${match.rendererID}" for element "${match.id}"`,
463 );
464 return;
465 }
466 highlightHostInstance({
467 displayName: renderer.getDisplayNameForElementID(match.id),
468 hideAfterTimeout: false,
469 id: match.id,
470 openBuiltinElementsPanel: false,
471 rendererID: match.rendererID,
472 scrollIntoView: false,
473 });
474 }
475 } else {
476 // Don't pass the name explicitly.
477 // It will be inferred from DOM tag and Fiber owner.
478 showOverlay([target], null, agent, false);
479 }
480 }
481
482 function onPointerUp(event: MouseEvent) {
483 event.preventDefault();
484 event.stopPropagation();
485 }
486
487 const selectElementForNode = (node: HTMLElement) => {
488 const match = agent.getIDForHostInstance(node, inspectOnlySuspenseNodes);
489 if (match !== null) {
490 bridge.send('selectElement', match.id);
491 }
492 };
493
494 function getEventTarget(event: MouseEvent): HTMLElement {
495 if (event.composed) {
496 return event.composedPath()[0] as any;
497 }
498
499 return event.target as any;
500 }
501 }