Skip to content

Commit 2635d3d

Browse files
committed
feat: support additional grab context for grabs
1 parent 53baff6 commit 2635d3d

File tree

9 files changed

+372
-4
lines changed

9 files changed

+372
-4
lines changed

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,21 @@ const config = getDefaultConfig(__dirname);
4646
module.exports = withReactNativeGrab(config);
4747

4848
// app root
49-
import { ReactNativeGrabRoot, ReactNativeGrabScreen } from "react-native-grab";
49+
import {
50+
ReactNativeGrabRoot,
51+
ReactNativeGrabScreen,
52+
ReactNativeGrabContextProvider,
53+
} from "react-native-grab";
5054

5155
// When using native navigators (native stack, native tabs), wrap each screen:
5256
function HomeScreen() {
53-
return <ReactNativeGrabScreen>{/* screen content */}</ReactNativeGrabScreen>;
57+
return (
58+
<ReactNativeGrabScreen>
59+
<ReactNativeGrabContextProvider value={{ screen: "home" }}>
60+
{/* screen content */}
61+
</ReactNativeGrabContextProvider>
62+
</ReactNativeGrabScreen>
63+
);
5464
}
5565

5666
export default function AppLayout() {
@@ -62,8 +72,11 @@ export default function AppLayout() {
6272

6373
- `ReactNativeGrabRoot`: Root-level provider for grab functionality.
6474
- `ReactNativeGrabScreen`: When using native navigators (native stack, native tabs), wrap **each screen** with this component for accurate selection.
75+
- `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.
6576
- `enableGrabbing()`: Programmatically enables grabbing flow.
6677

78+
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.
79+
6780
## Documentation
6881

6982
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:

example/src/app/(tabs)/explore.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,31 @@ export default function TabTwoScreen() {
104104
</Pressable>
105105
</Link>
106106
</Collapsible>
107+
108+
<Collapsible title="Grab context playground">
109+
<ThemedText type="small">
110+
Open a dedicated modal with nested grab context providers. Each nested element
111+
displays the context object it contributes.
112+
</ThemedText>
113+
<Link href="/context-playground" asChild>
114+
<Pressable
115+
style={({ pressed }) => [styles.modalTrigger, pressed && styles.pressed]}
116+
>
117+
<ThemedView type="backgroundElement" style={styles.modalTriggerInner}>
118+
<ThemedText type="link">Open context playground</ThemedText>
119+
<SymbolView
120+
tintColor={theme.text}
121+
name={{
122+
ios: "square.stack.3d.down.forward",
123+
android: "layers",
124+
web: "layers",
125+
}}
126+
size={14}
127+
/>
128+
</ThemedView>
129+
</Pressable>
130+
</Link>
131+
</Collapsible>
107132
</ThemedView>
108133
{Platform.OS === "web" && <WebBadge />}
109134
</ThemedView>

example/src/app/_layout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export default function MainLayout() {
1818
<Stack>
1919
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
2020
<Stack.Screen name="modal" options={{ presentation: "modal", title: "Modal" }} />
21+
<Stack.Screen
22+
name="context-playground"
23+
options={{ presentation: "modal", title: "Context Playground" }}
24+
/>
2125
</Stack>
2226
</View>
2327
</ThemeProvider>
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import React from "react";
2+
import { Platform, ScrollView, StyleSheet } from "react-native";
3+
import { useSafeAreaInsets } from "react-native-safe-area-context";
4+
5+
import { ThemedText } from "@/components/themed-text";
6+
import { ThemedView } from "@/components/themed-view";
7+
import { BottomTabInset, MaxContentWidth, Spacing } from "@/constants/theme";
8+
import { useTheme } from "@/hooks/use-theme";
9+
10+
import { ReactNativeGrabContextProvider, ReactNativeGrabScreen } from "react-native-grab";
11+
12+
const formatContext = (value: Record<string, string | number | boolean | null>) =>
13+
JSON.stringify(value);
14+
15+
const ContextLabel = ({ value }: { value: Record<string, string | number | boolean | null> }) => {
16+
return (
17+
<ThemedView type="backgroundSelected" style={styles.contextLabel}>
18+
<ThemedText type="code">adds: {formatContext(value)}</ThemedText>
19+
</ThemedView>
20+
);
21+
};
22+
23+
export default function ContextPlaygroundScreen() {
24+
const safeAreaInsets = useSafeAreaInsets();
25+
const theme = useTheme();
26+
27+
const rootContext = { area: "context-playground", release: "1.0", tracked: true } as const;
28+
const sectionContext = { section: "checkout-flow", density: "compact" } as const;
29+
const cardContext = { card: "payment-summary", currency: "USD", experiment: "B" } as const;
30+
const ctaContext = { action: "confirm-payment", priority: 1 } as const;
31+
32+
const contentPlatformStyle = Platform.select({
33+
default: {
34+
paddingTop: Spacing.three,
35+
paddingBottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
36+
paddingLeft: safeAreaInsets.left,
37+
paddingRight: safeAreaInsets.right,
38+
},
39+
android: {
40+
paddingTop: Spacing.three,
41+
paddingLeft: safeAreaInsets.left,
42+
paddingRight: safeAreaInsets.right,
43+
paddingBottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
44+
},
45+
web: {
46+
paddingTop: Spacing.six,
47+
paddingBottom: Spacing.four,
48+
},
49+
});
50+
51+
return (
52+
<ReactNativeGrabScreen>
53+
<ScrollView
54+
style={[styles.scrollView, { backgroundColor: theme.background }]}
55+
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}
56+
>
57+
<ThemedView style={styles.container}>
58+
<ThemedView style={styles.header}>
59+
<ThemedText type="subtitle">Grab Context Playground</ThemedText>
60+
<ThemedText themeColor="textSecondary">
61+
Select any box below and verify the copied payload includes this hierarchy.
62+
</ThemedText>
63+
</ThemedView>
64+
65+
<ReactNativeGrabContextProvider value={rootContext}>
66+
<ThemedView type="backgroundElement" style={styles.levelRoot}>
67+
<ThemedText type="smallBold">Level 1: App Area</ThemedText>
68+
<ContextLabel value={rootContext} />
69+
70+
<ReactNativeGrabContextProvider value={sectionContext}>
71+
<ThemedView type="backgroundElement" style={styles.levelSection}>
72+
<ThemedText type="smallBold">Level 2: Section</ThemedText>
73+
<ContextLabel value={sectionContext} />
74+
75+
<ReactNativeGrabContextProvider value={cardContext}>
76+
<ThemedView type="backgroundElement" style={styles.levelCard}>
77+
<ThemedText type="smallBold">Level 3: Card</ThemedText>
78+
<ContextLabel value={cardContext} />
79+
80+
<ReactNativeGrabContextProvider value={ctaContext}>
81+
<ThemedView type="backgroundSelected" style={styles.levelAction}>
82+
<ThemedText type="smallBold">Level 4: Action Button</ThemedText>
83+
<ContextLabel value={ctaContext} />
84+
<ThemedText type="small" themeColor="textSecondary">
85+
Grab this node to get all levels merged.
86+
</ThemedText>
87+
</ThemedView>
88+
</ReactNativeGrabContextProvider>
89+
</ThemedView>
90+
</ReactNativeGrabContextProvider>
91+
</ThemedView>
92+
</ReactNativeGrabContextProvider>
93+
</ThemedView>
94+
</ReactNativeGrabContextProvider>
95+
</ThemedView>
96+
</ScrollView>
97+
</ReactNativeGrabScreen>
98+
);
99+
}
100+
101+
const styles = StyleSheet.create({
102+
scrollView: {
103+
flex: 1,
104+
},
105+
contentContainer: {
106+
flexDirection: "row",
107+
justifyContent: "center",
108+
},
109+
container: {
110+
width: "100%",
111+
maxWidth: MaxContentWidth,
112+
paddingHorizontal: Spacing.four,
113+
gap: Spacing.four,
114+
},
115+
header: {
116+
gap: Spacing.one,
117+
},
118+
levelRoot: {
119+
borderRadius: Spacing.three,
120+
padding: Spacing.three,
121+
gap: Spacing.two,
122+
},
123+
levelSection: {
124+
borderRadius: Spacing.three,
125+
padding: Spacing.three,
126+
gap: Spacing.two,
127+
marginTop: Spacing.one,
128+
},
129+
levelCard: {
130+
borderRadius: Spacing.three,
131+
padding: Spacing.three,
132+
gap: Spacing.two,
133+
marginTop: Spacing.one,
134+
},
135+
levelAction: {
136+
borderRadius: Spacing.three,
137+
padding: Spacing.three,
138+
gap: Spacing.one,
139+
marginTop: Spacing.one,
140+
},
141+
contextLabel: {
142+
borderRadius: Spacing.two,
143+
paddingHorizontal: Spacing.two,
144+
paddingVertical: Spacing.one,
145+
},
146+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { getDescription } from "../description";
3+
import { composeGrabContextValue, ReactNativeGrabInternalContext } from "../grab-context";
4+
import type { ReactNativeFiberNode } from "../types";
5+
6+
vi.mock("../get-rendered-by", () => ({
7+
getRenderedBy: vi.fn(async () => []),
8+
}));
9+
10+
const createHostFiber = (
11+
props: Record<string, unknown>,
12+
parent: ReactNativeFiberNode | null = null,
13+
): ReactNativeFiberNode => ({
14+
type: "Text",
15+
memoizedProps: props,
16+
return: parent,
17+
stateNode: null,
18+
_debugStack: new Error(),
19+
_debugOwner: null,
20+
});
21+
22+
const createContextProviderFiber = (
23+
value: Record<string, string | number | boolean | null>,
24+
parent: ReactNativeFiberNode | null = null,
25+
): ReactNativeFiberNode => ({
26+
type: ReactNativeGrabInternalContext.Provider,
27+
memoizedProps: { value },
28+
return: parent,
29+
stateNode: null,
30+
_debugStack: new Error(),
31+
_debugOwner: null,
32+
});
33+
34+
describe("composeGrabContextValue", () => {
35+
it("returns shallow copy when parent context does not exist", () => {
36+
const result = composeGrabContextValue(null, { screen: "home", attempt: 1 });
37+
38+
expect(result).toEqual({ screen: "home", attempt: 1 });
39+
});
40+
41+
it("merges parent and child with child override precedence", () => {
42+
const result = composeGrabContextValue(
43+
{ screen: "home", theme: "light", source: "parent" },
44+
{ source: "child", variant: "hero" },
45+
);
46+
47+
expect(result).toEqual({
48+
screen: "home",
49+
theme: "light",
50+
source: "child",
51+
variant: "hero",
52+
});
53+
});
54+
});
55+
56+
describe("getDescription with grab context", () => {
57+
it("keeps current output format when no context provider is in ancestors", async () => {
58+
const selectedFiber = createHostFiber({ children: "Hello" });
59+
60+
const description = await getDescription(selectedFiber);
61+
62+
expect(description).toContain("<Text>");
63+
expect(description).toContain("Hello");
64+
expect(description).not.toContain("Context:");
65+
});
66+
67+
it("appends Context block from nearest provider value", async () => {
68+
const parentProvider = createContextProviderFiber({ screen: "home", locale: "en" });
69+
const childProvider = createContextProviderFiber(
70+
{ locale: "pl", section: "cta" },
71+
parentProvider,
72+
);
73+
const selectedFiber = createHostFiber({ children: "Tap me" }, childProvider);
74+
75+
const description = await getDescription(selectedFiber);
76+
77+
expect(description).toContain("<Text>");
78+
expect(description).toContain("Tap me");
79+
expect(description).toContain("Context:");
80+
expect(description).toContain('"locale": "pl"');
81+
expect(description).toContain('"section": "cta"');
82+
expect(description).not.toContain('"screen": "home"');
83+
});
84+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
(globalThis as { __DEV__?: boolean }).__DEV__ = true;

src/react-native/description.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { ReactNativeFiberNode } from "./types";
22
import { getRenderedBy } from "./get-rendered-by";
33
import type { RenderedByFrame } from "./get-rendered-by";
4+
import type { ReactNativeGrabContextValue } from "./grab-context";
5+
import { ReactNativeGrabInternalContext } from "./grab-context";
46

57
const MAX_STACK_LINES = 6;
68
const MAX_TEXT_LENGTH = 120;
@@ -164,12 +166,55 @@ const buildStackContext = (renderedBy: RenderedByFrame[]): string => {
164166
return lines.join("");
165167
};
166168

169+
type ReactProviderType = {
170+
Provider?: unknown;
171+
};
172+
173+
type ContextProviderFiberNode = ReactNativeFiberNode & {
174+
type?: unknown;
175+
memoizedProps?: {
176+
value?: ReactNativeGrabContextValue;
177+
} | null;
178+
};
179+
180+
const isGrabContextProviderFiber = (fiber: ContextProviderFiberNode): boolean => {
181+
const providerType = fiber.type;
182+
return (
183+
providerType === ReactNativeGrabInternalContext ||
184+
providerType === (ReactNativeGrabInternalContext as ReactProviderType).Provider
185+
);
186+
};
187+
188+
const getGrabContextFromFiber = (
189+
node: ReactNativeFiberNode,
190+
): ReactNativeGrabContextValue | null => {
191+
let current: ContextProviderFiberNode | null = node;
192+
193+
while (current) {
194+
if (isGrabContextProviderFiber(current)) {
195+
return current.memoizedProps?.value ?? null;
196+
}
197+
current = current.return ?? null;
198+
}
199+
200+
return null;
201+
};
202+
203+
const buildContextBlock = (contextValue: ReactNativeGrabContextValue | null): string => {
204+
if (!contextValue || Object.keys(contextValue).length === 0) {
205+
return "";
206+
}
207+
208+
return `\n\nContext:\n${JSON.stringify(contextValue, null, 2)}`;
209+
};
210+
167211
export const getDescription = async (node: ReactNativeFiberNode): Promise<string> => {
168212
let renderedBy = await getRenderedBy(node);
169213

170214
const preview = buildElementPreview(node, renderedBy);
171215
const stackContext = buildStackContext(renderedBy);
216+
const contextBlock = buildContextBlock(getGrabContextFromFiber(node));
172217

173-
if (!stackContext) return preview;
174-
return `${preview}${stackContext}`;
218+
if (!stackContext) return `${preview}${contextBlock}`;
219+
return `${preview}${stackContext}${contextBlock}`;
175220
};

0 commit comments

Comments
 (0)