Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ReactNativeGrabScreen>{/* screen content */}</ReactNativeGrabScreen>;
return (
<ReactNativeGrabScreen>
<ReactNativeGrabContextProvider value={{ screen: "home" }}>
{/* screen content */}
</ReactNativeGrabContextProvider>
</ReactNativeGrabScreen>
);
}

export default function AppLayout() {
Expand All @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions example/src/app/(tabs)/explore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,31 @@ export default function TabTwoScreen() {
</Pressable>
</Link>
</Collapsible>

<Collapsible title="Grab context playground">
<ThemedText type="small">
Open a dedicated modal with nested grab context providers. Each nested element
displays the context object it contributes.
</ThemedText>
<Link href="/context-playground" asChild>
<Pressable
style={({ pressed }) => [styles.modalTrigger, pressed && styles.pressed]}
>
<ThemedView type="backgroundElement" style={styles.modalTriggerInner}>
<ThemedText type="link">Open context playground</ThemedText>
<SymbolView
tintColor={theme.text}
name={{
ios: "square.stack.3d.down.forward",
android: "layers",
web: "layers",
}}
size={14}
/>
</ThemedView>
</Pressable>
</Link>
</Collapsible>
</ThemedView>
{Platform.OS === "web" && <WebBadge />}
</ThemedView>
Expand Down
4 changes: 4 additions & 0 deletions example/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export default function MainLayout() {
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: "modal", title: "Modal" }} />
<Stack.Screen
name="context-playground"
options={{ presentation: "modal", title: "Context Playground" }}
/>
</Stack>
</View>
</ThemeProvider>
Expand Down
146 changes: 146 additions & 0 deletions example/src/app/context-playground.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string | number | boolean | null>) =>
JSON.stringify(value);

const ContextLabel = ({ value }: { value: Record<string, string | number | boolean | null> }) => {
return (
<ThemedView type="backgroundSelected" style={styles.contextLabel}>
<ThemedText type="code">adds: {formatContext(value)}</ThemedText>
</ThemedView>
);
};

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 (
<ReactNativeGrabScreen>
<ScrollView
style={[styles.scrollView, { backgroundColor: theme.background }]}
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}
>
<ThemedView style={styles.container}>
<ThemedView style={styles.header}>
<ThemedText type="subtitle">Grab Context Playground</ThemedText>
<ThemedText themeColor="textSecondary">
Select any box below and verify the copied payload includes this hierarchy.
</ThemedText>
</ThemedView>

<ReactNativeGrabContextProvider value={rootContext}>
<ThemedView type="backgroundElement" style={styles.levelRoot}>
<ThemedText type="smallBold">Level 1: App Area</ThemedText>
<ContextLabel value={rootContext} />

<ReactNativeGrabContextProvider value={sectionContext}>
<ThemedView type="backgroundElement" style={styles.levelSection}>
<ThemedText type="smallBold">Level 2: Section</ThemedText>
<ContextLabel value={sectionContext} />

<ReactNativeGrabContextProvider value={cardContext}>
<ThemedView type="backgroundElement" style={styles.levelCard}>
<ThemedText type="smallBold">Level 3: Card</ThemedText>
<ContextLabel value={cardContext} />

<ReactNativeGrabContextProvider value={ctaContext}>
<ThemedView type="backgroundSelected" style={styles.levelAction}>
<ThemedText type="smallBold">Level 4: Action Button</ThemedText>
<ContextLabel value={ctaContext} />
<ThemedText type="small" themeColor="textSecondary">
Grab this node to get all levels merged.
</ThemedText>
</ThemedView>
</ReactNativeGrabContextProvider>
</ThemedView>
</ReactNativeGrabContextProvider>
</ThemedView>
</ReactNativeGrabContextProvider>
</ThemedView>
</ReactNativeGrabContextProvider>
</ThemedView>
</ScrollView>
</ReactNativeGrabScreen>
);
}

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,
},
});
84 changes: 84 additions & 0 deletions src/react-native/__tests__/grab-context-description.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
parent: ReactNativeFiberNode | null = null,
): ReactNativeFiberNode => ({
type: "Text",
memoizedProps: props,
return: parent,
stateNode: null,
_debugStack: new Error(),
_debugOwner: null,
});

const createContextProviderFiber = (
value: Record<string, string | number | boolean | null>,
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("<Text>");
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("<Text>");
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"');
});
});
1 change: 1 addition & 0 deletions src/react-native/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
(globalThis as { __DEV__?: boolean }).__DEV__ = true;
49 changes: 47 additions & 2 deletions src/react-native/description.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string> => {
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}`;
};
Loading