diff --git a/README.md b/README.md
index 048e965..25eb2bc 100644
--- a/README.md
+++ b/README.md
@@ -46,11 +46,21 @@ const config = getDefaultConfig(__dirname);
module.exports = withReactNativeGrab(config);
// app root
-import { ReactNativeGrabRoot, ReactNativeGrabScreen } from "react-native-grab";
+import {
+ ReactNativeGrabRoot,
+ ReactNativeGrabScreen,
+ ReactNativeGrabContextProvider,
+} from "react-native-grab";
// When using native navigators (native stack, native tabs), wrap each screen:
function HomeScreen() {
- return {/* screen content */};
+ return (
+
+
+ {/* screen content */}
+
+
+ );
}
export default function AppLayout() {
@@ -62,8 +72,11 @@ 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.
+- `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.
+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
Documentation lives in this repository: [callstackincubator/react-native-grab](https://github.com/callstackincubator/react-native-grab). You can also use the following links to jump to specific topics:
diff --git a/example/src/app/(tabs)/explore.tsx b/example/src/app/(tabs)/explore.tsx
index 44da71b..a548ce4 100644
--- a/example/src/app/(tabs)/explore.tsx
+++ b/example/src/app/(tabs)/explore.tsx
@@ -104,6 +104,31 @@ export default function TabTwoScreen() {
+
+
+
+ Open a dedicated modal with nested grab context providers. Each nested element
+ displays the context object it contributes.
+
+
+ [styles.modalTrigger, pressed && styles.pressed]}
+ >
+
+ Open context playground
+
+
+
+
+
{Platform.OS === "web" && }
diff --git a/example/src/app/_layout.tsx b/example/src/app/_layout.tsx
index 5f09cc6..48f6447 100644
--- a/example/src/app/_layout.tsx
+++ b/example/src/app/_layout.tsx
@@ -18,6 +18,10 @@ export default function MainLayout() {
+
diff --git a/example/src/app/context-playground.tsx b/example/src/app/context-playground.tsx
new file mode 100644
index 0000000..a9b7fde
--- /dev/null
+++ b/example/src/app/context-playground.tsx
@@ -0,0 +1,146 @@
+import React from "react";
+import { Platform, ScrollView, StyleSheet } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { ThemedText } from "@/components/themed-text";
+import { ThemedView } from "@/components/themed-view";
+import { BottomTabInset, MaxContentWidth, Spacing } from "@/constants/theme";
+import { useTheme } from "@/hooks/use-theme";
+
+import { ReactNativeGrabContextProvider, ReactNativeGrabScreen } from "react-native-grab";
+
+const formatContext = (value: Record) =>
+ JSON.stringify(value);
+
+const ContextLabel = ({ value }: { value: Record }) => {
+ return (
+
+ adds: {formatContext(value)}
+
+ );
+};
+
+export default function ContextPlaygroundScreen() {
+ const safeAreaInsets = useSafeAreaInsets();
+ const theme = useTheme();
+
+ const rootContext = { area: "context-playground", release: "1.0", tracked: true } as const;
+ const sectionContext = { section: "checkout-flow", density: "compact" } as const;
+ const cardContext = { card: "payment-summary", currency: "USD", experiment: "B" } as const;
+ const ctaContext = { action: "confirm-payment", priority: 1 } as const;
+
+ const contentPlatformStyle = Platform.select({
+ default: {
+ paddingTop: Spacing.three,
+ paddingBottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
+ paddingLeft: safeAreaInsets.left,
+ paddingRight: safeAreaInsets.right,
+ },
+ android: {
+ paddingTop: Spacing.three,
+ paddingLeft: safeAreaInsets.left,
+ paddingRight: safeAreaInsets.right,
+ paddingBottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
+ },
+ web: {
+ paddingTop: Spacing.six,
+ paddingBottom: Spacing.four,
+ },
+ });
+
+ return (
+
+
+
+
+ Grab Context Playground
+
+ Select any box below and verify the copied payload includes this hierarchy.
+
+
+
+
+
+ Level 1: App Area
+
+
+
+
+ Level 2: Section
+
+
+
+
+ Level 3: Card
+
+
+
+
+ Level 4: Action Button
+
+
+ Grab this node to get all levels merged.
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ scrollView: {
+ flex: 1,
+ },
+ contentContainer: {
+ flexDirection: "row",
+ justifyContent: "center",
+ },
+ container: {
+ width: "100%",
+ maxWidth: MaxContentWidth,
+ paddingHorizontal: Spacing.four,
+ gap: Spacing.four,
+ },
+ header: {
+ gap: Spacing.one,
+ },
+ levelRoot: {
+ borderRadius: Spacing.three,
+ padding: Spacing.three,
+ gap: Spacing.two,
+ },
+ levelSection: {
+ borderRadius: Spacing.three,
+ padding: Spacing.three,
+ gap: Spacing.two,
+ marginTop: Spacing.one,
+ },
+ levelCard: {
+ borderRadius: Spacing.three,
+ padding: Spacing.three,
+ gap: Spacing.two,
+ marginTop: Spacing.one,
+ },
+ levelAction: {
+ borderRadius: Spacing.three,
+ padding: Spacing.three,
+ gap: Spacing.one,
+ marginTop: Spacing.one,
+ },
+ contextLabel: {
+ borderRadius: Spacing.two,
+ paddingHorizontal: Spacing.two,
+ paddingVertical: Spacing.one,
+ },
+});
diff --git a/src/react-native/__tests__/grab-context-description.test.ts b/src/react-native/__tests__/grab-context-description.test.ts
new file mode 100644
index 0000000..f562cb9
--- /dev/null
+++ b/src/react-native/__tests__/grab-context-description.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it, vi } from "vitest";
+import { getDescription } from "../description";
+import { composeGrabContextValue, ReactNativeGrabInternalContext } from "../grab-context";
+import type { ReactNativeFiberNode } from "../types";
+
+vi.mock("../get-rendered-by", () => ({
+ getRenderedBy: vi.fn(async () => []),
+}));
+
+const createHostFiber = (
+ props: Record,
+ parent: ReactNativeFiberNode | null = null,
+): ReactNativeFiberNode => ({
+ type: "Text",
+ memoizedProps: props,
+ return: parent,
+ stateNode: null,
+ _debugStack: new Error(),
+ _debugOwner: null,
+});
+
+const createContextProviderFiber = (
+ value: Record,
+ parent: ReactNativeFiberNode | null = null,
+): ReactNativeFiberNode => ({
+ type: ReactNativeGrabInternalContext.Provider,
+ memoizedProps: { value },
+ return: parent,
+ stateNode: null,
+ _debugStack: new Error(),
+ _debugOwner: null,
+});
+
+describe("composeGrabContextValue", () => {
+ it("returns shallow copy when parent context does not exist", () => {
+ const result = composeGrabContextValue(null, { screen: "home", attempt: 1 });
+
+ expect(result).toEqual({ screen: "home", attempt: 1 });
+ });
+
+ it("merges parent and child with child override precedence", () => {
+ const result = composeGrabContextValue(
+ { screen: "home", theme: "light", source: "parent" },
+ { source: "child", variant: "hero" },
+ );
+
+ expect(result).toEqual({
+ screen: "home",
+ theme: "light",
+ source: "child",
+ variant: "hero",
+ });
+ });
+});
+
+describe("getDescription with grab context", () => {
+ it("keeps current output format when no context provider is in ancestors", async () => {
+ const selectedFiber = createHostFiber({ children: "Hello" });
+
+ const description = await getDescription(selectedFiber);
+
+ expect(description).toContain("");
+ expect(description).toContain("Hello");
+ expect(description).not.toContain("Context:");
+ });
+
+ it("appends Context block from nearest provider value", async () => {
+ const parentProvider = createContextProviderFiber({ screen: "home", locale: "en" });
+ const childProvider = createContextProviderFiber(
+ { locale: "pl", section: "cta" },
+ parentProvider,
+ );
+ const selectedFiber = createHostFiber({ children: "Tap me" }, childProvider);
+
+ const description = await getDescription(selectedFiber);
+
+ expect(description).toContain("");
+ expect(description).toContain("Tap me");
+ expect(description).toContain("Context:");
+ expect(description).toContain('"locale": "pl"');
+ expect(description).toContain('"section": "cta"');
+ expect(description).not.toContain('"screen": "home"');
+ });
+});
diff --git a/src/react-native/__tests__/setup.ts b/src/react-native/__tests__/setup.ts
new file mode 100644
index 0000000..bbf6171
--- /dev/null
+++ b/src/react-native/__tests__/setup.ts
@@ -0,0 +1 @@
+(globalThis as { __DEV__?: boolean }).__DEV__ = true;
diff --git a/src/react-native/description.ts b/src/react-native/description.ts
index 316a948..4844033 100644
--- a/src/react-native/description.ts
+++ b/src/react-native/description.ts
@@ -1,6 +1,8 @@
import { ReactNativeFiberNode } from "./types";
import { getRenderedBy } from "./get-rendered-by";
import type { RenderedByFrame } from "./get-rendered-by";
+import type { ReactNativeGrabContextValue } from "./grab-context";
+import { ReactNativeGrabInternalContext } from "./grab-context";
const MAX_STACK_LINES = 6;
const MAX_TEXT_LENGTH = 120;
@@ -164,12 +166,55 @@ const buildStackContext = (renderedBy: RenderedByFrame[]): string => {
return lines.join("");
};
+type ReactProviderType = {
+ Provider?: unknown;
+};
+
+type ContextProviderFiberNode = ReactNativeFiberNode & {
+ type?: unknown;
+ memoizedProps?: {
+ value?: ReactNativeGrabContextValue;
+ } | null;
+};
+
+const isGrabContextProviderFiber = (fiber: ContextProviderFiberNode): boolean => {
+ const providerType = fiber.type;
+ return (
+ providerType === ReactNativeGrabInternalContext ||
+ providerType === (ReactNativeGrabInternalContext as ReactProviderType).Provider
+ );
+};
+
+const getGrabContextFromFiber = (
+ node: ReactNativeFiberNode,
+): ReactNativeGrabContextValue | null => {
+ let current: ContextProviderFiberNode | null = node;
+
+ while (current) {
+ if (isGrabContextProviderFiber(current)) {
+ return current.memoizedProps?.value ?? null;
+ }
+ current = current.return ?? null;
+ }
+
+ return null;
+};
+
+const buildContextBlock = (contextValue: ReactNativeGrabContextValue | null): string => {
+ if (!contextValue || Object.keys(contextValue).length === 0) {
+ return "";
+ }
+
+ return `\n\nContext:\n${JSON.stringify(contextValue, null, 2)}`;
+};
+
export const getDescription = async (node: ReactNativeFiberNode): Promise => {
let renderedBy = await getRenderedBy(node);
const preview = buildElementPreview(node, renderedBy);
const stackContext = buildStackContext(renderedBy);
+ const contextBlock = buildContextBlock(getGrabContextFromFiber(node));
- if (!stackContext) return preview;
- return `${preview}${stackContext}`;
+ if (!stackContext) return `${preview}${contextBlock}`;
+ return `${preview}${stackContext}${contextBlock}`;
};
diff --git a/src/react-native/grab-context.tsx b/src/react-native/grab-context.tsx
new file mode 100644
index 0000000..782dcbb
--- /dev/null
+++ b/src/react-native/grab-context.tsx
@@ -0,0 +1,39 @@
+import { createContext, useContext } from "react";
+import type { ReactNode } from "react";
+
+export type ReactNativeGrabContextValue = Record;
+
+export type ReactNativeGrabContextProviderProps = {
+ value: ReactNativeGrabContextValue;
+ children?: ReactNode;
+};
+
+type InternalReactNativeGrabContextValue = ReactNativeGrabContextValue | null;
+
+export const ReactNativeGrabInternalContext =
+ createContext(null);
+
+export const composeGrabContextValue = (
+ parentValue: InternalReactNativeGrabContextValue,
+ value: ReactNativeGrabContextValue,
+): ReactNativeGrabContextValue => {
+ if (!parentValue) {
+ return { ...value };
+ }
+
+ return { ...parentValue, ...value };
+};
+
+export const ReactNativeGrabContextProvider = ({
+ value,
+ children,
+}: ReactNativeGrabContextProviderProps) => {
+ const parentValue = useContext(ReactNativeGrabInternalContext);
+ const composedValue = composeGrabContextValue(parentValue, value);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/react-native/index.ts b/src/react-native/index.ts
index f7bf81e..1d16a59 100644
--- a/src/react-native/index.ts
+++ b/src/react-native/index.ts
@@ -1,9 +1,17 @@
import type { ReactNativeGrabRootProps } from "./grab-root";
import type { ReactNativeGrabScreenProps } from "./grab-screen";
+import type {
+ ReactNativeGrabContextProviderProps,
+ ReactNativeGrabContextValue,
+} from "./grab-context";
import type { ReactNode } from "react";
export type { ReactNativeGrabRootProps } from "./grab-root";
export type { ReactNativeGrabScreenProps } from "./grab-screen";
+export type {
+ ReactNativeGrabContextProviderProps,
+ ReactNativeGrabContextValue,
+} from "./grab-context";
const noop = () => {};
const Passthrough = ({ children }: { children?: ReactNode }) => children;
@@ -16,6 +24,9 @@ export const ReactNativeGrabScreen: React.ComponentType =
+ __DEV__ ? require("./grab-context").ReactNativeGrabContextProvider : Passthrough;
+
export const enableGrabbing: () => void = __DEV__
? require("./grab-controller").enableGrabbing
: noop;