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
5 changes: 5 additions & 0 deletions .changeset/grab-selection-labels.md
Original file line number Diff line number Diff line change
@@ -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.
111 changes: 109 additions & 2 deletions src/react-native/__tests__/grab-context-description.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
parent: ReactNativeFiberNode | null = null,
Expand All @@ -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 });
Expand All @@ -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" });

Expand All @@ -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("<InstallTabs")).toBe(true);
expect(description).toContain("Hello");
});

it("appends Context block from nearest provider value", async () => {
const parentProvider = createContextProviderFiber({ screen: "home", locale: "en" });
const childProvider = createContextProviderFiber(
Expand Down
40 changes: 38 additions & 2 deletions src/react-native/description.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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)}...`;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string> => {
let renderedBy = await getRenderedBy(node);

Expand Down
14 changes: 10 additions & 4 deletions src/react-native/grab-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -369,7 +368,12 @@ export const ReactNativeGrabOverlay = ({
visible={state.selectedElement !== null}
>
<View style={styles.selectionMenuHeader}>
<Text numberOfLines={1} style={styles.selectionMenuTitle}>
<Text
adjustsFontSizeToFit
minimumFontScale={0.65}
numberOfLines={1}
style={styles.selectionMenuTitle}
>
{state.selectedElement?.elementName}
</Text>
</View>
Expand Down Expand Up @@ -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",
Expand Down
Loading