From 605af60c2437990cdc488d3880c4f1b2b3b0b58c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 31 Aug 2026 17:37:17 +0200 Subject: [PATCH] fix: keep grab controls out of the selection owner's subtree The controls moved inside the owner view so they would render in the resolved owner's window. On iOS that puts `FullWindowOverlay` - a full-screen native node with no `pointerEvents` prop - into the subtree that `findNodeAtPoint` walks, and since it skips a node only when `pointerEvents` makes it untargetable, the overlay swallowed every hit test: each grab resolved to the owner itself, so nothing but the root, screen or surface could be selected. Render the controls as a sibling of the registered view instead. They still mount inside the owner component, so a natively presented surface keeps its bar in its own window, but the owner's shadow subtree stays clean. --- .changeset/tidy-hoops-fold.md | 5 +++++ src/react-native/grab-controls.tsx | 7 ++++--- src/react-native/grab-selection-owner.tsx | 19 +++++++++++++++---- 3 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 .changeset/tidy-hoops-fold.md diff --git a/.changeset/tidy-hoops-fold.md b/.changeset/tidy-hoops-fold.md new file mode 100644 index 0000000..4bac3b1 --- /dev/null +++ b/.changeset/tidy-hoops-fold.md @@ -0,0 +1,5 @@ +--- +"react-native-grab": patch +--- + +Keep the grab controls out of the selection owner's shadow subtree, so elements can be grabbed again on iOS. The controls moved inside the owner view when they started rendering in the resolved owner's window, and on iOS they are hosted by `FullWindowOverlay` - a full-screen native node that takes no `pointerEvents` prop. `findNodeAtPoint` walks the owner's subtree and skips a node only when its `pointerEvents` makes it untargetable, so that overlay swallowed every hit test and resolved each grab to the owner itself. The controls are now a sibling of the registered view, which keeps them in the owner's window without shadowing its content. diff --git a/src/react-native/grab-controls.tsx b/src/react-native/grab-controls.tsx index 75c19c1..49fcf8f 100644 --- a/src/react-native/grab-controls.tsx +++ b/src/react-native/grab-controls.tsx @@ -134,9 +134,10 @@ export const GrabOwnerControls = () => { ); return ( - // The anchor is absolutely positioned so that mounting the controls inside an - // owner cannot disturb its layout: an owner is free to be a `gap`-spaced flex - // container, and absolutely positioned children are not flex items. + // The anchor is absolutely positioned so that mounting the controls alongside + // an owner cannot disturb the surrounding layout: that parent is free to be a + // `gap`-spaced flex container, and absolutely positioned children are not flex + // items. {/* Measured rather than the anchor, because `FullScreenOverlay` lifts this diff --git a/src/react-native/grab-selection-owner.tsx b/src/react-native/grab-selection-owner.tsx index 833f1b7..5f52113 100644 --- a/src/react-native/grab-selection-owner.tsx +++ b/src/react-native/grab-selection-owner.tsx @@ -53,10 +53,21 @@ export const GrabSelectionOwnerView = ({ const isResolvedSelectionOwner = useIsResolvedGrabSelectionOwner(ownerId); return ( - - {children} - + <> + + {children} + + + + {/* + A sibling of the registered view rather than a child of it: `findNodeAtPoint` + walks the owner's shadow subtree and skips a node only when its `pointerEvents` + makes it untargetable. The controls cannot rely on that - on iOS they are hosted + by `FullWindowOverlay`, a full-screen native node that takes no `pointerEvents` + prop - so a controls subtree inside the owner swallows every hit test and + resolves each grab to the owner itself. + */} {isResolvedSelectionOwner && } - + ); };