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
2 changes: 1 addition & 1 deletion .github/workflows/test-perf.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
# ─── Baseline run (PR only) ────────────────────────────────────────
# Swap `packages/react-grab/src/` to the base ref so the same
# bench harness measures the OLD library code. The harness itself
# (`e2e/perf-*.ts`, `scripts/diff-perf-runs.mjs`) and the e2e-app
# (`e2e/perf-*.ts`, `scripts/diff-perf-runs.mjs`) and the fixture-app
# stimulus stay at HEAD so the only variable across runs is
# react-grab's source.
- name: Swap react-grab src to base ref
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

Copy any UI element for your agent.

React Grab points agents to the actual source behind each selection. Agents are [**2× faster**](https://benchmark.react-grab.com/) and more accurate when using React Grab.
React Grab points agents to the actual source behind each selection. Agents are [**2× faster**](https://www.react-grab.com/benchmarks) and more accurate when using React Grab.

[**Website →**](https://react-grab.com)

Expand Down
3 changes: 1 addition & 2 deletions apps/web-extension/src/background/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ const STORAGE_KEY = "react_grab_enabled";

const getGlobalEnabled = async (): Promise<boolean> => {
const result = await chrome.storage.local.get(STORAGE_KEY);
const enabled = result[STORAGE_KEY] ?? true;
return enabled;
return result[STORAGE_KEY] !== false;
};

const setGlobalEnabled = async (enabled: boolean): Promise<void> => {
Expand Down
14 changes: 13 additions & 1 deletion apps/web-extension/src/content/bridge.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// This script runs in ISOLATED world and bridges chrome.runtime messages to MAIN world

import { isToolbarState } from "../is-toolbar-state.js";

chrome.storage.onChanged.addListener((changes) => {
if (changes.react_grab_enabled) {
const newEnabled = changes.react_grab_enabled.newValue ?? true;
Expand Down Expand Up @@ -28,6 +30,13 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
});

window.addEventListener("message", (event) => {
// Blocks cross-origin iframes from postMessaging the parent window into
// extension storage. Same-page scripts can still post these types — a
// shared-secret handshake with the MAIN world is impossible since any token
// exchanged over this channel is readable by the page — which is why the
// payload is also shape-validated before it is persisted.
if (event.source !== window) return;

if (event.data?.type === "__REACT_GRAB_QUERY_STATE__") {
chrome.storage.local.get(["react_grab_enabled", "react_grab_toolbar_state"], (result) => {
const enabled = result.react_grab_enabled ?? true;
Expand All @@ -44,7 +53,10 @@ window.addEventListener("message", (event) => {
});
}

if (event.data?.type === "__REACT_GRAB_TOOLBAR_STATE_SAVE__") {
if (
event.data?.type === "__REACT_GRAB_TOOLBAR_STATE_SAVE__" &&
isToolbarState(event.data.state)
) {
chrome.storage.local.set({ react_grab_toolbar_state: event.data.state });
}
});
35 changes: 23 additions & 12 deletions apps/web-extension/src/content/react-grab.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { init } from "react-grab/core";
import type { Options, ReactGrabAPI } from "react-grab";
import type { Options, ReactGrabAPI, ToolbarState } from "react-grab";
import TurndownService from "turndown";
import { LOCALHOST_INIT_DELAY_MS, STATE_QUERY_TIMEOUT_MS } from "../constants.js";
import { isToolbarState } from "../is-toolbar-state.js";

declare global {
interface Window {
Expand All @@ -16,13 +17,6 @@ const isLocalhost =

const turndownService = new TurndownService();

interface ToolbarState {
edge: "top" | "bottom" | "left" | "right";
ratio: number;
collapsed: boolean;
enabled: boolean;
}

let extensionApi: ReactGrabAPI | null = null;
let lastToolbarState: ToolbarState | null = null;
let isApplyingExternalState = false;
Expand All @@ -36,7 +30,8 @@ const handleToolbarStateFromApi = (toolbarState: ToolbarState | null): void => {
lastToolbarState.edge === toolbarState.edge &&
lastToolbarState.ratio === toolbarState.ratio &&
lastToolbarState.collapsed === toolbarState.collapsed &&
lastToolbarState.enabled === toolbarState.enabled
lastToolbarState.enabled === toolbarState.enabled &&
lastToolbarState.defaultAction === toolbarState.defaultAction
) {
return;
}
Expand Down Expand Up @@ -137,11 +132,24 @@ const handleToolbarStateChange = async (state: ToolbarState): Promise<void> => {
};

window.addEventListener("message", (event: MessageEvent) => {
if (event.data?.type === "__REACT_GRAB_EXTENSION_TOGGLE__") {
// Blocks cross-origin iframes from driving the tool by postMessaging the
// parent window. Same-page scripts can still post these types, but they
// share this JS context and can already call window.__REACT_GRAB__
// directly, so a stronger handshake would add nothing (any token held here
// is readable by the page).
if (event.source !== window) return;

if (
event.data?.type === "__REACT_GRAB_EXTENSION_TOGGLE__" &&
typeof event.data.enabled === "boolean"
) {
void handleToggle(event.data.enabled);
}

if (event.data?.type === "__REACT_GRAB_TOOLBAR_STATE_CHANGE__") {
if (
event.data?.type === "__REACT_GRAB_TOOLBAR_STATE_CHANGE__" &&
isToolbarState(event.data.state)
) {
void handleToolbarStateChange(event.data.state);
}
});
Expand All @@ -158,12 +166,15 @@ const queryInitialState = (): Promise<InitialState> => {
}, STATE_QUERY_TIMEOUT_MS);

const handler = (event: MessageEvent) => {
if (event.source !== window) return;
if (event.data?.type === "__REACT_GRAB_STATE_RESPONSE__") {
clearTimeout(timeout);
window.removeEventListener("message", handler);
resolve({
enabled: event.data.enabled ?? true,
toolbarState: event.data.toolbarState ?? null,
// Validate on read too: storage may hold a value written before
// saves were shape-checked.
toolbarState: isToolbarState(event.data.toolbarState) ? event.data.toolbarState : null,
});
}
};
Expand Down
17 changes: 17 additions & 0 deletions apps/web-extension/src/is-toolbar-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { ToolbarState } from "react-grab";

const TOOLBAR_EDGES = ["top", "bottom", "left", "right"];

export const isToolbarState = (value: unknown): value is ToolbarState => {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.edge === "string" &&
TOOLBAR_EDGES.includes(candidate.edge) &&
typeof candidate.ratio === "number" &&
Number.isFinite(candidate.ratio) &&
typeof candidate.collapsed === "boolean" &&
typeof candidate.enabled === "boolean" &&
(candidate.defaultAction === undefined || typeof candidate.defaultAction === "string")
);
};
19 changes: 0 additions & 19 deletions apps/website/hooks/use-mobile.ts

This file was deleted.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"format": "vp fmt",
"format:check": "vp fmt --check",
"check": "vp check",
"extension:dev": "pnpm --filter web-extension dev",
"extension:build": "pnpm --filter web-extension build",
"extension:dev": "pnpm --filter @react-grab/web-extension dev",
"extension:build": "pnpm --filter @react-grab/web-extension build",
"changeset": "changeset",
"version": "changeset version",
"check:provenance": "node scripts/check-publish-provenance.mjs",
Expand Down
24 changes: 3 additions & 21 deletions packages/cli/src/commands/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { highlighter } from "../utils/highlighter.js";
import { logger } from "../utils/logger.js";
import { spinner } from "../utils/spinner.js";
import {
applyTransform,
previewCdnTransform,
previewOptionsTransform,
type ReactGrabOptions,
} from "../utils/transform.js";
import { applyTransformWithFeedback } from "../utils/cli-helpers.js";
import {
MAX_SUGGESTIONS_COUNT,
MAX_KEY_HOLD_DURATION_MS,
Expand Down Expand Up @@ -341,16 +341,7 @@ export const configure = new Command()
}
}

const writeSpinner = spinner(`Applying changes to ${result.filePath}.`).start();
const writeResult = applyTransform(result);
if (!writeResult.success) {
writeSpinner.fail();
logger.break();
logger.error(writeResult.error || "Failed to write file.");
logger.break();
process.exit(1);
}
writeSpinner.succeed();
applyTransformWithFeedback(result);

logger.break();
logger.log(`${highlighter.success("Success!")} CDN updated.`);
Expand Down Expand Up @@ -579,16 +570,7 @@ export const configure = new Command()
}
}

const writeSpinner = spinner(`Applying changes to ${result.filePath}.`).start();
const writeResult = applyTransform(result);
if (!writeResult.success) {
writeSpinner.fail();
logger.break();
logger.error(writeResult.error || "Failed to write file.");
logger.break();
process.exit(1);
}
writeSpinner.succeed();
applyTransformWithFeedback(result);
} else {
logger.break();
logger.log("No changes needed.");
Expand Down
3 changes: 0 additions & 3 deletions packages/cli/src/utils/react-grab-setup-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,6 @@ export const findLayoutFile = (projectRoot: string): string | null =>
export const findDocumentFile = (projectRoot: string): string | null =>
findExistingFile(getDocumentFileCandidates(projectRoot));

export const findInstrumentationFile = (projectRoot: string): string | null =>
findExistingFile(getInstrumentationFileCandidates(projectRoot));

export const findIndexHtml = (projectRoot: string): string | null =>
findExistingFile(getIndexHtmlCandidates(projectRoot));

Expand Down
20 changes: 20 additions & 0 deletions packages/react-grab/e2e/copy-failure.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,26 @@ test.describe("Copy failure feedback", () => {
.toBe(false);
});

test("a failed copy that keeps the overlay open leaves the copying state", async ({
reactGrab,
}) => {
await forceCopyResult(reactGrab, false);

await reactGrab.activate();
await reactGrab.hoverUntilSelected("li");
// Modifier-click keeps the overlay active after the copy, which is the
// path that used to strand the state machine in "copying" on failure.
await reactGrab.page
.locator("li")
.first()
.click({ force: true, modifiers: ["ControlOrMeta"] });
await readErrorView(reactGrab);

await expect
.poll(async () => (await reactGrab.getState()).isCopying, { timeout: 5000 })
.toBe(false);
});

test("keeps the error label visible past the success-label fade window", async ({
reactGrab,
}) => {
Expand Down
3 changes: 0 additions & 3 deletions packages/react-grab/src/core/edit-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type {
} from "../types.js";
import { buildEditableProperties } from "../utils/build-editable-properties.js";
import { collectDesignTokens } from "../utils/collect-design-tokens.js";
import { createElementBounds } from "../utils/create-element-bounds.js";
import { formatSessionEditsPrompt } from "../utils/format-edit-prompt.js";
import { getTagName } from "../utils/get-tag-name.js";
import { createPreviewStyles } from "../utils/preview-styles.js";
Expand Down Expand Up @@ -148,7 +147,6 @@ export const createEditModeController = (
setState({
element,
position,
selectionBounds: createElementBounds(element),
properties,
preview: createPreviewStyles(element),
filePath: resolvedFilePath,
Expand Down Expand Up @@ -188,7 +186,6 @@ export const createEditModeController = (
setState({
element,
position,
selectionBounds: createElementBounds(element),
properties,
preview: createPreviewStyles(element),
tagName: getTagName(element),
Expand Down
Loading
Loading