From 39a844a06e92992155e699b25bdb85d716b518ff Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 20 Mar 2026 09:17:10 +0100 Subject: [PATCH] feat: improve grab selection labels and menu title Made-with: Cursor --- .changeset/grab-selection-labels.md | 5 + .../grab-context-description.test.ts | 111 +++++++++++++++++- src/react-native/description.ts | 40 ++++++- src/react-native/grab-overlay.tsx | 14 ++- 4 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 .changeset/grab-selection-labels.md diff --git a/.changeset/grab-selection-labels.md b/.changeset/grab-selection-labels.md new file mode 100644 index 0000000..18f5787 --- /dev/null +++ b/.changeset/grab-selection-labels.md @@ -0,0 +1,5 @@ +--- +"react-native-grab": patch +--- + +Grab labels now prefer meaningful component names from the owner stack (skipping generic `View` / `Text` wrappers), so the menu shows titles like **Text (in YourScreen)** and the copied description preview matches. The selection menu title also scales down when the label is long so it stays readable. diff --git a/src/react-native/__tests__/grab-context-description.test.ts b/src/react-native/__tests__/grab-context-description.test.ts index f562cb9..602fb12 100644 --- a/src/react-native/__tests__/grab-context-description.test.ts +++ b/src/react-native/__tests__/grab-context-description.test.ts @@ -1,12 +1,22 @@ -import { describe, expect, it, vi } from "vitest"; -import { getDescription } from "../description"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getDescription, + getGrabSelectionTitle, + GRAB_HOST_LIKE_COMPONENT_NAMES, + isHostLikeComponentName, +} from "../description"; import { composeGrabContextValue, ReactNativeGrabInternalContext } from "../grab-context"; +import type { RenderedByFrame } from "../get-rendered-by"; import type { ReactNativeFiberNode } from "../types"; vi.mock("../get-rendered-by", () => ({ getRenderedBy: vi.fn(async () => []), })); +import { getRenderedBy } from "../get-rendered-by"; + +const mockedGetRenderedBy = vi.mocked(getRenderedBy); + const createHostFiber = ( props: Record, parent: ReactNativeFiberNode | null = null, @@ -31,6 +41,88 @@ const createContextProviderFiber = ( _debugOwner: null, }); +const frame = (name: string): RenderedByFrame => ({ + name, + file: null, + line: null, + column: null, + collapse: false, +}); + +describe("isHostLikeComponentName", () => { + it("treats View and Text as host-like", () => { + for (const name of GRAB_HOST_LIKE_COMPONENT_NAMES) { + expect(isHostLikeComponentName(name)).toBe(true); + } + expect(isHostLikeComponentName("InstallTabs")).toBe(false); + }); + + it("trims names before matching", () => { + expect(isHostLikeComponentName(" Text ")).toBe(true); + }); +}); + +describe("getGrabSelectionTitle", () => { + it("skips host-like owners to show Text (in InstallTabs)", () => { + const fiber = createHostFiber({ children: "x" }); + expect(getGrabSelectionTitle(fiber, [frame("Text"), frame("InstallTabs")])).toBe( + "Text (in InstallTabs)", + ); + }); + + it("skips multiple host-like owners", () => { + const viewFiber: ReactNativeFiberNode = { + type: "View", + memoizedProps: {}, + return: null, + stateNode: null, + _debugStack: new Error(), + _debugOwner: null, + }; + expect(getGrabSelectionTitle(viewFiber, [frame("View"), frame("Text"), frame("Screen")])).toBe( + "View (in Screen)", + ); + }); + + it("returns host only when every owner is host-like", () => { + const viewFiber: ReactNativeFiberNode = { + type: "View", + memoizedProps: {}, + return: null, + stateNode: null, + _debugStack: new Error(), + _debugOwner: null, + }; + expect(getGrabSelectionTitle(viewFiber, [frame("View"), frame("Text")])).toBe("View"); + }); + + it("returns Selected element when host is unknown", () => { + const fiber = { + type: () => null, + memoizedProps: {}, + return: null, + stateNode: null, + _debugStack: new Error(), + _debugOwner: null, + } as ReactNativeFiberNode; + expect(getGrabSelectionTitle(fiber, [])).toBe("Selected element"); + }); + + it("uses host-like name from renderedBy when the fiber has no string host", () => { + const fiber = { + type: () => null, + memoizedProps: {}, + return: null, + stateNode: null, + _debugStack: new Error(), + _debugOwner: null, + } as ReactNativeFiberNode; + expect(getGrabSelectionTitle(fiber, [frame("Text"), frame("InstallTabs")])).toBe( + "Text (in InstallTabs)", + ); + }); +}); + describe("composeGrabContextValue", () => { it("returns shallow copy when parent context does not exist", () => { const result = composeGrabContextValue(null, { screen: "home", attempt: 1 }); @@ -54,6 +146,11 @@ describe("composeGrabContextValue", () => { }); describe("getDescription with grab context", () => { + beforeEach(() => { + mockedGetRenderedBy.mockReset(); + mockedGetRenderedBy.mockResolvedValue([]); + }); + it("keeps current output format when no context provider is in ancestors", async () => { const selectedFiber = createHostFiber({ children: "Hello" }); @@ -64,6 +161,16 @@ describe("getDescription with grab context", () => { expect(description).not.toContain("Context:"); }); + it("uses first non-host-like renderedBy name for the preview tag", async () => { + mockedGetRenderedBy.mockResolvedValue([frame("Text"), frame("InstallTabs")]); + const selectedFiber = createHostFiber({ children: "Hello" }); + + const description = await getDescription(selectedFiber); + + expect(description.startsWith(" { const parentProvider = createContextProviderFiber({ screen: "home", locale: "en" }); const childProvider = createContextProviderFiber( diff --git a/src/react-native/description.ts b/src/react-native/description.ts index 4844033..792a510 100644 --- a/src/react-native/description.ts +++ b/src/react-native/description.ts @@ -18,6 +18,26 @@ const PRIORITY_ATTRS = [ "accessibilityValue", ] as const; +/** Owner names treated as host-like when resolving `Text (in Owner)` for the grab menu. */ +export const GRAB_HOST_LIKE_COMPONENT_NAMES = ["View", "Text"] as const; + +const HOST_LIKE_NAME_SET = new Set(GRAB_HOST_LIKE_COMPONENT_NAMES); + +export const isHostLikeComponentName = (name: string): boolean => + HOST_LIKE_NAME_SET.has(name.trim()); + +const firstHostLikeRenderedByName = (renderedBy: RenderedByFrame[]): string | null => { + const frame = renderedBy.find((f) => isHostLikeComponentName(f.name)); + const trimmed = frame?.name?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +}; + +const firstNonHostRenderedByName = (renderedBy: RenderedByFrame[]): string | null => { + const frame = renderedBy.find((f) => !isHostLikeComponentName(f.name)); + const trimmed = frame?.name?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +}; + const truncate = (value: string, maxLength: number): string => { if (value.length <= maxLength) return value; return `${value.slice(0, maxLength - 3)}...`; @@ -120,8 +140,8 @@ const getPreviewComponentName = ( node: ReactNativeFiberNode, renderedBy: RenderedByFrame[], ): string => { - const firstRenderedBy = renderedBy[0]?.name?.trim(); - if (firstRenderedBy) return firstRenderedBy; + const fromRenderedBy = firstNonHostRenderedByName(renderedBy); + if (fromRenderedBy) return fromRenderedBy; const hostFiber = getHostFiber(node); return getHostComponentName(hostFiber); @@ -208,6 +228,22 @@ const buildContextBlock = (contextValue: ReactNativeGrabContextValue | null): st return `\n\nContext:\n${JSON.stringify(contextValue, null, 2)}`; }; +export const getGrabSelectionTitle = ( + node: ReactNativeFiberNode, + renderedBy: RenderedByFrame[], +): string => { + const fromFiber = getHostComponentName(getHostFiber(node)); + const rawHostLabel = + firstHostLikeRenderedByName(renderedBy) ?? (fromFiber !== "(unknown)" ? fromFiber : null); + const hostUnknown = rawHostLabel == null; + const hostLabel = hostUnknown ? "Selected element" : rawHostLabel; + const ownerName = firstNonHostRenderedByName(renderedBy); + if (ownerName && ownerName !== hostLabel) { + return `${hostLabel} (in ${ownerName})`; + } + return hostLabel; +}; + export const getDescription = async (node: ReactNativeFiberNode): Promise => { let renderedBy = await getRenderedBy(node); diff --git a/src/react-native/grab-overlay.tsx b/src/react-native/grab-overlay.tsx index 71e1d8d..24e205d 100644 --- a/src/react-native/grab-overlay.tsx +++ b/src/react-native/grab-overlay.tsx @@ -20,7 +20,7 @@ import { showGrabSelectionMenu, unregisterLocalGrabSelectionController, } from "./grab-controller"; -import { getDescription } from "./description"; +import { getDescription, getGrabSelectionTitle } from "./description"; import { getRenderedBy, type RenderedByFrame } from "./get-rendered-by"; import { findNodeAtPoint, measureInWindow } from "./measure"; import { openStackFrameInEditor } from "./open"; @@ -178,8 +178,7 @@ export const ReactNativeGrabOverlay = ({ ]); const firstFrame = renderedBy.find((frame) => Boolean(frame.file)) ?? null; - const elementNameMatch = description.match(/<([A-Za-z0-9_$.:-]+)/); - const elementName = elementNameMatch?.[1] ?? firstFrame?.name ?? "Selected element"; + const elementName = getGrabSelectionTitle(result.fiberNode, renderedBy); setState((prev) => ({ ...prev, @@ -369,7 +368,12 @@ export const ReactNativeGrabOverlay = ({ visible={state.selectedElement !== null} > - + {state.selectedElement?.elementName} @@ -437,10 +441,12 @@ const styles = StyleSheet.create({ borderColor: GRAB_PRIMARY, }, selectionMenuHeader: { + alignSelf: "stretch", paddingHorizontal: 14, paddingVertical: 12, }, selectionMenuTitle: { + width: "100%", color: "#111111", fontSize: 14, fontWeight: "600",