diff --git a/.github/workflows/test-perf.yml b/.github/workflows/test-perf.yml index e78137565..e42e29267 100644 --- a/.github/workflows/test-perf.yml +++ b/.github/workflows/test-perf.yml @@ -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 diff --git a/README.md b/README.md index 836896efc..07c12387a 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/apps/web-extension/src/background/service-worker.ts b/apps/web-extension/src/background/service-worker.ts index 8034b2d9e..f99982dc0 100644 --- a/apps/web-extension/src/background/service-worker.ts +++ b/apps/web-extension/src/background/service-worker.ts @@ -2,8 +2,7 @@ const STORAGE_KEY = "react_grab_enabled"; const getGlobalEnabled = async (): Promise => { 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 => { diff --git a/apps/web-extension/src/content/bridge.ts b/apps/web-extension/src/content/bridge.ts index 0fc0002ae..628bba016 100644 --- a/apps/web-extension/src/content/bridge.ts +++ b/apps/web-extension/src/content/bridge.ts @@ -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; @@ -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; @@ -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 }); } }); diff --git a/apps/web-extension/src/content/react-grab.ts b/apps/web-extension/src/content/react-grab.ts index fddd9e43f..67dbdd3ee 100644 --- a/apps/web-extension/src/content/react-grab.ts +++ b/apps/web-extension/src/content/react-grab.ts @@ -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 { @@ -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; @@ -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; } @@ -137,11 +132,24 @@ const handleToolbarStateChange = async (state: ToolbarState): Promise => { }; 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); } }); @@ -158,12 +166,15 @@ const queryInitialState = (): Promise => { }, 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, }); } }; diff --git a/apps/web-extension/src/is-toolbar-state.ts b/apps/web-extension/src/is-toolbar-state.ts new file mode 100644 index 000000000..6163aea0d --- /dev/null +++ b/apps/web-extension/src/is-toolbar-state.ts @@ -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; + 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") + ); +}; diff --git a/apps/website/hooks/use-mobile.ts b/apps/website/hooks/use-mobile.ts deleted file mode 100644 index 502fd3239..000000000 --- a/apps/website/hooks/use-mobile.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "react"; - -const MOBILE_BREAKPOINT = 768; - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined); - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - }; - mql.addEventListener("change", onChange); - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - return () => mql.removeEventListener("change", onChange); - }, []); - - return !!isMobile; -} diff --git a/package.json b/package.json index fae214018..f42a9bcea 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/src/commands/configure.ts b/packages/cli/src/commands/configure.ts index f83114ffa..2d50c4410 100644 --- a/packages/cli/src/commands/configure.ts +++ b/packages/cli/src/commands/configure.ts @@ -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, @@ -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.`); @@ -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."); diff --git a/packages/cli/src/utils/react-grab-setup-files.ts b/packages/cli/src/utils/react-grab-setup-files.ts index 409dfac6a..042590309 100644 --- a/packages/cli/src/utils/react-grab-setup-files.ts +++ b/packages/cli/src/utils/react-grab-setup-files.ts @@ -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)); diff --git a/packages/react-grab/e2e/copy-failure.spec.ts b/packages/react-grab/e2e/copy-failure.spec.ts index 2cd238af6..c63ff28b7 100644 --- a/packages/react-grab/e2e/copy-failure.spec.ts +++ b/packages/react-grab/e2e/copy-failure.spec.ts @@ -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, }) => { diff --git a/packages/react-grab/src/core/edit-mode.ts b/packages/react-grab/src/core/edit-mode.ts index 6cb4a75e0..78400ad0d 100644 --- a/packages/react-grab/src/core/edit-mode.ts +++ b/packages/react-grab/src/core/edit-mode.ts @@ -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"; @@ -148,7 +147,6 @@ export const createEditModeController = ( setState({ element, position, - selectionBounds: createElementBounds(element), properties, preview: createPreviewStyles(element), filePath: resolvedFilePath, @@ -188,7 +186,6 @@ export const createEditModeController = ( setState({ element, position, - selectionBounds: createElementBounds(element), properties, preview: createPreviewStyles(element), tagName: getTagName(element), diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 49c3df2d5..8367b914d 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -269,10 +269,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ); }); // True only when the drag has actually moved beyond the click threshold. - // We use this for selection-visibility decisions so a click (which - // momentarily enters the dragging-select phase between pointerdown and - // pointerup) does not flash the selection bounds off and back on. - const isActivelyDragging = createMemo(() => { + // We use this for selection/drag-box visibility decisions so a click + // (which momentarily enters the dragging-select phase between pointerdown + // and pointerup) does not flash the selection bounds off and back on. + const isDraggingBeyondThreshold = createMemo(() => { if (!isDragging()) return false; const deltaX = Math.abs(pointer().x + window.scrollX - store.dragStart.x); const deltaY = Math.abs(pointer().y + window.scrollY - store.dragStart.y); @@ -752,6 +752,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { actions.activate(); startCopyFeedbackCooldown(); } else { + // Leave the copying state before clearing the selection: unfreeze is a + // no-op outside the active state, and staying in copying strands the + // overlay (progress cursor, dead hover, swallowed activation keys). + actions.activate(); actions.unfreeze(); } }; @@ -873,7 +877,12 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ); } if (current().state === "copying") { - actions.unfreeze(); + if (copy.shouldDeactivateAfter) { + deactivateRenderer(); + } else { + actions.activate(); + actions.unfreeze(); + } } }); }; @@ -963,7 +972,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { void viewportVersion(); if ( !isRendererActive() || - isActivelyDragging() || + isDraggingBeyondThreshold() || isSelectionInteractionLocked() || keyboardSelection.isPendingDismiss() ) @@ -1100,7 +1109,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (store.isTouchMode && isDragging()) { return isRendererActive(); } - return isRendererActive() && !isActivelyDragging(); + return isRendererActive() && !isDraggingBeyondThreshold(); }; const frozenElementBoundsAccessors = mapArray( @@ -1180,14 +1189,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }; }; - const isDraggingBeyondThreshold = createMemo(() => { - if (!isDragging()) return false; - - const dragDistance = calculateDragDistance(pointer().x, pointer().y); - - return dragDistance.x > DRAG_THRESHOLD_PX || dragDistance.y > DRAG_THRESHOLD_PX; - }); - const calculateDragRectangle = (endX: number, endY: number) => { const { pageX: endPageX, pageY: endPageY } = toPageCoordinates(endX, endY); @@ -1683,15 +1684,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } const element = store.frozenElement || targetElement(); if (!element) return; - openEditMode(element, { x: pointer().x, y: pointer().y }); + editMode.trigger(element, { x: pointer().x, y: pointer().y }); }; - const openEditMode = ( - element: Element, - position: Position, - overrides: EditModeOverrides = {}, - ): boolean => editMode.trigger(element, position, overrides); - const tryHandleEditModeElementSwitch = (clientX: number, clientY: number): boolean => { if (!editMode.isOpen() || store.contextMenuPosition !== null) return false; const element = getElementsAtPoint(clientX, clientY).find(isValidGrabbableElement); @@ -1733,7 +1728,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } if (actionId === EDIT_ACTION_ID) { - const didOpen = openEditMode(element, position, currentSelectionEditOverrides(element)); + const didOpen = editMode.trigger(element, position, currentSelectionEditOverrides(element)); if (!didOpen) return true; actions.clearInputText(); actions.exitPromptMode(); @@ -1803,7 +1798,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (!actionId) return; if (actionId === EDIT_ACTION_ID) { - openEditMode(element, position, currentSelectionEditOverrides(element)); + editMode.trigger(element, position, currentSelectionEditOverrides(element)); return; } @@ -1868,14 +1863,14 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { // elementFromPoint pass (~20ms on 100k-node DOMs). cancelActiveDrag // redetects on cancel; a committed drag enters the frozen phase. if ( - !isActivelyDragging() && + !isDraggingBeyondThreshold() && now - elementDetectionState.lastDetectionTimestamp >= ELEMENT_DETECTION_THROTTLE_MS && !isDetectionPending ) { elementDetectionState.lastDetectionTimestamp = now; elementDetectionState.pendingDetectionScheduledAt = now; setTimeout(() => { - if (isElementDetectionBlocked() || isActivelyDragging()) { + if (isElementDetectionBlocked() || isDraggingBeyondThreshold()) { elementDetectionState.pendingDetectionScheduledAt = 0; return; } @@ -2979,7 +2974,13 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const didHandle = handlePointerDown(event.clientX, event.clientY, event.shiftKey); if (didHandle) { if (event.pointerId !== undefined) { - document.documentElement.setPointerCapture(event.pointerId); + // setPointerCapture throws NotFoundError for inactive pointer ids + // (synthetic events, some Firefox touch paths); a throw here would + // skip preventDefault and leak the pointerdown to the app while a + // drag is already tracked. + try { + document.documentElement.setPointerCapture(event.pointerId); + } catch {} } event.preventDefault(); event.stopImmediatePropagation(); @@ -3666,7 +3667,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }; const enterEditModeAction = () => { - const didOpen = openEditMode(element, position, { + const didOpen = editMode.trigger(element, position, { filePath, lineNumber, componentName, diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index ffececa3a..fb88fee51 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -267,14 +267,12 @@ export interface PreviewStyles { export interface EditPanelState { element: Element; position: Position; - selectionBounds: OverlayBounds; properties: EditableProperty[]; preview: PreviewStyles; filePath?: string; lineNumber?: number; componentName?: string; tagName?: string; - htmlPreview?: string; initialSearchQuery?: string; hasSessionEdits?: boolean; designTokens?: DesignTokenResolver;