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;