From 9ae05485e3e442246968be5de07980225b7a3dd1 Mon Sep 17 00:00:00 2001 From: huymobile Date: Sun, 30 Aug 2026 08:44:27 +0700 Subject: [PATCH 1/5] fix: support native-presented grab surfaces --- .changeset/quiet-pandas-grab.md | 5 ++ README.md | 21 +++++- .../__tests__/selection-owners.test.ts | 66 +++++++++++++++++++ src/react-native/containers.ts | 48 ++++++++++++-- src/react-native/grab-root.tsx | 36 ++-------- src/react-native/grab-screen.tsx | 48 ++++---------- src/react-native/grab-selection-owner.tsx | 57 ++++++++++++++++ src/react-native/grab-surface.tsx | 27 ++++++++ src/react-native/grab-surface.web.tsx | 3 + src/react-native/index.ts | 6 ++ 10 files changed, 241 insertions(+), 76 deletions(-) create mode 100644 .changeset/quiet-pandas-grab.md create mode 100644 src/react-native/__tests__/selection-owners.test.ts create mode 100644 src/react-native/grab-selection-owner.tsx create mode 100644 src/react-native/grab-surface.tsx create mode 100644 src/react-native/grab-surface.web.tsx diff --git a/.changeset/quiet-pandas-grab.md b/.changeset/quiet-pandas-grab.md new file mode 100644 index 0000000..f7f9bcc --- /dev/null +++ b/.changeset/quiet-pandas-grab.md @@ -0,0 +1,5 @@ +--- +"react-native-grab": patch +--- + +Add `ReactNativeGrabSurface` for selecting React Native content hosted in separately presented native surfaces. diff --git a/README.md b/README.md index a1510af..645c988 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ npm install react-native-grab 1. Add React Native Grab middleware to Metro. 2. Wrap your app root with `ReactNativeGrabRoot`. 3. **If your app uses native navigators** (e.g. native stack, native tabs), **wrap each screen** with `ReactNativeGrabScreen`. -4. Open Dev Menu and choose `React Native Grab` to start selecting elements. +4. Wrap content hosted in a separately presented native sheet or modal with `ReactNativeGrabSurface` and set `active` only while it is presented. +5. Open Dev Menu and choose `React Native Grab` to start selecting elements. ## Quick Configuration Example @@ -72,6 +73,7 @@ export default function AppLayout() { - `ReactNativeGrabRoot`: Root-level provider for grab functionality. - `ReactNativeGrabScreen`: When using native navigators (native stack, native tabs), wrap **each screen** with this component for accurate selection. +- `ReactNativeGrabSurface`: Wraps content hosted in a separately presented native surface. An active surface takes selection priority over the focused screen; when it becomes inactive, selection falls back to the focused screen or root. This component is a no-op in production builds. - `ReactNativeGrabContextProvider`: Adds custom metadata to grabbed elements. Nested providers are shallow-merged and child keys override parent keys. This provider is a no-op in production builds. - `enableGrabbing()`: Programmatically enables grabbing flow. - `setFocusEffect(impl)`: Overrides the hook used by `ReactNativeGrabScreen` to detect when a screen is focused. By default the library auto-detects `useFocusEffect` from `expo-router` or `@react-navigation/native`. Call `setFocusEffect` once at app startup when neither package is present (e.g. a custom router) or when you want explicit control over which implementation is used. @@ -83,6 +85,23 @@ import { useFocusEffect } from "my-custom-router"; setFocusEffect(useFocusEffect); ``` +For a native sheet or modal, place `ReactNativeGrabSurface` inside the presented content and keep `active` synchronized with its presentation lifecycle: + +```tsx +import { Modal } from "react-native"; +import { ReactNativeGrabSurface } from "react-native-grab"; + +function DetailsModal({ visible }: { visible: boolean }) { + return ( + + + {/* modal or sheet content */} + + + ); +} +``` + When grab context is available for a selected element, copied output includes an additional `Context:` JSON block appended after the existing element preview and stack trace lines. ## Documentation diff --git a/src/react-native/__tests__/selection-owners.test.ts b/src/react-native/__tests__/selection-owners.test.ts new file mode 100644 index 0000000..3cf9a4a --- /dev/null +++ b/src/react-native/__tests__/selection-owners.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ReactNativeElement } from "react-native"; + +vi.mock("react-native", () => ({ + findNodeHandle: (ref: { nativeTag: number }) => ref.nativeTag, +})); + +vi.mock("../fabric", () => ({ + getFabricUIManager: () => ({ + findShadowNodeByTag_DEPRECATED: (nativeTag: number) => ({ nativeTag }), + }), +})); + +import { + clearGrabSelectionOwnerFocus, + getResolvedGrabSelectionOwnerId, + registerGrabSelectionOwner, + setGrabSelectionOwnerActive, + setGrabSelectionOwnerFocused, + unregisterGrabSelectionOwner, +} from "../containers"; + +const registeredOwnerIds: string[] = []; + +const registerOwner = (id: string, kind: "root" | "screen" | "surface", nativeTag: number) => { + registeredOwnerIds.push(id); + registerGrabSelectionOwner(id, kind, { nativeTag } as unknown as ReactNativeElement); +}; + +afterEach(() => { + for (const id of registeredOwnerIds.splice(0)) { + unregisterGrabSelectionOwner(id); + } +}); + +describe("grab selection owner resolution", () => { + it("prefers the most recently activated surface and restores previous owners", () => { + registerOwner("root", "root", 1); + registerOwner("screen", "screen", 2); + setGrabSelectionOwnerFocused("screen", true); + registerOwner("first-sheet", "surface", 3); + registerOwner("second-sheet", "surface", 4); + expect(getResolvedGrabSelectionOwnerId()).toBe("screen"); + + setGrabSelectionOwnerActive("first-sheet", true); + expect(getResolvedGrabSelectionOwnerId()).toBe("first-sheet"); + + setGrabSelectionOwnerActive("second-sheet", true); + expect(getResolvedGrabSelectionOwnerId()).toBe("second-sheet"); + + setGrabSelectionOwnerActive("second-sheet", false); + expect(getResolvedGrabSelectionOwnerId()).toBe("first-sheet"); + + setGrabSelectionOwnerActive("second-sheet", true); + expect(getResolvedGrabSelectionOwnerId()).toBe("second-sheet"); + + unregisterGrabSelectionOwner("second-sheet"); + expect(getResolvedGrabSelectionOwnerId()).toBe("first-sheet"); + + setGrabSelectionOwnerActive("first-sheet", false); + expect(getResolvedGrabSelectionOwnerId()).toBe("screen"); + + clearGrabSelectionOwnerFocus("screen"); + expect(getResolvedGrabSelectionOwnerId()).toBe("root"); + }); +}); diff --git a/src/react-native/containers.ts b/src/react-native/containers.ts index a2e1b64..612232f 100644 --- a/src/react-native/containers.ts +++ b/src/react-native/containers.ts @@ -3,13 +3,14 @@ import { findNodeHandle, type ReactNativeElement } from "react-native"; import type { ReactNativeShadowNode } from "./types"; import { getFabricUIManager } from "./fabric"; -export type GrabSelectionOwnerKind = "root" | "screen"; +export type GrabSelectionOwnerKind = "root" | "screen" | "surface"; export type GrabSelectionOwner = { id: string; kind: GrabSelectionOwnerKind; shadowNode: ReactNativeShadowNode; registrationOrder: number; + activationOrder: number | null; }; type SelectionOwnersStoreSnapshot = { @@ -19,6 +20,7 @@ type SelectionOwnersStoreSnapshot = { let ownerIdCounter = 0; let registrationOrder = 0; +let activationOrder = 0; let focusedScreenOwnerId: string | null = null; const owners = new Map(); const listeners = new Set<() => void>(); @@ -52,12 +54,26 @@ const getOwnerShadowNode = (ref: ReactNativeElement, errorMessage: string) => { return getFabricUIManager().findShadowNodeByTag_DEPRECATED(nativeTag); }; +const ownerNativeTagErrorMessages: Record = { + root: "Failed to find native tag for app root", + screen: "Failed to find native tag for screen", + surface: "Failed to find native tag for native surface", +}; + const getFallbackRootOwner = () => { const rootOwners = Array.from(owners.values()).filter((owner) => owner.kind === "root"); rootOwners.sort((left, right) => right.registrationOrder - left.registrationOrder); return rootOwners[0] ?? null; }; +const getActiveSurfaceOwner = () => { + const surfaceOwners = Array.from(owners.values()).filter( + (owner) => owner.kind === "surface" && owner.activationOrder !== null, + ); + surfaceOwners.sort((left, right) => (right.activationOrder ?? 0) - (left.activationOrder ?? 0)); + return surfaceOwners[0] ?? null; +}; + export const createGrabSelectionOwnerId = (kind: GrabSelectionOwnerKind) => { ownerIdCounter += 1; return `react-native-grab-${kind}-${ownerIdCounter}`; @@ -68,12 +84,7 @@ export const registerGrabSelectionOwner = ( kind: GrabSelectionOwnerKind, ref: ReactNativeElement, ) => { - const shadowNode = getOwnerShadowNode( - ref, - kind === "root" - ? "Failed to find native tag for app root" - : "Failed to find native tag for screen", - ); + const shadowNode = getOwnerShadowNode(ref, ownerNativeTagErrorMessages[kind]); registrationOrder += 1; owners.set(id, { @@ -81,6 +92,7 @@ export const registerGrabSelectionOwner = ( kind, shadowNode, registrationOrder, + activationOrder: null, }); notify(); }; @@ -124,11 +136,33 @@ export const clearGrabSelectionOwnerFocus = (id: string) => { notify(); }; +export const setGrabSelectionOwnerActive = (id: string, isActive: boolean) => { + const owner = owners.get(id); + if (!owner || owner.kind !== "surface" || (owner.activationOrder !== null) === isActive) { + return; + } + + if (isActive) { + activationOrder += 1; + } + + owners.set(id, { + ...owner, + activationOrder: isActive ? activationOrder : null, + }); + notify(); +}; + export const getGrabSelectionOwner = (id: string): GrabSelectionOwner | null => { return owners.get(id) ?? null; }; export const getResolvedGrabSelectionOwner = (): GrabSelectionOwner | null => { + const activeSurfaceOwner = getActiveSurfaceOwner(); + if (activeSurfaceOwner) { + return activeSurfaceOwner; + } + if (focusedScreenOwnerId) { const focusedOwner = owners.get(focusedScreenOwnerId); if (focusedOwner) { diff --git a/src/react-native/grab-root.tsx b/src/react-native/grab-root.tsx index 2342a58..7f04ca6 100644 --- a/src/react-native/grab-root.tsx +++ b/src/react-native/grab-root.tsx @@ -1,43 +1,17 @@ -import { useEffect, useRef, useState } from "react"; -import { View, ViewProps, type GestureResponderHandlers } from "react-native"; -import { - createGrabSelectionOwnerId, - registerGrabSelectionOwner, - unregisterGrabSelectionOwner, -} from "./containers"; -import { ReactNativeGrabOverlay } from "./grab-overlay"; +import type { ViewProps } from "react-native"; import { ReactNativeGrabRootControls } from "./grab-root-controls"; +import { GrabSelectionOwnerView, useGrabSelectionOwner } from "./grab-selection-owner"; export type ReactNativeGrabRootProps = ViewProps; export const ReactNativeGrabRoot = ({ children, style, ...props }: ReactNativeGrabRootProps) => { - const rootRef = useRef(null); - const ownerIdRef = useRef(createGrabSelectionOwnerId("root")); - const [panHandlers, setPanHandlers] = useState(null); - - useEffect(() => { - if (!rootRef.current) { - return; - } - - registerGrabSelectionOwner(ownerIdRef.current, "root", rootRef.current); - return () => { - unregisterGrabSelectionOwner(ownerIdRef.current); - }; - }, []); + const { ownerId, ownerRef } = useGrabSelectionOwner("root"); return ( <> - + {children} - - + diff --git a/src/react-native/grab-screen.tsx b/src/react-native/grab-screen.tsx index 574cb1f..ba18303 100644 --- a/src/react-native/grab-screen.tsx +++ b/src/react-native/grab-screen.tsx @@ -1,14 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { View, type GestureResponderHandlers, type ViewProps } from "react-native"; -import { - clearGrabSelectionOwnerFocus, - createGrabSelectionOwnerId, - registerGrabSelectionOwner, - setGrabSelectionOwnerFocused, - unregisterGrabSelectionOwner, -} from "./containers"; +import { useCallback } from "react"; +import { type ViewProps } from "react-native"; +import { clearGrabSelectionOwnerFocus, setGrabSelectionOwnerFocused } from "./containers"; import { getFocusEffect } from "./focus-effect"; -import { ReactNativeGrabOverlay } from "./grab-overlay"; +import { GrabSelectionOwnerView, useGrabSelectionOwner } from "./grab-selection-owner"; const useFocusEffect = getFocusEffect(); @@ -22,44 +16,24 @@ export const ReactNativeGrabScreen = ({ id, ...props }: ReactNativeGrabScreenProps) => { - const screenRef = useRef(null); - const ownerIdRef = useRef(id ?? createGrabSelectionOwnerId("screen")); - const [panHandlers, setPanHandlers] = useState(null); - - useEffect(() => { - if (!screenRef.current) { - return; - } - - registerGrabSelectionOwner(ownerIdRef.current, "screen", screenRef.current); - return () => { - unregisterGrabSelectionOwner(ownerIdRef.current); - }; - }, []); + const { ownerId, ownerRef } = useGrabSelectionOwner("screen", id); useFocusEffect( useCallback(() => { - if (!screenRef.current) { + if (!ownerRef.current) { return; } - setGrabSelectionOwnerFocused(ownerIdRef.current, true); + setGrabSelectionOwnerFocused(ownerId, true); return () => { - clearGrabSelectionOwnerFocus(ownerIdRef.current); + clearGrabSelectionOwnerFocus(ownerId); }; - }, []), + }, [ownerId, ownerRef]), ); return ( - + {children} - - + ); }; diff --git a/src/react-native/grab-selection-owner.tsx b/src/react-native/grab-selection-owner.tsx new file mode 100644 index 0000000..324c5a6 --- /dev/null +++ b/src/react-native/grab-selection-owner.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState, type RefObject } from "react"; +import { View, type GestureResponderHandlers, type ViewProps } from "react-native"; +import { + createGrabSelectionOwnerId, + registerGrabSelectionOwner, + unregisterGrabSelectionOwner, + type GrabSelectionOwnerKind, +} from "./containers"; +import { ReactNativeGrabOverlay } from "./grab-overlay"; + +export const useGrabSelectionOwner = (kind: GrabSelectionOwnerKind, id?: string) => { + const ownerRef = useRef(null); + const ownerIdRef = useRef(id ?? createGrabSelectionOwnerId(kind)); + + useEffect(() => { + if (!ownerRef.current) { + return; + } + + registerGrabSelectionOwner(ownerIdRef.current, kind, ownerRef.current); + return () => { + unregisterGrabSelectionOwner(ownerIdRef.current); + }; + }, [kind]); + + return { ownerId: ownerIdRef.current, ownerRef }; +}; + +type GrabSelectionOwnerViewProps = ViewProps & { + fill?: boolean; + ownerId: string; + ownerRef: RefObject; +}; + +export const GrabSelectionOwnerView = ({ + children, + fill = false, + ownerId, + ownerRef, + style, + ...props +}: GrabSelectionOwnerViewProps) => { + const [panHandlers, setPanHandlers] = useState(null); + + return ( + + {children} + + + ); +}; diff --git a/src/react-native/grab-surface.tsx b/src/react-native/grab-surface.tsx new file mode 100644 index 0000000..7bc1bb5 --- /dev/null +++ b/src/react-native/grab-surface.tsx @@ -0,0 +1,27 @@ +import { useEffect } from "react"; +import type { ViewProps } from "react-native"; +import { setGrabSelectionOwnerActive } from "./containers"; +import { GrabSelectionOwnerView, useGrabSelectionOwner } from "./grab-selection-owner"; + +export type ReactNativeGrabSurfaceProps = ViewProps & { + active: boolean; +}; + +export const ReactNativeGrabSurface = ({ + active, + children, + style, + ...props +}: ReactNativeGrabSurfaceProps) => { + const { ownerId, ownerRef } = useGrabSelectionOwner("surface"); + + useEffect(() => { + setGrabSelectionOwnerActive(ownerId, active); + }, [active, ownerId]); + + return ( + + {children} + + ); +}; diff --git a/src/react-native/grab-surface.web.tsx b/src/react-native/grab-surface.web.tsx new file mode 100644 index 0000000..b55561d --- /dev/null +++ b/src/react-native/grab-surface.web.tsx @@ -0,0 +1,3 @@ +import type { ReactNode } from "react"; + +export const ReactNativeGrabSurface = ({ children }: { children?: ReactNode }) => children; diff --git a/src/react-native/index.ts b/src/react-native/index.ts index c59a453..1d6663f 100644 --- a/src/react-native/index.ts +++ b/src/react-native/index.ts @@ -1,10 +1,12 @@ import type { ReactNativeGrabRootProps } from "./grab-root"; import type { ReactNativeGrabScreenProps } from "./grab-screen"; +import type { ReactNativeGrabSurfaceProps } from "./grab-surface"; import type { ReactNativeGrabContextProviderProps } from "./grab-context"; import type { ReactNode } from "react"; export type { ReactNativeGrabRootProps } from "./grab-root"; export type { ReactNativeGrabScreenProps } from "./grab-screen"; +export type { ReactNativeGrabSurfaceProps } from "./grab-surface"; export type { ReactNativeGrabContextProviderProps, ReactNativeGrabContextValue, @@ -21,6 +23,10 @@ export const ReactNativeGrabScreen: React.ComponentType = __DEV__ + ? require("./grab-surface").ReactNativeGrabSurface + : Passthrough; + export const ReactNativeGrabContextProvider: React.ComponentType = __DEV__ ? require("./grab-context").ReactNativeGrabContextProvider : Passthrough; From 2946ca8d7e1ef1b5af79cf7b94e1799b7fe59fa8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 31 Aug 2026 12:14:26 +0200 Subject: [PATCH 2/5] test(example): add TrueSheet playground for native-presented surfaces Adds a sheet playground screen exercising ReactNativeGrabSurface against a real native-presented sheet, which is the case ReactNativeGrabSurface exists for and which the existing modal route does not cover. Two sheets so surface ordering is testable: - an `auto` detent sheet, where the surface must stay content-measured - a fixed-detent sheet stacked on top, where the surface must fill instead Grab targets render `Text` directly rather than through `ThemedText` so the selection menu title names the surface the element came from (`Text (in AutoSheetTarget)`), making it possible to tell which owner resolved the selection. Each sheet drives `active` from onDidPresent/onDidDismiss rather than from the press handlers, so drag-to-dismiss also deactivates the surface. Claude-Session: https://claude.ai/code/session_01KDiabfZtYE2vxCeybZnA4D --- example/package-lock.json | 65 ++++++++++- example/package.json | 1 + example/src/app/(tabs)/explore.tsx | 25 +++++ example/src/app/_layout.tsx | 1 + example/src/app/sheet-playground.tsx | 155 +++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 example/src/app/sheet-playground.tsx diff --git a/example/package-lock.json b/example/package-lock.json index 9eba180..80702a6 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@expo/vector-icons": "^15.0.2", + "@lodev09/react-native-true-sheet": "^3.11.12", "@react-navigation/bottom-tabs": "^7.7.3", "@react-navigation/elements": "^2.8.1", "@react-navigation/native": "^7.1.28", @@ -42,21 +43,41 @@ } }, "..": { - "version": "0.0.2", + "version": "1.1.1", "license": "MIT", "dependencies": { "metro-config-transformers": "latest" }, "devDependencies": { + "@changesets/cli": "^2.29.8", "@types/node": "latest", "@types/react": "latest", + "husky": "^9.1.7", "oxfmt": "latest", - "typescript": "latest" + "typescript": "latest", + "vitest": "^3.2.4" }, "peerDependencies": { + "@react-navigation/native": "*", + "expo-dev-menu": "*", + "expo-router": "*", "react": ">=19", - "react-native": ">=0.82", + "react-native": ">=0.80", "react-native-screens": "*" + }, + "peerDependenciesMeta": { + "@react-navigation/native": { + "optional": true + }, + "expo-dev-menu": { + "optional": true + }, + "expo-router": { + "optional": true + }, + "react-native-screens": { + "optional": true + } } }, "node_modules/@babel/code-frame": { @@ -2156,6 +2177,44 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lodev09/react-native-true-sheet": { + "version": "3.11.12", + "resolved": "https://registry.npmjs.org/@lodev09/react-native-true-sheet/-/react-native-true-sheet-3.11.12.tgz", + "integrity": "sha512-eL7mfUw0p3dxqXKqck3k7H2Ja6UHGbPSqbVDRKeANuxZN4xctxF1iFSPYHj0LULVtheqxKBhWBiR4lJ25El57Q==", + "license": "MIT", + "workspaces": [ + "example/bare", + "example/expo", + "example/shared", + "docs" + ], + "peerDependencies": { + "@radix-ui/react-dialog": ">=1", + "@radix-ui/react-presence": ">=1", + "@react-navigation/core": ">=7", + "react": "*", + "react-native": "*", + "react-native-reanimated": ">=4", + "react-native-worklets": "*" + }, + "peerDependenciesMeta": { + "@radix-ui/react-dialog": { + "optional": true + }, + "@radix-ui/react-presence": { + "optional": true + }, + "@react-navigation/core": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + }, + "react-native-worklets": { + "optional": true + } + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", diff --git a/example/package.json b/example/package.json index 864ce61..d9efc5c 100644 --- a/example/package.json +++ b/example/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.2", + "@lodev09/react-native-true-sheet": "^3.11.12", "@react-navigation/bottom-tabs": "^7.7.3", "@react-navigation/elements": "^2.8.1", "@react-navigation/native": "^7.1.28", diff --git a/example/src/app/(tabs)/explore.tsx b/example/src/app/(tabs)/explore.tsx index a548ce4..626086a 100644 --- a/example/src/app/(tabs)/explore.tsx +++ b/example/src/app/(tabs)/explore.tsx @@ -105,6 +105,31 @@ export default function TabTwoScreen() { + + + Content presented by a native sheet lives outside the screen subtree. Open the sheet + playground and try selecting elements inside a presented sheet. + + + [styles.modalTrigger, pressed && styles.pressed]} + > + + Open sheet playground + + + + + + Open a dedicated modal with nested grab context providers. Each nested element diff --git a/example/src/app/_layout.tsx b/example/src/app/_layout.tsx index 48f6447..5ead18e 100644 --- a/example/src/app/_layout.tsx +++ b/example/src/app/_layout.tsx @@ -22,6 +22,7 @@ export default function MainLayout() { name="context-playground" options={{ presentation: "modal", title: "Context Playground" }} /> + diff --git a/example/src/app/sheet-playground.tsx b/example/src/app/sheet-playground.tsx new file mode 100644 index 0000000..354c061 --- /dev/null +++ b/example/src/app/sheet-playground.tsx @@ -0,0 +1,155 @@ +import { TrueSheet } from "@lodev09/react-native-true-sheet"; +import { useRef, useState } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; + +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { Spacing } from "@/constants/theme"; +import { useTheme } from "@/hooks/use-theme"; + +import { ReactNativeGrabScreen, ReactNativeGrabSurface, enableGrabbing } from "react-native-grab"; + +/** + * Named wrappers so the grab selection menu title identifies which surface the + * element came from: the menu renders `Text (in ScreenTarget)`, `Text (in AutoSheetTarget)`, + * and so on. The e2e flows assert on those exact strings. + * + * These render `Text` directly instead of `ThemedText`: the menu title names the + * closest non-host owner, so a shared wrapper component would make every target + * report the same `Text (in ThemedText)`. + */ +function ScreenTarget() { + const theme = useTheme(); + return Screen target; +} + +function AutoSheetTarget() { + const theme = useTheme(); + return Auto sheet target; +} + +function FullSheetTarget() { + const theme = useTheme(); + return Full sheet target; +} + +type ActionProps = { + label: string; + onPress: () => void; +}; + +function Action({ label, onPress }: ActionProps) { + return ( + [pressed && styles.pressed]}> + + {label} + + + ); +} + +export default function SheetPlaygroundScreen() { + const theme = useTheme(); + const autoSheet = useRef(null); + const fullSheet = useRef(null); + + // Driven by the sheet's own presentation lifecycle rather than by the press + // handlers, so drag-to-dismiss also deactivates the surface. + const [isAutoSheetPresented, setIsAutoSheetPresented] = useState(false); + const [isFullSheetPresented, setIsFullSheetPresented] = useState(false); + + return ( + + + Native sheets + + TrueSheet presents its content in a native container outside the screen subtree. Each + sheet wraps its content in ReactNativeGrabSurface so grabbing resolves to the presented + sheet instead of the screen behind it. + + + + + + + void autoSheet.current?.present()} /> + + + + {/* Content-measured sheet: the surface wrapper sizes to its content, which is + what the `auto` detent needs to measure. */} + setIsAutoSheetPresented(true)} + onDidDismiss={() => setIsAutoSheetPresented(false)} + > + + Auto sheet + + + void fullSheet.current?.present()} /> + void autoSheet.current?.dismiss()} /> + + + + {/* Fixed-height sheet: the surface has to fill the sheet, so it takes an + explicit flex style. */} + setIsFullSheetPresented(true)} + onDidDismiss={() => setIsFullSheetPresented(false)} + > + + Full sheet + + + void fullSheet.current?.dismiss()} /> + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + gap: Spacing.three, + padding: Spacing.four, + }, + fill: { + flex: 1, + }, + target: { + fontSize: 16, + fontWeight: "500", + lineHeight: 24, + }, + sheetContent: { + gap: Spacing.three, + padding: Spacing.four, + paddingBottom: Spacing.six, + }, + actions: { + gap: Spacing.two, + }, + action: { + alignItems: "center", + borderRadius: Spacing.three, + paddingHorizontal: Spacing.four, + paddingVertical: Spacing.two, + }, + pressed: { + opacity: 0.7, + }, +}); From b35a644c84ce4a4e76adfb2138d453a4b9257852 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 31 Aug 2026 12:16:07 +0200 Subject: [PATCH 3/5] fix: tighten grab surface layout, lifecycle and web shims Review follow-ups on the ReactNativeGrabSurface work: - Drop the `fill` prop from GrabSelectionOwnerView. Root and screen pass the fill style themselves, so a surface can now be made to fill its container with a plain `style` prop. Without this a surface wrapping full-screen content collapsed it in development and laid out correctly in production, where the component compiles away to a passthrough. - Make surface activation symmetric: deactivate on effect cleanup instead of relying on the owner being unregistered first. - Report a failed owner registration instead of returning silently. A surface whose ref never attached would otherwise ignore every later activation and stay permanently unselectable with no signal. - Render children from the web shims. `ReactNativeGrabRoot` and `ReactNativeGrabScreen` returned null, which drops the entire subtree on web; all three shims now preserve layout instead. - Replace the per-resolve filter and sort of active surfaces with a single scan. Resolution runs inside useSyncExternalStore for every mounted overlay. - Delete the unused selection owners store snapshot API. Claude-Session: https://claude.ai/code/session_01KDiabfZtYE2vxCeybZnA4D --- README.md | 21 ++++++++-- .../__tests__/selection-owners.test.ts | 26 ++++++++++++ src/react-native/containers.ts | 42 +++++++------------ src/react-native/grab-root.tsx | 13 +++++- src/react-native/grab-root.web.tsx | 8 ++-- src/react-native/grab-screen.tsx | 19 +++++---- src/react-native/grab-screen.web.tsx | 9 ++-- src/react-native/grab-selection-owner.tsx | 23 +++++----- src/react-native/grab-surface.tsx | 9 +++- src/react-native/grab-surface.web.tsx | 7 +++- 10 files changed, 117 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 645c988..47cc347 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ export default function AppLayout() { - `ReactNativeGrabRoot`: Root-level provider for grab functionality. - `ReactNativeGrabScreen`: When using native navigators (native stack, native tabs), wrap **each screen** with this component for accurate selection. -- `ReactNativeGrabSurface`: Wraps content hosted in a separately presented native surface. An active surface takes selection priority over the focused screen; when it becomes inactive, selection falls back to the focused screen or root. This component is a no-op in production builds. +- `ReactNativeGrabSurface`: Wraps content hosted in a separately presented native surface. An active surface takes selection priority over the focused screen; when it becomes inactive, selection falls back to the focused screen or root. Unlike `ReactNativeGrabRoot` and `ReactNativeGrabScreen`, it does not stretch to fill its parent, so that sheets using content-measured detents can still measure their content. This component is a no-op in production builds. - `ReactNativeGrabContextProvider`: Adds custom metadata to grabbed elements. Nested providers are shallow-merged and child keys override parent keys. This provider is a no-op in production builds. - `enableGrabbing()`: Programmatically enables grabbing flow. - `setFocusEffect(impl)`: Overrides the hook used by `ReactNativeGrabScreen` to detect when a screen is focused. By default the library auto-detects `useFocusEffect` from `expo-router` or `@react-navigation/native`. Call `setFocusEffect` once at app startup when neither package is present (e.g. a custom router) or when you want explicit control over which implementation is used. @@ -88,20 +88,35 @@ setFocusEffect(useFocusEffect); For a native sheet or modal, place `ReactNativeGrabSurface` inside the presented content and keep `active` synchronized with its presentation lifecycle: ```tsx -import { Modal } from "react-native"; +import { Modal, StyleSheet } from "react-native"; import { ReactNativeGrabSurface } from "react-native-grab"; function DetailsModal({ visible }: { visible: boolean }) { return ( - + {/* modal or sheet content */} ); } + +const styles = StyleSheet.create({ + surface: { + flex: 1, + }, +}); ``` +`ReactNativeGrabSurface` renders a `View` around your content and sizes to that content by +default, which is what sheets with content-measured detents need. Pass `flex: 1` through `style` +when the surface should fill the presented container instead, as above. + +Keep `active` synchronized with the full presentation lifecycle, including drag-to-dismiss — drive +it from the presentation callbacks (`onDidDismiss` and friends) rather than from the handler that +opened the surface. A surface left `active` after dismissal keeps selection priority and blocks +grabbing on the screen behind it. + When grab context is available for a selected element, copied output includes an additional `Context:` JSON block appended after the existing element preview and stack trace lines. ## Documentation diff --git a/src/react-native/__tests__/selection-owners.test.ts b/src/react-native/__tests__/selection-owners.test.ts index 3cf9a4a..07a3a9b 100644 --- a/src/react-native/__tests__/selection-owners.test.ts +++ b/src/react-native/__tests__/selection-owners.test.ts @@ -63,4 +63,30 @@ describe("grab selection owner resolution", () => { clearGrabSelectionOwnerFocus("screen"); expect(getResolvedGrabSelectionOwnerId()).toBe("root"); }); + + it("ignores activation for owners that are not surfaces", () => { + registerOwner("root", "root", 1); + registerOwner("screen", "screen", 2); + + setGrabSelectionOwnerActive("screen", true); + setGrabSelectionOwnerActive("root", true); + + expect(getResolvedGrabSelectionOwnerId()).toBe("root"); + }); + + it("falls back to the focused screen when the only active surface unmounts", () => { + registerOwner("root", "root", 1); + registerOwner("screen", "screen", 2); + setGrabSelectionOwnerFocused("screen", true); + registerOwner("sheet", "surface", 3); + setGrabSelectionOwnerActive("sheet", true); + expect(getResolvedGrabSelectionOwnerId()).toBe("sheet"); + + unregisterGrabSelectionOwner("sheet"); + expect(getResolvedGrabSelectionOwnerId()).toBe("screen"); + + // A stale activation for the unmounted surface must not resurrect it. + setGrabSelectionOwnerActive("sheet", true); + expect(getResolvedGrabSelectionOwnerId()).toBe("screen"); + }); }); diff --git a/src/react-native/containers.ts b/src/react-native/containers.ts index 612232f..418b81e 100644 --- a/src/react-native/containers.ts +++ b/src/react-native/containers.ts @@ -13,11 +13,6 @@ export type GrabSelectionOwner = { activationOrder: number | null; }; -type SelectionOwnersStoreSnapshot = { - owners: Map; - focusedScreenOwnerId: string | null; -}; - let ownerIdCounter = 0; let registrationOrder = 0; let activationOrder = 0; @@ -38,11 +33,6 @@ const subscribe = (listener: () => void) => { }; }; -const getSnapshot = (): SelectionOwnersStoreSnapshot => ({ - owners: new Map(owners), - focusedScreenOwnerId, -}); - const getOwnerShadowNode = (ref: ReactNativeElement, errorMessage: string) => { // @ts-expect-error - findNodeHandle is not typed correctly const nativeTag = findNodeHandle(ref); @@ -67,11 +57,18 @@ const getFallbackRootOwner = () => { }; const getActiveSurfaceOwner = () => { - const surfaceOwners = Array.from(owners.values()).filter( - (owner) => owner.kind === "surface" && owner.activationOrder !== null, - ); - surfaceOwners.sort((left, right) => (right.activationOrder ?? 0) - (left.activationOrder ?? 0)); - return surfaceOwners[0] ?? null; + let activeSurfaceOwner: GrabSelectionOwner | null = null; + let highestActivationOrder = -1; + + for (const owner of owners.values()) { + const order = owner.kind === "surface" ? owner.activationOrder : null; + if (order !== null && order > highestActivationOrder) { + activeSurfaceOwner = owner; + highestActivationOrder = order; + } + } + + return activeSurfaceOwner; }; export const createGrabSelectionOwnerId = (kind: GrabSelectionOwnerKind) => { @@ -138,6 +135,9 @@ export const clearGrabSelectionOwnerFocus = (id: string) => { export const setGrabSelectionOwnerActive = (id: string, isActive: boolean) => { const owner = owners.get(id); + // Re-activating an already active surface deliberately keeps its original + // activation order, so a redundant render cannot promote a surface above one + // that was presented on top of it. if (!owner || owner.kind !== "surface" || (owner.activationOrder !== null) === isActive) { return; } @@ -177,14 +177,6 @@ export const getResolvedGrabSelectionOwnerId = (): string | null => { return getResolvedGrabSelectionOwner()?.id ?? null; }; -export const useResolvedGrabSelectionOwnerId = () => { - return useSyncExternalStore( - subscribe, - () => getResolvedGrabSelectionOwnerId(), - () => null, - ); -}; - export const useIsResolvedGrabSelectionOwner = (id: string) => { return useSyncExternalStore( subscribe, @@ -192,7 +184,3 @@ export const useIsResolvedGrabSelectionOwner = (id: string) => { () => false, ); }; - -export const useSelectionOwnersStore = () => { - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -}; diff --git a/src/react-native/grab-root.tsx b/src/react-native/grab-root.tsx index 7f04ca6..3d0e51a 100644 --- a/src/react-native/grab-root.tsx +++ b/src/react-native/grab-root.tsx @@ -1,6 +1,10 @@ import type { ViewProps } from "react-native"; import { ReactNativeGrabRootControls } from "./grab-root-controls"; -import { GrabSelectionOwnerView, useGrabSelectionOwner } from "./grab-selection-owner"; +import { + grabSelectionOwnerFillStyle, + GrabSelectionOwnerView, + useGrabSelectionOwner, +} from "./grab-selection-owner"; export type ReactNativeGrabRootProps = ViewProps; @@ -9,7 +13,12 @@ export const ReactNativeGrabRoot = ({ children, style, ...props }: ReactNativeGr return ( <> - + {children} diff --git a/src/react-native/grab-root.web.tsx b/src/react-native/grab-root.web.tsx index f353582..6855770 100644 --- a/src/react-native/grab-root.web.tsx +++ b/src/react-native/grab-root.web.tsx @@ -1,4 +1,6 @@ -// React Native Grab doesn't support web yet! -export const ReactNativeGrabRoot = () => { - return null; +import { View, type ViewProps } from "react-native"; + +// React Native Grab doesn't support web yet, so the root only preserves layout. +export const ReactNativeGrabRoot = ({ style, ...props }: ViewProps) => { + return ; }; diff --git a/src/react-native/grab-screen.tsx b/src/react-native/grab-screen.tsx index ba18303..07d12cc 100644 --- a/src/react-native/grab-screen.tsx +++ b/src/react-native/grab-screen.tsx @@ -2,7 +2,11 @@ import { useCallback } from "react"; import { type ViewProps } from "react-native"; import { clearGrabSelectionOwnerFocus, setGrabSelectionOwnerFocused } from "./containers"; import { getFocusEffect } from "./focus-effect"; -import { GrabSelectionOwnerView, useGrabSelectionOwner } from "./grab-selection-owner"; +import { + grabSelectionOwnerFillStyle, + GrabSelectionOwnerView, + useGrabSelectionOwner, +} from "./grab-selection-owner"; const useFocusEffect = getFocusEffect(); @@ -20,19 +24,20 @@ export const ReactNativeGrabScreen = ({ useFocusEffect( useCallback(() => { - if (!ownerRef.current) { - return; - } - setGrabSelectionOwnerFocused(ownerId, true); return () => { clearGrabSelectionOwnerFocus(ownerId); }; - }, [ownerId, ownerRef]), + }, [ownerId]), ); return ( - + {children} ); diff --git a/src/react-native/grab-screen.web.tsx b/src/react-native/grab-screen.web.tsx index 7ca6ccb..776ed5b 100644 --- a/src/react-native/grab-screen.web.tsx +++ b/src/react-native/grab-screen.web.tsx @@ -1,4 +1,7 @@ -// React Native Grab doesn't support web yet! -export const ReactNativeGrabScreen = () => { - return null; +import { View, type ViewProps } from "react-native"; + +// React Native Grab doesn't support web yet, so the screen only preserves layout. +// `id` identifies a selection owner, not a DOM node, so it is not forwarded. +export const ReactNativeGrabScreen = ({ id, style, ...props }: ViewProps & { id?: string }) => { + return ; }; diff --git a/src/react-native/grab-selection-owner.tsx b/src/react-native/grab-selection-owner.tsx index 324c5a6..36c7c2b 100644 --- a/src/react-native/grab-selection-owner.tsx +++ b/src/react-native/grab-selection-owner.tsx @@ -8,18 +8,26 @@ import { } from "./containers"; import { ReactNativeGrabOverlay } from "./grab-overlay"; +/** Root and screen owners always fill their parent; surfaces are sized by their host. */ +export const grabSelectionOwnerFillStyle = { flex: 1 } as const; + export const useGrabSelectionOwner = (kind: GrabSelectionOwnerKind, id?: string) => { const ownerRef = useRef(null); const ownerIdRef = useRef(id ?? createGrabSelectionOwnerId(kind)); useEffect(() => { + const ownerId = ownerIdRef.current; + if (!ownerRef.current) { + console.error( + `[react-native-grab] Failed to register ${kind} selection owner: the view ref was never attached. Elements inside it cannot be grabbed.`, + ); return; } - registerGrabSelectionOwner(ownerIdRef.current, kind, ownerRef.current); + registerGrabSelectionOwner(ownerId, kind, ownerRef.current); return () => { - unregisterGrabSelectionOwner(ownerIdRef.current); + unregisterGrabSelectionOwner(ownerId); }; }, [kind]); @@ -27,29 +35,20 @@ export const useGrabSelectionOwner = (kind: GrabSelectionOwnerKind, id?: string) }; type GrabSelectionOwnerViewProps = ViewProps & { - fill?: boolean; ownerId: string; ownerRef: RefObject; }; export const GrabSelectionOwnerView = ({ children, - fill = false, ownerId, ownerRef, - style, ...props }: GrabSelectionOwnerViewProps) => { const [panHandlers, setPanHandlers] = useState(null); return ( - + {children} diff --git a/src/react-native/grab-surface.tsx b/src/react-native/grab-surface.tsx index 7bc1bb5..07201be 100644 --- a/src/react-native/grab-surface.tsx +++ b/src/react-native/grab-surface.tsx @@ -16,7 +16,14 @@ export const ReactNativeGrabSurface = ({ const { ownerId, ownerRef } = useGrabSelectionOwner("surface"); useEffect(() => { - setGrabSelectionOwnerActive(ownerId, active); + if (!active) { + return; + } + + setGrabSelectionOwnerActive(ownerId, true); + return () => { + setGrabSelectionOwnerActive(ownerId, false); + }; }, [active, ownerId]); return ( diff --git a/src/react-native/grab-surface.web.tsx b/src/react-native/grab-surface.web.tsx index b55561d..beef152 100644 --- a/src/react-native/grab-surface.web.tsx +++ b/src/react-native/grab-surface.web.tsx @@ -1,3 +1,6 @@ -import type { ReactNode } from "react"; +import { View, type ViewProps } from "react-native"; -export const ReactNativeGrabSurface = ({ children }: { children?: ReactNode }) => children; +// React Native Grab doesn't support web yet, so the surface only preserves layout. +export const ReactNativeGrabSurface = ({ active, ...props }: ViewProps & { active: boolean }) => { + return ; +}; From ab3f32fa132eba0bb115fd0f5218d6e34f5bcf53 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 31 Aug 2026 12:38:09 +0200 Subject: [PATCH 4/5] fix: resolve grab points against the owner instead of the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findNodeAtPoint resolves its point against the shadow node it is given, but the overlay passed raw page coordinates. The two only coincide when the owner sits at the window origin, which is the case for ReactNativeGrabRoot — so the bug stayed invisible until an owner was offset. Verified on an iOS simulator: on a screen under a native stack header, tapping an element selected whatever sat one header-height below it. For a natively presented surface the offset is the sheet's own origin, several hundred points down, so ReactNativeGrabSurface could not have selected its content at all. The owner rect was already measured to make the highlight rect relative; it now also rebases the incoming point. Claude-Session: https://claude.ai/code/session_01KDiabfZtYE2vxCeybZnA4D --- example/src/app/sheet-playground.tsx | 62 +++++++++++++++++++--------- src/react-native/grab-overlay.tsx | 12 +++++- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/example/src/app/sheet-playground.tsx b/example/src/app/sheet-playground.tsx index 354c061..edccc1a 100644 --- a/example/src/app/sheet-playground.tsx +++ b/example/src/app/sheet-playground.tsx @@ -1,6 +1,6 @@ import { TrueSheet } from "@lodev09/react-native-true-sheet"; import { useRef, useState } from "react"; -import { Pressable, StyleSheet, Text, View } from "react-native"; +import { Modal, Pressable, StyleSheet, Text, View } from "react-native"; import { ThemedText } from "@/components/themed-text"; import { ThemedView } from "@/components/themed-view"; @@ -28,9 +28,14 @@ function AutoSheetTarget() { return Auto sheet target; } -function FullSheetTarget() { +function StackedSheetTarget() { const theme = useTheme(); - return Full sheet target; + return Stacked sheet target; +} + +function ModalTarget() { + const theme = useTheme(); + return Modal target; } type ActionProps = { @@ -51,12 +56,13 @@ function Action({ label, onPress }: ActionProps) { export default function SheetPlaygroundScreen() { const theme = useTheme(); const autoSheet = useRef(null); - const fullSheet = useRef(null); + const stackedSheet = useRef(null); // Driven by the sheet's own presentation lifecycle rather than by the press // handlers, so drag-to-dismiss also deactivates the surface. const [isAutoSheetPresented, setIsAutoSheetPresented] = useState(false); - const [isFullSheetPresented, setIsFullSheetPresented] = useState(false); + const [isStackedSheetPresented, setIsStackedSheetPresented] = useState(false); + const [isModalVisible, setIsModalVisible] = useState(false); return ( @@ -73,6 +79,7 @@ export default function SheetPlaygroundScreen() { void autoSheet.current?.present()} /> + setIsModalVisible(true)} /> @@ -91,32 +98,47 @@ export default function SheetPlaygroundScreen() { Auto sheet - void fullSheet.current?.present()} /> + void stackedSheet.current?.present()} /> void autoSheet.current?.dismiss()} /> - {/* Fixed-height sheet: the surface has to fill the sheet, so it takes an - explicit flex style. */} + {/* Second sheet, presented on top of the first one: the most recently + activated surface has to win, and dismissing it has to hand selection + back to the sheet underneath rather than to the screen. */} setIsFullSheetPresented(true)} - onDidDismiss={() => setIsFullSheetPresented(false)} + onDidPresent={() => setIsStackedSheetPresented(true)} + onDidDismiss={() => setIsStackedSheetPresented(false)} > + + Stacked sheet + + + void stackedSheet.current?.dismiss()} + /> + + + + {/* A plain RN modal presents a full-screen container, so here the surface + has to be told to fill it. */} + setIsModalVisible(false)}> - Full sheet - - - void fullSheet.current?.dismiss()} /> + Modal + + + setIsModalVisible(false)} /> - + ); } diff --git a/src/react-native/grab-overlay.tsx b/src/react-native/grab-overlay.tsx index 24e205d..ff66c25 100644 --- a/src/react-native/grab-overlay.tsx +++ b/src/react-native/grab-overlay.tsx @@ -144,14 +144,22 @@ export const ReactNativeGrabOverlay = ({ return null; } - const internalNode = findNodeAtPoint(owner.shadowNode, pageX, pageY); + // findNodeAtPoint resolves the point against the owner, not the window, so + // page coordinates have to be rebased onto the owner's origin. They only + // coincide for an owner sitting at the window origin, which is why a screen + // under a native header, or a natively presented surface, missed its target. + const ownerRect = measureInWindow(owner.shadowNode); + const internalNode = findNodeAtPoint( + owner.shadowNode, + pageX - ownerRect[0], + pageY - ownerRect[1], + ); const shadowNode = internalNode?.stateNode?.node; if (!shadowNode) { return null; } - const ownerRect = measureInWindow(owner.shadowNode); const rect = nativeFabricUIManager.getBoundingClientRect(shadowNode, true); return { fiberNode: internalNode, From 9e6636ebf461bc073910c851a5d54ffa46e70366 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 31 Aug 2026 12:53:17 +0200 Subject: [PATCH 5/5] chore: add changeset for grab surface and hit-testing fixes Claude-Session: https://claude.ai/code/session_01KDiabfZtYE2vxCeybZnA4D --- .changeset/olive-pugs-repeat.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/olive-pugs-repeat.md diff --git a/.changeset/olive-pugs-repeat.md b/.changeset/olive-pugs-repeat.md new file mode 100644 index 0000000..a5ea414 --- /dev/null +++ b/.changeset/olive-pugs-repeat.md @@ -0,0 +1,7 @@ +--- +"react-native-grab": patch +--- + +Resolve grab points against the selection owner instead of the window, so elements are selected under the finger when the owner is not at the window origin (a screen under a native header, or a natively presented surface). + +Let `ReactNativeGrabSurface` be sized by its host: it no longer forces a fill, and takes `flex: 1` through `style` when it should fill a presented container. Surface activation now deactivates on unmount, a failed owner registration is reported instead of failing silently, and the web entry points render their children rather than dropping the subtree.