Repository: facebook/react
Pull request: [DevTools] More robust resize handling

Follow-up to https://github.com/facebook/react/pull/18046
Best reviewed without whitespace changes

Switches to Pointer Events for the resize handling in the Components tab. [Pointer events are supported in all evergreen browsers and iOS 13](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events#browser_compatibility). The main motivation is that this fixes a disorienting horizontal scroll when you resized.
It also fixes various other edge cases where you get stuck in resize mode even though you actually considered the interaction cancelled because pointers can actually escape the window without a pointerup.
With pointer capture we also get a consistent cursor even if you you quickly resize or move the cursor outside the window.

Before: 

https://github.com/user-attachments/assets/9af59aeb-20a7-4e6c-9b16-858ce4a69ea6

After:


https://github.com/user-attachments/assets/3b085a1a-64e6-40cf-a1d8-88f9224f077e

I verified all interactions in the Chrome extension as well.


I included drive-by fixes which are also scrolling related:
1. Disabled text selection of the tree
    You could only select the keys and entering text selection mode entered scroll mode which I found confusing
	Before:

    https://github.com/user-attachments/assets/78af4ed7-23fa-4f2a-85e2-62c0c3be0d0e


2. Prevent scroll+resize mode
   The resize bar overlapped with the Component tree scroll container. If you clicked right on the edge, you scrolled the tree while resized. I found that confusing and it's likely not intended. Otherwise we should fully overlap the bar and tree so that you don't have to aim at a 2px overlap.
	Before:
	
    https://github.com/user-attachments/assets/705765cb-2b0e-409a-8fe9-d5eed69df3a6


3. Minimum width of left column to avoid double scrollbars
    Before:
    <img width="1304" height="468" alt="CleanShot 2025-07-29 at 11 02 06@2x" src="https://github.com/user-attachments/assets/bc84af0f-c7d4-45aa-b525-64100e5da669" />
	After:


Changed files:
filename: .eslintrc.js
patch: @@ -608,6 +608,7 @@ module.exports = {
     symbol: 'readonly',
     SyntheticEvent: 'readonly',
     SyntheticMouseEvent: 'readonly',
+    SyntheticPointerEvent: 'readonly',
     Thenable: 'readonly',
     TimeoutID: 'readonly',
     WheelEventHandler: 'readonly',

filename: packages/react-devtools-shared/src/devtools/views/Components/Components.css
patch: @@ -16,7 +16,6 @@
 
 .TreeWrapper {
   flex: 0 0 var(--horizontal-resize-percentage);
-  overflow: auto;
 }
 
 .InspectedElementWrapper {
@@ -32,7 +31,14 @@
 
 .ResizeBar {
   position: absolute;
-  left: -2px;
+  /*
+   * moving the bar out of its bounding box might cause its hitbox to overlap
+   * with another scrollbar creating disorienting UX where you both resize and scroll
+   * at the same time.
+   * If you adjust this value, double check that starting resize right on this edge
+   * doesn't also cause scroll
+   */
+  left: 1px;
   width: 5px;
   height: 100%;
   cursor: ew-resize;
@@ -52,7 +58,7 @@
   }
 
   .ResizeBar {
-    top: -2px;
+    top: 1px;
     left: 0;
     width: 100%;
     height: 5px;

filename: packages/react-devtools-shared/src/devtools/views/Components/Components.js
patch: @@ -29,7 +29,6 @@ type Orientation = 'horizontal' | 'vertical';
 
 type ResizeActionType =
   | 'ACTION_SET_DID_MOUNT'
-  | 'ACTION_SET_IS_RESIZING'
   | 'ACTION_SET_HORIZONTAL_PERCENTAGE'
   | 'ACTION_SET_VERTICAL_PERCENTAGE';
 
@@ -40,7 +39,6 @@ type ResizeAction = {
 
 type ResizeState = {
   horizontalPercentage: number,
-  isResizing: boolean,
   verticalPercentage: number,
 };
 
@@ -81,82 +79,81 @@ function Components(_: {}) {
     return () => clearTimeout(timeoutID);
   }, [horizontalPercentage, verticalPercentage]);
 
-  const {isResizing} = state;
+  const onResizeStart = (event: SyntheticPointerEvent<HTMLElement>) => {
+    const element = event.currentTarget;
+    element.setPointerCapture(event.pointerId);
+  };
+
+  const onResizeEnd = (event: SyntheticPointerEvent<HTMLElement>) => {
+    const element = event.currentTarget;
+    element.releasePointerCapture(event.pointerId);
+  };
+
+  const onResize = (event: SyntheticPointerEvent<HTMLElement>) => {
+    const element = event.currentTarget;
+    const isResizing = element.hasPointerCapture(event.pointerId);
+    if (!isResizing) {
+      return;
+    }
 
-  const onResizeStart = () =>
-    dispatch({type: 'ACTION_SET_IS_RESIZING', payload: true});
+    const resizeElement = resizeElementRef.current;
+    const wrapperElement = wrapperElementRef.current;
 
-  let onResize;
-  let onResizeEnd;
-  if (isResizing) {
-    onResizeEnd = () =>
-      dispatch({type: 'ACTION_SET_IS_RESIZING', payload: false});
+    if (wrapperElement === null || resizeElement === null) {
+      return;
+    }
 
-    // $FlowFixMe[missing-local-annot]
-    onResize = event => {
-      const resizeElement = resizeElementRef.current;
-      const wrapperElement = wrapperElementRef.current;
+    event.preventDefault();
 
-      if (!isResizing || wrapperElement === null || resizeElement === null) {
-        return;
-      }
+    const orientation = getOrientation(wrapperElement);
 
-      event.preventDefault();
+    const {height, width, left, top} = wrapperElement.getBoundingClientRect();
 
-      const orientation = getOrientation(wrapperElement);
+    const currentMousePosition =
+      orientation === 'horizontal' ? event.clientX - left : event.clientY - top;
 
-      const {height, width, left, top} = wrapperElement.getBoundingClientRect();
+    const boundaryMin = MINIMUM_SIZE;
+    const boundaryMax =
+      orientation === 'horizontal'
+        ? width - MINIMUM_SIZE
+        : height - MINIMUM_SIZE;
 
-      const currentMousePosition =
-        orientation === 'horizontal'
-          ? event.clientX - left
-          : event.clientY - top;
+    const isMousePositionInBounds =
+      currentMousePosition > boundaryMin && currentMousePosition < boundaryMax;
 
-      const boundaryMin = MINIMUM_SIZE;
-      const boundaryMax =
+    if (isMousePositionInBounds) {
+      const resizedElementDimension =
+        orientation === 'horizontal' ? width : height;
+      const actionType =
         orientation === 'horizontal'
-          ? width - MINIMUM_SIZE
-          : height - MINIMUM_SIZE;
-
-      const isMousePositionInBounds =
-        currentMousePosition > boundaryMin &&
-        currentMousePosition < boundaryMax;
-
-      if (isMousePositionInBounds) {
-        const resizedElementDimension =
-          orientation === 'horizontal' ? width : height;
-        const actionType =
-          orientation === 'horizontal'
-            ? 'ACTION_SET_HORIZONTAL_PERCENTAGE'
-            : 'ACTION_SET_VERTICAL_PERCENTAGE';
-        const percentage =
-          (currentMousePosition / resizedElementDimension) * 100;
-
-        setResizeCSSVariable(resizeElement, orientation, percentage);
-
-        dispatch({
-          type: actionType,
-          payload: currentMousePosition / resizedElementDimension,
-        });
-      }
-    };
-  }
+          ? 'ACTION_SET_HORIZONTAL_PERCENTAGE'
+          : 'ACTION_SET_VERTICAL_PERCENTAGE';
+      const percentage = (currentMousePosition / resizedElementDimension) * 100;
+
+      setResizeCSSVariable(resizeElement, orientation, percentage);
+
+      dispatch({
+        type: actionType,
+        payload: currentMousePosition / resizedElementDimension,
+      });
+    }
+  };
 
   return (
     <SettingsModalContextController>
       <OwnersListContextController>
-        <div
-          ref={wrapperElementRef}
-          className={styles.Components}
-          onMouseMove={onResize}
-          onMouseLeave={onResizeEnd}
-          onMouseUp={onResizeEnd}>
+        <div ref={wrapperElementRef} className={styles.Components}>
           <Fragment>
             <div ref={resizeElementRef} className={styles.TreeWrapper}>
               <Tree />
             </div>
             <div className={styles.ResizeBarWrapper}>
-              <div onMouseDown={onResizeStart} className={styles.ResizeBar} />
+              <div
+                onPointerDown={onResizeStart}
+                onPointerMove={onResize}
+                onPointerUp={onResizeEnd}
+                className={styles.ResizeBar}
+              />
             </div>
             <div className={styles.InspectedElementWrapper}>
               <NativeStyleContextController>
@@ -193,18 +190,12 @@ function initResizeState(): ResizeState {
 
   return {
     horizontalPercentage,
-    isResizing: false,
     verticalPercentage,
   };
 }
 
 function resizeReducer(state: ResizeState, action: ResizeAction): ResizeState {
   switch (action.type) {
-    case 'ACTION_SET_IS_RESIZING':
-      return {
-        ...state,
-        isResizing: action.payload,
-      };
     case 'ACTION_SET_HORIZONTAL_PERCENTAGE':
       return {
         ...state,

filename: packages/react-devtools-shared/src/devtools/views/Components/Tree.css
patch: @@ -38,6 +38,7 @@
   font-family: var(--font-family-monospace);
   font-size: var(--font-size-monospace-normal);
   line-height: var(--line-height-data);
+  user-select: none;
 }
 
 .VRule {