Skip to content
Open
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: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from "commander";
import { add } from "./commands/add.js";
import { configure } from "./commands/configure.js";
import { init } from "./commands/init.js";
import { peek } from "./commands/peek.js";
import { pull } from "./commands/pull.js";
import { remove } from "./commands/remove.js";
import { stop } from "./commands/stop.js";
Expand Down Expand Up @@ -32,6 +33,7 @@ program.addCommand(remove);
program.addCommand(configure);
program.addCommand(upgrade);
program.addCommand(pull);
program.addCommand(peek);
program.addCommand(stop);
program.addCommand(watch, { hidden: true });

Expand Down
96 changes: 96 additions & 0 deletions packages/cli/src/commands/peek.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import path from "node:path";
import { Command } from "commander";
import {
createReader,
extractPrompt,
isGrabText,
NO_READER_MESSAGE,
prepareWorkDir,
} from "../utils/clipboard.js";
import { DEFAULT_WATCH_DIR } from "../utils/constants.js";

interface PeekOptions {
dir: string;
textOnly?: boolean;
}

interface GrabPayload {
timestamp?: number;
version?: string;
content?: unknown;
entries?: unknown;
implicit?: boolean;
}

const fail = (message: string): never => {
process.stderr.write(`react-grab peek: ${message}\n`);
process.exit(1);
};

export const peek = new Command()
.name("peek")
.description(
"read the current clipboard once and print it if it contains a React Grab grab (no daemon needed)",
)
.option("-d, --dir <dir>", "work dir for the compiled native binary", DEFAULT_WATCH_DIR)
.option("--text-only", "skip the native reader and use the plain-text fallback")
.action((options: PeekOptions) => {
const dir = path.resolve(options.dir);

try {
prepareWorkDir(dir);
} catch (error) {
fail(String((error as Error)?.message ?? error));
}

const reader = createReader({ textOnly: Boolean(options.textOnly), workDir: dir });
if (!reader) {
fail(NO_READER_MESSAGE);
}

const snapshot = reader.read();
if (!snapshot) {
process.exit(0);
}

let parsedGrab: GrabPayload | null = null;
if (snapshot.grab) {
try {
parsedGrab = JSON.parse(snapshot.grab) as GrabPayload;
} catch {}
}

if (parsedGrab && typeof parsedGrab.timestamp === "number") {
const record: Record<string, unknown> = {
source: parsedGrab.implicit ? "viewport" : "custom",
timestamp: parsedGrab.timestamp,
version: typeof parsedGrab.version === "string" ? parsedGrab.version : undefined,
content: typeof parsedGrab.content === "string" ? parsedGrab.content : "",
entries: Array.isArray(parsedGrab.entries) ? parsedGrab.entries : [],
implicit: Boolean(parsedGrab.implicit),
id: `peek-${parsedGrab.timestamp}`,
receivedAt: Date.now(),
};
const prompt = extractPrompt(record);
if (prompt) record.prompt = prompt;
process.stdout.write(`${JSON.stringify(record)}\n`, () => process.exit(0));
return;
}

if (snapshot.text && isGrabText(snapshot.text)) {
const now = Date.now();
const record = {
source: "text",
timestamp: now,
content: snapshot.text,
entries: [],
implicit: false,
id: `peek-${now}`,
receivedAt: now,
};
Comment on lines +82 to +90

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Inconsistent timestamps in text-fallback record due to separate Date.now() calls

In the text-fallback path, timestamp (line 83), the timestamp embedded in id (line 87), and receivedAt (line 88) each call Date.now() independently, so they can produce different values. This is inconsistent with the structured-grab path (lines 66-72) where parsedGrab.timestamp is reused for both timestamp and id. A downstream consumer matching id against timestamp would see a mismatch.

Text-fallback path with three separate Date.now() calls
const record = {
    source: "text",
    timestamp: Date.now(),       // Call 1
    content: snapshot.text,
    entries: [],
    implicit: false,
    id: `peek-${Date.now()}`,    // Call 2 — may differ
    receivedAt: Date.now(),      // Call 3 — may differ
};
Suggested change
const record = {
source: "text",
timestamp: Date.now(),
content: snapshot.text,
entries: [],
implicit: false,
id: `peek-${Date.now()}`,
receivedAt: Date.now(),
};
const record = {
source: "text",
timestamp: Date.now(),
content: snapshot.text,
entries: [],
implicit: false,
id: `peek-${Date.now()}`,
receivedAt: Date.now(),
};
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b3b620e — now captures Date.now() once into now and reuses it for timestamp, id, and receivedAt.

process.stdout.write(`${JSON.stringify(record)}\n`, () => process.exit(0));
return;
}

process.exit(0);
});
6 changes: 6 additions & 0 deletions packages/react-grab/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,3 +338,9 @@ export const RELEVANT_CSS_PROPERTIES = new Set([
"object-fit",
"object-position",
]);

export const VIEWPORT_CLIPBOARD_DEBOUNCE_MS = 3000;
export const VIEWPORT_SAMPLE_COLUMNS = 6;
export const VIEWPORT_SAMPLE_ROWS = 6;
export const VIEWPORT_SAMPLE_EDGE_INSET_PX = 8;
export const VIEWPORT_MAX_ELEMENTS = 30;
16 changes: 16 additions & 0 deletions packages/react-grab/src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ import { generateId } from "../utils/generate-id.js";
import { logRecoverableError } from "../utils/log-recoverable-error.js";
import { getNearestEdge } from "../utils/get-nearest-edge.js";
import { findShortcutAction } from "../utils/action-shortcuts.js";
import { createViewportClipboard } from "./viewport-clipboard.js";

const builtInPlugins = [copyPlugin, editPlugin, commentPlugin, openPlugin];

Expand Down Expand Up @@ -1996,6 +1997,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
};

const eventListenerManager = createEventListenerManager();
const viewportClipboard = createViewportClipboard();

const keyboardClaimer = setupKeyboardEventClaimer();

Expand Down Expand Up @@ -2570,6 +2572,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
if (!event.isPrimary) return;
const isTouchPointer = event.pointerType === "touch";
actions.setTouchMode(isTouchPointer);
if (!isActivated() && !isHoldingKeys() && !isCopying() && isEnabled()) {
viewportClipboard.scheduleWrite();
}
if (isEventFromOverlay(event, "data-react-grab-ignore-events")) return;
if (isModalPopoverOpen()) return;
if (isSelectionInteractionLocked()) return;
Expand All @@ -2594,6 +2599,16 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
{ passive: true },
);

eventListenerManager.addWindowListener(
"scroll",
() => {
if (!isActivated() && !isHoldingKeys() && !isCopying() && isEnabled()) {
viewportClipboard.scheduleWrite();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idle grab overwrites explicit copy

Medium Severity

Implicit scheduleWrite runs whenever the session is not active, not holding, and not copying. After an explicit grab finishes, state becomes justCopied then often inactive, so pointer or scroll activity can debounce a viewport write that replaces the user’s structured clipboard payload.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 47b95b6. Configure here.

},
{ passive: true },

@vercel vercel Bot Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scroll event listener on window needs capture: true to catch scroll events from nested overflow: auto containers

Fix on Vercel

);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scroll listener misses nested scrollers

Medium Severity

The new scroll handler is registered on window with { passive: true } only. scroll events do not bubble, so this listener misses scrolling inside nested overflow containers. Viewport clipboard updates then rely on pointermove, leaving implicit context stale after in-panel scroll.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ac22acf. Configure here.


eventListenerManager.addWindowListener(
"pointerdown",
(event: PointerEvent) => {
Expand Down Expand Up @@ -3665,6 +3680,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
dispose: () => {
disposed = true;
hasInited = false;
viewportClipboard.cancel();
disposeRenderer?.();
stopToolbarMenuTracking?.();
stopToolbarMenuTracking = null;
Expand Down
197 changes: 197 additions & 0 deletions packages/react-grab/src/core/viewport-clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import {
getFiberFromHostInstance,
isInstrumentationActive,
getDisplayName,
isCompositeFiber,
type Fiber,
} from "bippy";
import { getSource, isSourceFile } from "bippy/source";
import { VERSION, VIEWPORT_CLIPBOARD_DEBOUNCE_MS } from "../constants.js";
import { collectViewportElements } from "../utils/collect-viewport-elements.js";
import { getTagName } from "../utils/get-tag-name.js";
import { normalizeFilePath } from "../utils/normalize-file-path.js";
import { isUsefulComponentName } from "../utils/is-useful-component-name.js";

const REACT_GRAB_MIME_TYPE = "application/x-react-grab";
const WEB_CUSTOM_MIME_TYPE = `web ${REACT_GRAB_MIME_TYPE}`;

interface ViewportEntry {
tagName: string;
componentName?: string;
filePath?: string;
lineNumber?: number;
columnNumber?: number;
}

interface ViewportClipboardController {
scheduleWrite: () => void;
cancel: () => void;
}

const getComponentNameFromFiber = (fiber: Fiber): string | undefined => {
let currentFiber: Fiber | null = fiber.return ?? null;
while (currentFiber) {
if (isCompositeFiber(currentFiber)) {
const name = getDisplayName(currentFiber.type);
if (name && isUsefulComponentName(name)) {
return name;
}
}
currentFiber = currentFiber.return ?? null;
}
return undefined;
};

const resolveElementEntry = async (element: Element): Promise<ViewportEntry | null> => {

@vercel vercel Bot Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implicit viewport grabs skip elements when the sampled point lands on a leaf DOM node without a React fiber, whereas explicit grabs would include those elements by walking up the DOM tree

Fix on Vercel

const fiber = getFiberFromHostInstance(element);
if (!fiber) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fiber lookup skips parent walk

Medium Severity

resolveElementEntry calls getFiberFromHostInstance on the sampled DOM node only. Elsewhere, grabs resolve via findNearestFiberElement, walking ancestors until a host fiber exists. Sampled leaf nodes without their own fiber are dropped, so implicit viewport lists can omit elements explicit copy would include.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ac22acf. Configure here.


const tagName = getTagName(element);
const componentName = getComponentNameFromFiber(fiber);

let filePath: string | undefined;
let lineNumber: number | undefined;
let columnNumber: number | undefined;

try {
const source = await getSource(fiber);
if (source?.fileName && isSourceFile(source.fileName)) {
filePath = normalizeFilePath(source.fileName);
lineNumber = source.lineNumber ?? undefined;
columnNumber = source.columnNumber ?? undefined;
}
} catch {}

return { tagName, componentName, filePath, lineNumber, columnNumber };
};

const formatEntryReference = (entry: ViewportEntry): string => {
const tag = entry.componentName
? `<${entry.tagName}>`

@vercel vercel Bot Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatEntryReference displays the underlying HTML element tag instead of the component name when a component wraps the element

Fix on Vercel

: `<${entry.tagName} />`;

const locationParts: string[] = [];
if (entry.componentName) {
locationParts.push(`in ${entry.componentName}`);
}
if (entry.filePath) {
const locationSuffix =
entry.lineNumber != null && entry.columnNumber != null
? `:${entry.lineNumber}:${entry.columnNumber}`
: entry.lineNumber != null
? `:${entry.lineNumber}`
: "";
locationParts.push(`(at ${entry.filePath}${locationSuffix})`);
}

const location = locationParts.length > 0 ? ` ${locationParts.join(" ")}` : "";
return `[${tag}${location}]`;
};

@vercel vercel Bot Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clipboard write operations are called from a setTimeout callback outside the user gesture context, causing modern browsers to reject them with NotAllowedError

Fix on Vercel

const writeToClipboard = async (content: string, metadata: string): Promise<boolean> => {
if (!navigator.clipboard) return false;

try {
if (typeof ClipboardItem !== "undefined") {
const textBlob = new Blob([content], { type: "text/plain" });
const customBlob = new Blob([metadata], { type: REACT_GRAB_MIME_TYPE });
const item = new ClipboardItem({
"text/plain": textBlob,
[WEB_CUSTOM_MIME_TYPE]: customBlob,
});
await navigator.clipboard.write([item]);
return true;
}
} catch {}

try {
await navigator.clipboard.writeText(content);
return true;
} catch {}
Comment on lines +91 to +110

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Clipboard API writes will silently fail in most contexts without user activation

The writeToClipboard function in viewport-clipboard.ts:91-110 uses navigator.clipboard.write() and navigator.clipboard.writeText(), both of which require transient user activation in most modern browsers. The scheduleWrite is triggered from pointermove and scroll events (which do not grant user activation) and debounced by 3 seconds (VIEWPORT_CLIPBOARD_DEBOUNCE_MS), meaning any activation from a qualifying event (like pointerdown) would have expired. The calls are wrapped in empty catch blocks so failures are silent. In Chrome on localhost (common for React development), the clipboard-write permission may be auto-granted, making this work. On Firefox/Safari or in production HTTPS contexts, this feature will be a silent no-op. Whether this is acceptable depends on the target deployment scenario.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is by design — the feature targets Chrome on localhost (where clipboard-write is auto-granted), which covers the primary dev-tool use case. The empty catches ensure silent degradation on Firefox/Safari/production HTTPS contexts. No action needed.


return false;
};

export const createViewportClipboard = (): ViewportClipboardController => {
let timerId: ReturnType<typeof setTimeout> | null = null;
let lastContentHash = "";
let isWriting = false;

@vercel vercel Bot Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Viewport changes are dropped when a new debounced write is scheduled while a previous write is still in progress

Fix on Vercel

const performWrite = async (): Promise<void> => {
if (isWriting) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent write skips scheduled flush

Low Severity

When performWrite is already running, another debounced invocation returns immediately at if (isWriting) return with no follow-up. Long getSource batches can cause a timer-fired update to be dropped until the user moves or scrolls again.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ac22acf. Configure here.

if (!isInstrumentationActive()) return;

isWriting = true;
try {
const elements = collectViewportElements();
if (elements.length === 0) return;

const entryResults = await Promise.allSettled(
elements.map(resolveElementEntry),
);

const entries: ViewportEntry[] = [];
for (const result of entryResults) {
if (result.status === "fulfilled" && result.value) {
entries.push(result.value);
}
}

if (entries.length === 0) return;

const deduplicatedEntries: ViewportEntry[] = [];
const seenKeys = new Set<string>();
for (const entry of entries) {
const deduplicationKey = entry.componentName && entry.filePath
? `${entry.componentName}:${entry.filePath}:${entry.lineNumber ?? ""}`
: `${entry.tagName}:${entry.filePath ?? ""}:${entry.lineNumber ?? ""}`;
if (seenKeys.has(deduplicationKey)) continue;
seenKeys.add(deduplicationKey);
deduplicatedEntries.push(entry);
}

const references = deduplicatedEntries.map(formatEntryReference);
const content = references.join("\n");
Comment thread
vercel[bot] marked this conversation as resolved.

if (content === lastContentHash) return;

const metadata = JSON.stringify({
version: VERSION,
content,
entries: deduplicatedEntries.map((entry) => ({
tagName: entry.tagName,
componentName: entry.componentName,
content: formatEntryReference(entry),
})),
timestamp: Date.now(),
implicit: true,
});

const didWrite = await writeToClipboard(content, metadata);
if (didWrite) {
lastContentHash = content;
}
} catch {} finally {
isWriting = false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Write may run after dispose

Low Severity

dispose only calls viewportClipboard.cancel(), which clears the debounce timer. An in-flight performWrite can still finish afterward and call navigator.clipboard.write, updating the clipboard after React Grab has torn down.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 47b95b6. Configure here.

};

const scheduleWrite = (): void => {
if (timerId !== null) {
clearTimeout(timerId);
}
timerId = setTimeout(() => {
timerId = null;
void performWrite();
}, VIEWPORT_CLIPBOARD_DEBOUNCE_MS);
Comment thread
cursor[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debounced write overwrites grabs

High Severity

Implicit viewport writes are scheduled while React Grab is idle, but performWrite runs after the debounce without checking whether the user is still idle. A pending timer is not cancelled when activation, key-hold, or copying starts, so an explicit grab on the clipboard can be replaced seconds later by a stale viewport snapshot.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b3b620e. Configure here.

};

const cancel = (): void => {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
};

return { scheduleWrite, cancel };
};
Loading
Loading