From e4618ed5538e76e5361906ca85f00f171e5bf06f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 May 2026 00:32:13 +0000 Subject: [PATCH 1/4] feat(react-grab): canvas screenshot labels on Shift+Cmd / Win+Shift / PrintScreen Hold Shift+Cmd (Mac), Win+Shift (Windows), or press PrintScreen (Linux) to overlay component labels on every visible React component before taking a screenshot. Each label is rendered onto the existing overlay canvas as 'ComponentName \u00b7 page.tsx' (component display name + basename of the source file) with no selection boxes or background highlights on the components themselves. Implementation: - new utilities: is-linux, is-screenshot-shortcut, collect-screenshot-labels - new OverlayCanvas render pass (text-only, no per-element fill/stroke) - new keydown/keyup/blur handlers in core to activate/clear labels - component file path is resolved synchronously from fiber _debugSource when present and asynchronously via resolveSource() as a fallback so the label upgrades from name-only to name + file once available Co-authored-by: Aiden Bai --- .../src/components/overlay-canvas.tsx | 84 ++++++++++- .../react-grab/src/components/renderer.tsx | 1 + packages/react-grab/src/constants.ts | 12 ++ packages/react-grab/src/core/index.tsx | 125 ++++++++++++++++ packages/react-grab/src/types.ts | 9 ++ .../src/utils/collect-screenshot-labels.ts | 139 ++++++++++++++++++ packages/react-grab/src/utils/is-linux.ts | 27 ++++ .../src/utils/is-screenshot-shortcut.ts | 19 +++ 8 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 packages/react-grab/src/utils/collect-screenshot-labels.ts create mode 100644 packages/react-grab/src/utils/is-linux.ts create mode 100644 packages/react-grab/src/utils/is-screenshot-shortcut.ts diff --git a/packages/react-grab/src/components/overlay-canvas.tsx b/packages/react-grab/src/components/overlay-canvas.tsx index b505003bc..30b71a761 100644 --- a/packages/react-grab/src/components/overlay-canvas.tsx +++ b/packages/react-grab/src/components/overlay-canvas.tsx @@ -1,5 +1,5 @@ import { createEffect, on, onCleanup, onMount, type Component } from "solid-js"; -import type { OverlayBounds, SelectionLabelInstance } from "../types.js"; +import type { OverlayBounds, ScreenshotLabel, SelectionLabelInstance } from "../types.js"; import { lerp } from "../utils/lerp.js"; import { SELECTION_LERP_FACTOR, @@ -15,6 +15,16 @@ import { OVERLAY_BORDER_COLOR_DEFAULT, OVERLAY_FILL_COLOR_DEFAULT, BASELINE_FRAME_DURATION_MS, + SCREENSHOT_LABEL_BACKGROUND_COLOR, + SCREENSHOT_LABEL_CORNER_RADIUS_PX, + SCREENSHOT_LABEL_FONT_FAMILY, + SCREENSHOT_LABEL_FONT_SIZE_PX, + SCREENSHOT_LABEL_MIDDOT, + SCREENSHOT_LABEL_PADDING_X_PX, + SCREENSHOT_LABEL_PADDING_Y_PX, + SCREENSHOT_LABEL_SECONDARY_COLOR, + SCREENSHOT_LABEL_TEXT_COLOR, + SCREENSHOT_LABEL_VERTICAL_OFFSET_PX, } from "../constants.js"; import { nativeCancelAnimationFrame, nativeRequestAnimationFrame } from "../utils/native-raf.js"; import { supportsDisplayP3 } from "../utils/supports-display-p3.js"; @@ -72,6 +82,8 @@ interface OverlayCanvasProps { }>; labelInstances?: SelectionLabelInstance[]; + + screenshotLabels?: ScreenshotLabel[]; } export const OverlayCanvas: Component = (props) => { @@ -288,6 +300,65 @@ export const OverlayCanvas: Component = (props) => { } }; + const renderScreenshotLabels = () => { + if (!mainContext) return; + const labels = props.screenshotLabels; + if (!labels || labels.length === 0) return; + + const context = mainContext; + context.save(); + context.font = `${SCREENSHOT_LABEL_FONT_SIZE_PX}px ${SCREENSHOT_LABEL_FONT_FAMILY}`; + context.textBaseline = "top"; + + const textHeight = SCREENSHOT_LABEL_FONT_SIZE_PX; + const paddingX = SCREENSHOT_LABEL_PADDING_X_PX; + const paddingY = SCREENSHOT_LABEL_PADDING_Y_PX; + const rectHeight = textHeight + paddingY * 2; + const cornerRadius = SCREENSHOT_LABEL_CORNER_RADIUS_PX; + const middotSegment = ` ${SCREENSHOT_LABEL_MIDDOT} `; + + for (const label of labels) { + const componentText = label.componentName; + const fileText = label.fileBaseName ?? ""; + const componentWidth = context.measureText(componentText).width; + const middotWidth = fileText ? context.measureText(middotSegment).width : 0; + const fileWidth = fileText ? context.measureText(fileText).width : 0; + const totalTextWidth = componentWidth + middotWidth + fileWidth; + const rectWidth = totalTextWidth + paddingX * 2; + + let rectX = Math.round(label.x); + let rectY = Math.round(label.y - rectHeight - SCREENSHOT_LABEL_VERTICAL_OFFSET_PX); + if (rectY < 0) { + rectY = Math.round(label.y + SCREENSHOT_LABEL_VERTICAL_OFFSET_PX); + } + if (rectX + rectWidth > canvasWidth) { + rectX = Math.max(0, canvasWidth - rectWidth); + } + if (rectX < 0) rectX = 0; + + context.fillStyle = SCREENSHOT_LABEL_BACKGROUND_COLOR; + context.beginPath(); + context.roundRect(rectX, rectY, rectWidth, rectHeight, cornerRadius); + context.fill(); + + let textCursorX = rectX + paddingX; + const textCursorY = rectY + paddingY; + + context.fillStyle = SCREENSHOT_LABEL_TEXT_COLOR; + context.fillText(componentText, textCursorX, textCursorY); + textCursorX += componentWidth; + + if (fileText) { + context.fillStyle = SCREENSHOT_LABEL_SECONDARY_COLOR; + context.fillText(middotSegment, textCursorX, textCursorY); + textCursorX += middotWidth; + context.fillText(fileText, textCursorX, textCursorY); + } + } + + context.restore(); + }; + const compositeAllLayers = () => { if (!mainContext || !canvasRef) return; @@ -306,6 +377,8 @@ export const OverlayCanvas: Component = (props) => { mainContext.drawImage(layer.canvas, 0, 0, canvasWidth, canvasHeight); } } + + renderScreenshotLabels(); }; const interpolateBounds = ( @@ -522,6 +595,15 @@ export const OverlayCanvas: Component = (props) => { ), ); + createEffect( + on( + () => props.screenshotLabels, + () => { + scheduleAnimationFrame(); + }, + ), + ); + createEffect( on( () => [props.grabbedBoxes, props.labelInstances] as const, diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index 6ab15c16e..5ee318925 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -27,6 +27,7 @@ export const ReactGrabRenderer: Component = (props) => { dragBounds={props.dragBounds} grabbedBoxes={props.grabbedBoxes} labelInstances={props.labelInstances} + screenshotLabels={props.screenshotLabels} /> {/* translateZ(0) promotes to its own compositor layer so opacity transitions skip main-thread repaints; contain:strict with diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 24e0f8a35..b7673a649 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -187,6 +187,18 @@ export const DROPDOWN_EDGE_TRANSFORM_ORIGIN = { export const NEXTJS_REVALIDATION_DELAY_MS = 1000; +export const SCREENSHOT_LABEL_FONT_SIZE_PX = 11; +export const SCREENSHOT_LABEL_PADDING_X_PX = 4; +export const SCREENSHOT_LABEL_PADDING_Y_PX = 2; +export const SCREENSHOT_LABEL_BACKGROUND_COLOR = "rgba(0, 0, 0, 0.78)"; +export const SCREENSHOT_LABEL_TEXT_COLOR = "#ffffff"; +export const SCREENSHOT_LABEL_SECONDARY_COLOR = "rgba(255, 255, 255, 0.6)"; +export const SCREENSHOT_LABEL_CORNER_RADIUS_PX = 3; +export const SCREENSHOT_LABEL_MIDDOT = "\u00B7"; +export const SCREENSHOT_LABEL_FONT_FAMILY = + "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; +export const SCREENSHOT_LABEL_VERTICAL_OFFSET_PX = 2; + export const TEXTAREA_MAX_HEIGHT_PX = 95; export const IME_COMPOSING_KEY_CODE = 229; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 31be2169e..b54db1837 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -97,6 +97,7 @@ import type { GrabbedBox, ReactGrabAPI, ReactGrabState, + ScreenshotLabel, SelectionLabelInstance, ContextMenuActionContext, ContextMenuAction, @@ -136,6 +137,15 @@ import { copyContent } from "../utils/copy-content.js"; import { generateId } from "../utils/generate-id.js"; import { logRecoverableError } from "../utils/log-recoverable-error.js"; import { getNearestEdge } from "../utils/get-nearest-edge.js"; +import { + collectScreenshotLabels, + resolveScreenshotLabelFileName, + type ScreenshotLabelCandidate, +} from "../utils/collect-screenshot-labels.js"; +import { + isScreenshotShortcutPressed, + isScreenshotShortcutReleased, +} from "../utils/is-screenshot-shortcut.js"; const builtInPlugins = [copyPlugin, commentPlugin, openPlugin]; @@ -3469,6 +3479,120 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }, 0); }; + const [screenshotLabelCandidates, setScreenshotLabelCandidates] = createSignal< + ScreenshotLabelCandidate[] + >([]); + const [screenshotLabelFileNames, setScreenshotLabelFileNames] = createSignal< + Map + >(new Map()); + let screenshotLabelResolutionToken = 0; + + const clearScreenshotLabels = () => { + screenshotLabelResolutionToken++; + setScreenshotLabelCandidates([]); + setScreenshotLabelFileNames(new Map()); + }; + + const activateScreenshotLabels = () => { + const candidates = collectScreenshotLabels(); + if (candidates.length === 0) { + clearScreenshotLabels(); + return; + } + + const initialFileNames = new Map(); + for (const candidate of candidates) { + if (candidate.fileBaseName) { + initialFileNames.set(candidate.fiberId, candidate.fileBaseName); + } + } + + const resolutionToken = ++screenshotLabelResolutionToken; + setScreenshotLabelFileNames(initialFileNames); + setScreenshotLabelCandidates(candidates); + + for (const candidate of candidates) { + if (initialFileNames.has(candidate.fiberId)) continue; + void resolveScreenshotLabelFileName(candidate.element).then((fileBaseName) => { + if (resolutionToken !== screenshotLabelResolutionToken) return; + if (!fileBaseName) return; + setScreenshotLabelFileNames((previous) => { + if (previous.get(candidate.fiberId) === fileBaseName) return previous; + const next = new Map(previous); + next.set(candidate.fiberId, fileBaseName); + return next; + }); + }); + } + }; + + const computedScreenshotLabels = createMemo(() => { + const candidates = screenshotLabelCandidates(); + if (candidates.length === 0) return []; + void viewportVersion(); + const fileNames = screenshotLabelFileNames(); + const labels: ScreenshotLabel[] = []; + + for (const candidate of candidates) { + if (!isElementConnected(candidate.element)) continue; + const rect = candidate.element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) continue; + if (rect.right <= 0 || rect.bottom <= 0) continue; + if (rect.left >= window.innerWidth || rect.top >= window.innerHeight) continue; + + labels.push({ + id: candidate.fiberId, + x: rect.left, + y: rect.top, + componentName: candidate.componentName, + fileBaseName: fileNames.get(candidate.fiberId) ?? candidate.fileBaseName, + }); + } + + return labels; + }); + + const isScreenshotTriggerKey = (event: KeyboardEvent): boolean => { + return ( + event.key === "Meta" || + event.key === "Shift" || + event.key === "PrintScreen" || + event.code === "PrintScreen" + ); + }; + + eventListenerManager.addWindowListener( + "keydown", + (event: KeyboardEvent) => { + if (!isEnabled()) return; + if (event.repeat) return; + if (!isScreenshotTriggerKey(event)) return; + if (!isScreenshotShortcutPressed(event)) return; + if (isPromptMode()) return; + if (isCopying()) return; + if (isKeyboardEventTriggeredByInput(event)) return; + if (screenshotLabelCandidates().length > 0) return; + activateScreenshotLabels(); + }, + { capture: true }, + ); + + eventListenerManager.addWindowListener( + "keyup", + (event: KeyboardEvent) => { + if (screenshotLabelCandidates().length === 0) return; + if (!isScreenshotShortcutReleased(event)) return; + clearScreenshotLabels(); + }, + { capture: true }, + ); + + eventListenerManager.addWindowListener("blur", () => { + if (screenshotLabelCandidates().length > 0) { + clearScreenshotLabels(); + } + }); + createEffect(() => { const hue = pluginRegistry.store.theme.hue; if (hue !== 0) { @@ -3506,6 +3630,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { selectionArrowNavigationState={arrowNavigationState()} onArrowNavigationSelect={handleArrowNavigationSelect} labelInstances={computedLabelInstances()} + screenshotLabels={computedScreenshotLabels()} dragVisible={dragVisible()} dragBounds={dragBounds()} grabbedBoxes={computedGrabbedBoxes()} diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index affbf06bb..6a65f3bd5 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -331,6 +331,14 @@ export interface SelectionLabelInstance { hideArrow?: boolean; } +export interface ScreenshotLabel { + id: number; + x: number; + y: number; + componentName: string; + fileBaseName: string | null; +} + export interface FrozenLabelEntry { tagName: string; componentName?: string; @@ -355,6 +363,7 @@ export interface ReactGrabRendererProps { selectionArrowNavigationState?: ArrowNavigationState; onArrowNavigationSelect?: (index: number) => void; labelInstances?: SelectionLabelInstance[]; + screenshotLabels?: ScreenshotLabel[]; dragVisible?: boolean; dragBounds?: OverlayBounds; grabbedBoxes?: Array<{ diff --git a/packages/react-grab/src/utils/collect-screenshot-labels.ts b/packages/react-grab/src/utils/collect-screenshot-labels.ts new file mode 100644 index 000000000..93bfc183f --- /dev/null +++ b/packages/react-grab/src/utils/collect-screenshot-labels.ts @@ -0,0 +1,139 @@ +import { + _fiberRoots, + getDisplayName, + getFiberFromHostInstance, + getNearestHostFibers, + isCompositeFiber, + traverseFiber, + type Fiber, + type FiberRoot, +} from "bippy"; +import { resolveSource } from "../core/context.js"; +import { isUsefulComponentName } from "./is-useful-component-name.js"; +import { normalizeFilePath } from "./normalize-file-path.js"; + +interface FiberRootLike extends FiberRoot { + current: Fiber | null; +} + +interface FiberDebugLink { + fileName?: string; + lineNumber?: number; + columnNumber?: number; +} + +const collectFiberRoots = (): Set => { + const typedFiberRoots = _fiberRoots as Set; + if (typedFiberRoots.size > 0) return typedFiberRoots; + + const collected = new Set(); + const walk = (element: Element): void => { + const fiber = getFiberFromHostInstance(element); + if (fiber) { + let current: Fiber | null = fiber; + while (current?.return) current = current.return; + const root = current?.stateNode as FiberRootLike | undefined; + if (root) collected.add(root); + return; + } + for (const child of Array.from(element.children)) { + walk(child); + if (collected.size > 0) return; + } + }; + if (typeof document !== "undefined") walk(document.body); + return collected; +}; + +const getSyncFileName = (fiber: Fiber): string | null => { + let current: Fiber | null = fiber; + while (current) { + const debugSource = current._debugSource as FiberDebugLink | null | undefined; + if (debugSource?.fileName) { + return normalizeFilePath(debugSource.fileName); + } + current = (current._debugOwner ?? null) as Fiber | null; + } + return null; +}; + +const getFileBaseName = (filePath: string): string => { + const cleaned = filePath.split("?")[0].split("#")[0]; + const parts = cleaned.split("/"); + return parts[parts.length - 1] || cleaned; +}; + +const isElementOnscreen = (element: Element): boolean => { + if (!(element instanceof Element)) return false; + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + if (rect.right <= 0 || rect.bottom <= 0) return false; + if (rect.left >= window.innerWidth || rect.top >= window.innerHeight) return false; + return true; +}; + +export interface ScreenshotLabelCandidate { + fiberId: number; + element: Element; + componentName: string; + fileBaseName: string | null; +} + +let fiberIdSequence = 0; +const fiberIdCache = new WeakMap(); +const getStableFiberId = (fiber: Fiber): number => { + const cached = fiberIdCache.get(fiber); + if (cached !== undefined) return cached; + const id = ++fiberIdSequence; + fiberIdCache.set(fiber, id); + return id; +}; + +export const collectScreenshotLabels = (): ScreenshotLabelCandidate[] => { + const fiberRoots = collectFiberRoots(); + const candidates: ScreenshotLabelCandidate[] = []; + const seenElements = new WeakSet(); + + for (const fiberRoot of fiberRoots) { + if (!fiberRoot.current) continue; + traverseFiber( + fiberRoot.current, + (fiber) => { + if (!isCompositeFiber(fiber)) return false; + const displayName = getDisplayName(fiber.type); + if (!displayName || !isUsefulComponentName(displayName)) return false; + + const hostFibers = getNearestHostFibers(fiber); + if (hostFibers.length === 0) return false; + + const hostNode = hostFibers[0].stateNode; + if (!(hostNode instanceof Element)) return false; + if (seenElements.has(hostNode)) return false; + if (!isElementOnscreen(hostNode)) return false; + + seenElements.add(hostNode); + const fileName = getSyncFileName(fiber); + candidates.push({ + fiberId: getStableFiberId(fiber), + element: hostNode, + componentName: displayName, + fileBaseName: fileName ? getFileBaseName(fileName) : null, + }); + return false; + }, + true, + ); + } + + return candidates; +}; + +export const resolveScreenshotLabelFileName = async (element: Element): Promise => { + try { + const source = await resolveSource(element); + if (!source?.filePath) return null; + return getFileBaseName(source.filePath); + } catch { + return null; + } +}; diff --git a/packages/react-grab/src/utils/is-linux.ts b/packages/react-grab/src/utils/is-linux.ts new file mode 100644 index 000000000..c1b7a6566 --- /dev/null +++ b/packages/react-grab/src/utils/is-linux.ts @@ -0,0 +1,27 @@ +let cachedIsLinux: boolean | null = null; + +const getPlatformFromUserAgentData = (): string | null => { + if (typeof navigator === "undefined") return null; + if (!("userAgentData" in navigator)) return null; + + const userAgentData = navigator.userAgentData; + if (typeof userAgentData !== "object" || userAgentData === null) return null; + if (!("platform" in userAgentData)) return null; + + const platform = userAgentData.platform; + if (typeof platform !== "string") return null; + return platform; +}; + +export const isLinux = (): boolean => { + if (cachedIsLinux === null) { + if (typeof navigator === "undefined") { + cachedIsLinux = false; + return cachedIsLinux; + } + + const platform = navigator.platform ?? getPlatformFromUserAgentData() ?? navigator.userAgent; + cachedIsLinux = /Linux|X11|CrOS/i.test(platform) && !/Android/i.test(platform); + } + return cachedIsLinux; +}; diff --git a/packages/react-grab/src/utils/is-screenshot-shortcut.ts b/packages/react-grab/src/utils/is-screenshot-shortcut.ts new file mode 100644 index 000000000..25df56045 --- /dev/null +++ b/packages/react-grab/src/utils/is-screenshot-shortcut.ts @@ -0,0 +1,19 @@ +import { isLinux } from "./is-linux.js"; +import { isMac } from "./is-mac.js"; + +export const isScreenshotShortcutPressed = (event: KeyboardEvent): boolean => { + if (isLinux()) { + return event.key === "PrintScreen" || event.code === "PrintScreen"; + } + if (isMac()) { + return event.metaKey && event.shiftKey; + } + return event.metaKey && event.shiftKey; +}; + +export const isScreenshotShortcutReleased = (event: KeyboardEvent): boolean => { + if (isLinux()) { + return event.key === "PrintScreen" || event.code === "PrintScreen"; + } + return event.key === "Meta" || event.key === "Shift"; +}; From 3085dc1d065cb3f20762280afd1c16a171fef4c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 May 2026 01:12:17 +0000 Subject: [PATCH 2/4] fix(react-grab): address cubic review - isLinux(): also check navigator.userAgent for 'Android' so an Android device reporting navigator.platform = 'Linux armv8l' is not treated as desktop Linux (and doesn't get PrintScreen handling on a touch device). - collectFiberRoots() fallback: don't abort sibling traversal after the first root is found. Pages mounting multiple React roots (e.g. portals, micro-frontends) were missing labels for every root past the first. - resolveScreenshotLabelFileName(): run resolveSource()'s file path through normalizeFilePath() before basename extraction so Windows-style backslash paths split correctly. getFileBaseName() also now coerces backslashes defensively for sync _debugSource paths. Co-authored-by: Aiden Bai --- packages/react-grab/src/utils/collect-screenshot-labels.ts | 5 ++--- packages/react-grab/src/utils/is-linux.ts | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/react-grab/src/utils/collect-screenshot-labels.ts b/packages/react-grab/src/utils/collect-screenshot-labels.ts index 93bfc183f..5e4d94f45 100644 --- a/packages/react-grab/src/utils/collect-screenshot-labels.ts +++ b/packages/react-grab/src/utils/collect-screenshot-labels.ts @@ -38,7 +38,6 @@ const collectFiberRoots = (): Set => { } for (const child of Array.from(element.children)) { walk(child); - if (collected.size > 0) return; } }; if (typeof document !== "undefined") walk(document.body); @@ -58,7 +57,7 @@ const getSyncFileName = (fiber: Fiber): string | null => { }; const getFileBaseName = (filePath: string): string => { - const cleaned = filePath.split("?")[0].split("#")[0]; + const cleaned = filePath.split("?")[0].split("#")[0].replace(/\\/g, "/"); const parts = cleaned.split("/"); return parts[parts.length - 1] || cleaned; }; @@ -132,7 +131,7 @@ export const resolveScreenshotLabelFileName = async (element: Element): Promise< try { const source = await resolveSource(element); if (!source?.filePath) return null; - return getFileBaseName(source.filePath); + return getFileBaseName(normalizeFilePath(source.filePath)); } catch { return null; } diff --git a/packages/react-grab/src/utils/is-linux.ts b/packages/react-grab/src/utils/is-linux.ts index c1b7a6566..ab541ace4 100644 --- a/packages/react-grab/src/utils/is-linux.ts +++ b/packages/react-grab/src/utils/is-linux.ts @@ -21,7 +21,9 @@ export const isLinux = (): boolean => { } const platform = navigator.platform ?? getPlatformFromUserAgentData() ?? navigator.userAgent; - cachedIsLinux = /Linux|X11|CrOS/i.test(platform) && !/Android/i.test(platform); + const userAgent = navigator.userAgent ?? ""; + const isAndroid = /Android/i.test(platform) || /Android/i.test(userAgent); + cachedIsLinux = /Linux|X11|CrOS/i.test(platform) && !isAndroid; } return cachedIsLinux; }; From f595dae97ff8fc2b2cb95d218afebf3d37678f1f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 May 2026 01:44:16 +0000 Subject: [PATCH 3/4] fix(react-grab): screenshot labels never appeared - traverseFiber was a no-op + isEnabled gate Two bugs, both stopped labels from ever rendering: 1. collectScreenshotLabels() used bippy's traverseFiber(root, cb, true), which in the published bippy build only visits the passed-in fiber and does not recurse into .child/.sibling. e2e instrumentation showed: roots: 1, visited: 1, composite: 0 for a tree with 312 fibers and 24 composite components. Replaced with a manual recursive walker (and a manual nearest-host-element finder) so candidate collection is bippy-version-independent. 2. The keydown handler gated activation on isEnabled() (the toolbar's collapsed/enabled signal). With the toolbar collapsed the shortcut was silently swallowed even though the screenshot helper has nothing to do with selection mode. Removed the gate. Also hardened activation so an upstream stopPropagation on either window or document cannot swallow the shortcut: same handler is registered on both targets in capture phase, idempotent via the existing 'already-active' early-return. Added e2e/screenshot-labels.spec.ts with 9 tests covering all three platforms (macOS Cmd+Shift, Windows Win+Shift, Linux PrintScreen), their negative cases (Cmd-only, Shift-only, Ctrl+Shift, Meta+Shift on Linux), and clean teardown on keyup. Tests mock navigator.platform via addInitScript so the platform branches can be exercised on Linux CI. Confirms the activation path actually paints to the canvas. Co-authored-by: Aiden Bai --- .../react-grab/e2e/screenshot-labels.spec.ts | 207 ++++++++++++++++++ packages/react-grab/src/core/index.tsx | 50 ++--- .../src/utils/collect-screenshot-labels.ts | 67 +++--- 3 files changed, 270 insertions(+), 54 deletions(-) create mode 100644 packages/react-grab/e2e/screenshot-labels.spec.ts diff --git a/packages/react-grab/e2e/screenshot-labels.spec.ts b/packages/react-grab/e2e/screenshot-labels.spec.ts new file mode 100644 index 000000000..4a18f4aa2 --- /dev/null +++ b/packages/react-grab/e2e/screenshot-labels.spec.ts @@ -0,0 +1,207 @@ +import { test as base, expect, type Page } from "@playwright/test"; +import { test } from "./fixtures.js"; + +interface CanvasInspectResult { + found: boolean; + hasInk: boolean; + inkPixels: number; + width: number; + height: number; +} + +const inspectOverlayCanvasInk = async (page: Page): Promise => + page.evaluate(async () => { + const fallback: CanvasInspectResult = { + found: false, + hasInk: false, + inkPixels: 0, + width: 0, + height: 0, + }; + const host = document.querySelector("[data-react-grab]"); + const shadowRoot = host?.shadowRoot; + if (!shadowRoot) return fallback; + const canvas = shadowRoot.querySelector( + "canvas[data-react-grab-overlay-canvas]", + ) as HTMLCanvasElement | null; + if (!canvas) return fallback; + + const ctx = canvas.getContext("2d"); + if (!ctx) return { ...fallback, found: true, width: canvas.width, height: canvas.height }; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + let inkPixels = 0; + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 0) inkPixels++; + } + return { + found: true, + hasInk: inkPixels > 0, + inkPixels, + width: canvas.width, + height: canvas.height, + }; + }); + +const waitForOverlayInk = async (page: Page, shouldHaveInk: boolean): Promise => { + await page.waitForFunction( + (expected) => { + const host = document.querySelector("[data-react-grab]"); + const shadowRoot = host?.shadowRoot; + if (!shadowRoot) return !expected; + const canvas = shadowRoot.querySelector( + "canvas[data-react-grab-overlay-canvas]", + ) as HTMLCanvasElement | null; + if (!canvas) return !expected; + const ctx = canvas.getContext("2d"); + if (!ctx) return !expected; + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + let hasInk = false; + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 0) { + hasInk = true; + break; + } + } + return expected ? hasInk : !hasInk; + }, + shouldHaveInk, + { timeout: 2000 }, + ); +}; + +const withPlatform = (platform: string) => + base.extend({ + page: async ({ page }, use) => { + await page.addInitScript((mockedPlatform) => { + Object.defineProperty(navigator, "platform", { + configurable: true, + get: () => mockedPlatform, + }); + }, platform); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForFunction( + () => (window as { __REACT_GRAB__?: unknown }).__REACT_GRAB__ !== undefined, + undefined, + { timeout: 8000 }, + ); + await use(page); + }, + }); + +const macTest = withPlatform("MacIntel"); +const windowsTest = withPlatform("Win32"); +const linuxTest = withPlatform("Linux x86_64"); + +test.describe("Screenshot Labels - smoke", () => { + test("overlay canvas is mounted by default", async ({ reactGrab }) => { + const result = await inspectOverlayCanvasInk(reactGrab.page); + expect(result.found).toBe(true); + expect(result.width).toBeGreaterThan(0); + expect(result.height).toBeGreaterThan(0); + }); +}); + +macTest.describe("Screenshot Labels - macOS (Cmd+Shift)", () => { + macTest("Cmd then Shift activates labels", async ({ page }) => { + const before = await inspectOverlayCanvasInk(page); + expect(before.found).toBe(true); + expect(before.hasInk).toBe(false); + + await page.keyboard.down("Meta"); + await page.keyboard.down("Shift"); + + await waitForOverlayInk(page, true); + const during = await inspectOverlayCanvasInk(page); + expect(during.inkPixels).toBeGreaterThan(0); + + await page.keyboard.up("Shift"); + await page.keyboard.up("Meta"); + + await waitForOverlayInk(page, false); + }); + + macTest("Shift then Cmd activates labels", async ({ page }) => { + await page.keyboard.down("Shift"); + await page.keyboard.down("Meta"); + + await waitForOverlayInk(page, true); + + await page.keyboard.up("Meta"); + await page.keyboard.up("Shift"); + + await waitForOverlayInk(page, false); + }); + + macTest("Cmd alone does not activate", async ({ page }) => { + await page.keyboard.down("Meta"); + await page.waitForTimeout(200); + + const result = await inspectOverlayCanvasInk(page); + expect(result.hasInk).toBe(false); + + await page.keyboard.up("Meta"); + }); + + macTest("Shift alone does not activate", async ({ page }) => { + await page.keyboard.down("Shift"); + await page.waitForTimeout(200); + + const result = await inspectOverlayCanvasInk(page); + expect(result.hasInk).toBe(false); + + await page.keyboard.up("Shift"); + }); +}); + +windowsTest.describe("Screenshot Labels - Windows (Win+Shift)", () => { + windowsTest("Win+Shift activates labels", async ({ page }) => { + await page.keyboard.down("Meta"); + await page.keyboard.down("Shift"); + + await waitForOverlayInk(page, true); + + await page.keyboard.up("Shift"); + await page.keyboard.up("Meta"); + + await waitForOverlayInk(page, false); + }); + + windowsTest("Ctrl+Shift does not activate", async ({ page }) => { + await page.keyboard.down("Control"); + await page.keyboard.down("Shift"); + await page.waitForTimeout(200); + + const result = await inspectOverlayCanvasInk(page); + expect(result.hasInk).toBe(false); + + await page.keyboard.up("Shift"); + await page.keyboard.up("Control"); + }); +}); + +linuxTest.describe("Screenshot Labels - Linux (PrintScreen)", () => { + linuxTest("PrintScreen activates labels", async ({ page }) => { + await page.keyboard.down("PrintScreen"); + + await waitForOverlayInk(page, true); + + await page.keyboard.up("PrintScreen"); + + await waitForOverlayInk(page, false); + }); + + linuxTest("Meta+Shift does not activate on Linux", async ({ page }) => { + await page.keyboard.down("Meta"); + await page.keyboard.down("Shift"); + await page.waitForTimeout(200); + + const result = await inspectOverlayCanvasInk(page); + expect(result.hasInk).toBe(false); + + await page.keyboard.up("Shift"); + await page.keyboard.up("Meta"); + }); +}); diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index b54db1837..e64893e02 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -3561,31 +3561,31 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ); }; - eventListenerManager.addWindowListener( - "keydown", - (event: KeyboardEvent) => { - if (!isEnabled()) return; - if (event.repeat) return; - if (!isScreenshotTriggerKey(event)) return; - if (!isScreenshotShortcutPressed(event)) return; - if (isPromptMode()) return; - if (isCopying()) return; - if (isKeyboardEventTriggeredByInput(event)) return; - if (screenshotLabelCandidates().length > 0) return; - activateScreenshotLabels(); - }, - { capture: true }, - ); - - eventListenerManager.addWindowListener( - "keyup", - (event: KeyboardEvent) => { - if (screenshotLabelCandidates().length === 0) return; - if (!isScreenshotShortcutReleased(event)) return; - clearScreenshotLabels(); - }, - { capture: true }, - ); + const handleScreenshotKeydown = (event: KeyboardEvent): void => { + if (event.repeat) return; + if (!isScreenshotTriggerKey(event)) return; + if (!isScreenshotShortcutPressed(event)) return; + if (isPromptMode()) return; + if (isCopying()) return; + if (isKeyboardEventTriggeredByInput(event)) return; + if (screenshotLabelCandidates().length > 0) return; + activateScreenshotLabels(); + }; + + const handleScreenshotKeyup = (event: KeyboardEvent): void => { + if (screenshotLabelCandidates().length === 0) return; + if (!isScreenshotShortcutReleased(event)) return; + clearScreenshotLabels(); + }; + + // Listen on both window and document with capture so the activation + // still fires when an upstream listener calls stopPropagation on the + // other target. Same handler on both - the early-return on existing + // candidates makes a second invocation for the same press a no-op. + eventListenerManager.addWindowListener("keydown", handleScreenshotKeydown, { capture: true }); + eventListenerManager.addDocumentListener("keydown", handleScreenshotKeydown, { capture: true }); + eventListenerManager.addWindowListener("keyup", handleScreenshotKeyup, { capture: true }); + eventListenerManager.addDocumentListener("keyup", handleScreenshotKeyup, { capture: true }); eventListenerManager.addWindowListener("blur", () => { if (screenshotLabelCandidates().length > 0) { diff --git a/packages/react-grab/src/utils/collect-screenshot-labels.ts b/packages/react-grab/src/utils/collect-screenshot-labels.ts index 5e4d94f45..31e486357 100644 --- a/packages/react-grab/src/utils/collect-screenshot-labels.ts +++ b/packages/react-grab/src/utils/collect-screenshot-labels.ts @@ -2,9 +2,7 @@ import { _fiberRoots, getDisplayName, getFiberFromHostInstance, - getNearestHostFibers, isCompositeFiber, - traverseFiber, type Fiber, type FiberRoot, } from "bippy"; @@ -88,40 +86,51 @@ const getStableFiberId = (fiber: Fiber): number => { return id; }; +const findNearestHostElement = (fiber: Fiber): Element | null => { + const queue: Array = [fiber.child]; + while (queue.length > 0) { + const candidate = queue.shift(); + if (!candidate) continue; + const stateNode = candidate.stateNode; + if (stateNode instanceof Element) return stateNode; + if (candidate.child) queue.push(candidate.child); + if (candidate.sibling) queue.push(candidate.sibling); + } + return null; +}; + export const collectScreenshotLabels = (): ScreenshotLabelCandidate[] => { const fiberRoots = collectFiberRoots(); const candidates: ScreenshotLabelCandidate[] = []; const seenElements = new WeakSet(); + const visit = (fiber: Fiber | null | undefined): void => { + if (!fiber) return; + + if (isCompositeFiber(fiber)) { + const displayName = getDisplayName(fiber.type); + if (displayName && isUsefulComponentName(displayName)) { + const hostElement = findNearestHostElement(fiber); + if (hostElement && !seenElements.has(hostElement) && isElementOnscreen(hostElement)) { + seenElements.add(hostElement); + const fileName = getSyncFileName(fiber); + candidates.push({ + fiberId: getStableFiberId(fiber), + element: hostElement, + componentName: displayName, + fileBaseName: fileName ? getFileBaseName(fileName) : null, + }); + } + } + } + + visit(fiber.child); + visit(fiber.sibling); + }; + for (const fiberRoot of fiberRoots) { if (!fiberRoot.current) continue; - traverseFiber( - fiberRoot.current, - (fiber) => { - if (!isCompositeFiber(fiber)) return false; - const displayName = getDisplayName(fiber.type); - if (!displayName || !isUsefulComponentName(displayName)) return false; - - const hostFibers = getNearestHostFibers(fiber); - if (hostFibers.length === 0) return false; - - const hostNode = hostFibers[0].stateNode; - if (!(hostNode instanceof Element)) return false; - if (seenElements.has(hostNode)) return false; - if (!isElementOnscreen(hostNode)) return false; - - seenElements.add(hostNode); - const fileName = getSyncFileName(fiber); - candidates.push({ - fiberId: getStableFiberId(fiber), - element: hostNode, - componentName: displayName, - fileBaseName: fileName ? getFileBaseName(fileName) : null, - }); - return false; - }, - true, - ); + visit(fiberRoot.current); } return candidates; From 19c841986f6db033818d46f49b8692e82b064b32 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 May 2026 01:55:47 +0000 Subject: [PATCH 4/4] feat(react-grab): selectively render screenshot labels (size + non-overlap) Three changes to cut visual clutter: 1. Only consider components whose host element is at least SCREENSHOT_LABEL_MIN_ELEMENT_WIDTH_PX (80) and SCREENSHOT_LABEL_MIN_ELEMENT_HEIGHT_PX (28). This drops icon-sized wrappers and styled-component leaves that the user almost never wants labeled in a screenshot. 2. Render labels sorted by host element area (largest first), so when we drop overlapping labels we keep the most structurally meaningful parent and skip the smaller children sharing the same region. 3. Greedy non-overlap placement in the canvas pass: a label is only painted if its rounded-rect bounds do not intersect any already painted label (with SCREENSHOT_LABEL_COLLISION_PADDING_PX gap). This keeps the overlay readable on dense apps where the previous pass produced a stack of pills sitting on top of each other. Also address review feedback on the prior commit: - restore isEnabled() guard on the screenshot keydown handler (cubic + Cursor Bugbot both flagged that disabling the toolbar should also silence the screenshot shortcut) - convert the fiber traversal in collectScreenshotLabels and the fallback DOM walk in collectFiberRoots from recursive to explicit-stack iterative (cubic flagged stack-overflow risk on deep React trees) ScreenshotLabel grows an area field so the renderer can sort; the collection pass adds the candidate's host bounds area into the candidate via the memo. No state shape changes outside that. Adds an e2e test that verifies setEnabled(false) actually silences the shortcut, alongside the previously added platform/keypress coverage. All 10 screenshot-labels tests still pass. Co-authored-by: Aiden Bai --- .../react-grab/e2e/screenshot-labels.spec.ts | 24 +++++++ .../src/components/overlay-canvas.tsx | 24 ++++++- packages/react-grab/src/constants.ts | 3 + packages/react-grab/src/core/index.tsx | 11 ++- packages/react-grab/src/types.ts | 1 + .../src/utils/collect-screenshot-labels.ts | 69 ++++++++++--------- 6 files changed, 98 insertions(+), 34 deletions(-) diff --git a/packages/react-grab/e2e/screenshot-labels.spec.ts b/packages/react-grab/e2e/screenshot-labels.spec.ts index 4a18f4aa2..9c4bae513 100644 --- a/packages/react-grab/e2e/screenshot-labels.spec.ts +++ b/packages/react-grab/e2e/screenshot-labels.spec.ts @@ -105,6 +105,30 @@ test.describe("Screenshot Labels - smoke", () => { }); macTest.describe("Screenshot Labels - macOS (Cmd+Shift)", () => { + macTest("does not activate when react-grab is disabled via API", async ({ page }) => { + await page.evaluate(() => { + const api = (window as { __REACT_GRAB__?: { setEnabled: (enabled: boolean) => void } }) + .__REACT_GRAB__; + api?.setEnabled(false); + }); + + await page.keyboard.down("Meta"); + await page.keyboard.down("Shift"); + await page.waitForTimeout(200); + + const result = await inspectOverlayCanvasInk(page); + expect(result.hasInk).toBe(false); + + await page.keyboard.up("Shift"); + await page.keyboard.up("Meta"); + + await page.evaluate(() => { + const api = (window as { __REACT_GRAB__?: { setEnabled: (enabled: boolean) => void } }) + .__REACT_GRAB__; + api?.setEnabled(true); + }); + }); + macTest("Cmd then Shift activates labels", async ({ page }) => { const before = await inspectOverlayCanvasInk(page); expect(before.found).toBe(true); diff --git a/packages/react-grab/src/components/overlay-canvas.tsx b/packages/react-grab/src/components/overlay-canvas.tsx index 30b71a761..9c76e421d 100644 --- a/packages/react-grab/src/components/overlay-canvas.tsx +++ b/packages/react-grab/src/components/overlay-canvas.tsx @@ -16,6 +16,7 @@ import { OVERLAY_FILL_COLOR_DEFAULT, BASELINE_FRAME_DURATION_MS, SCREENSHOT_LABEL_BACKGROUND_COLOR, + SCREENSHOT_LABEL_COLLISION_PADDING_PX, SCREENSHOT_LABEL_CORNER_RADIUS_PX, SCREENSHOT_LABEL_FONT_FAMILY, SCREENSHOT_LABEL_FONT_SIZE_PX, @@ -315,9 +316,13 @@ export const OverlayCanvas: Component = (props) => { const paddingY = SCREENSHOT_LABEL_PADDING_Y_PX; const rectHeight = textHeight + paddingY * 2; const cornerRadius = SCREENSHOT_LABEL_CORNER_RADIUS_PX; + const collisionPadding = SCREENSHOT_LABEL_COLLISION_PADDING_PX; const middotSegment = ` ${SCREENSHOT_LABEL_MIDDOT} `; - for (const label of labels) { + const sortedLabels = labels.slice().sort((labelA, labelB) => labelB.area - labelA.area); + const placedRects: Array<{ x: number; y: number; width: number; height: number }> = []; + + for (const label of sortedLabels) { const componentText = label.componentName; const fileText = label.fileBaseName ?? ""; const componentWidth = context.measureText(componentText).width; @@ -336,6 +341,23 @@ export const OverlayCanvas: Component = (props) => { } if (rectX < 0) rectX = 0; + let collidesWithPlaced = false; + for (const placed of placedRects) { + const horizontallyApart = + rectX + rectWidth + collisionPadding <= placed.x || + placed.x + placed.width + collisionPadding <= rectX; + const verticallyApart = + rectY + rectHeight + collisionPadding <= placed.y || + placed.y + placed.height + collisionPadding <= rectY; + if (!horizontallyApart && !verticallyApart) { + collidesWithPlaced = true; + break; + } + } + if (collidesWithPlaced) continue; + + placedRects.push({ x: rectX, y: rectY, width: rectWidth, height: rectHeight }); + context.fillStyle = SCREENSHOT_LABEL_BACKGROUND_COLOR; context.beginPath(); context.roundRect(rectX, rectY, rectWidth, rectHeight, cornerRadius); diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index b7673a649..be0e64aba 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -190,6 +190,9 @@ export const NEXTJS_REVALIDATION_DELAY_MS = 1000; export const SCREENSHOT_LABEL_FONT_SIZE_PX = 11; export const SCREENSHOT_LABEL_PADDING_X_PX = 4; export const SCREENSHOT_LABEL_PADDING_Y_PX = 2; +export const SCREENSHOT_LABEL_MIN_ELEMENT_WIDTH_PX = 80; +export const SCREENSHOT_LABEL_MIN_ELEMENT_HEIGHT_PX = 28; +export const SCREENSHOT_LABEL_COLLISION_PADDING_PX = 2; export const SCREENSHOT_LABEL_BACKGROUND_COLOR = "rgba(0, 0, 0, 0.78)"; export const SCREENSHOT_LABEL_TEXT_COLOR = "#ffffff"; export const SCREENSHOT_LABEL_SECONDARY_COLOR = "rgba(255, 255, 255, 0.6)"; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index e64893e02..f4bf9acfd 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -78,6 +78,8 @@ import { WINDOW_REFOCUS_GRACE_PERIOD_MS, PREVIEW_TEXT_MAX_LENGTH, NEXTJS_REVALIDATION_DELAY_MS, + SCREENSHOT_LABEL_MIN_ELEMENT_HEIGHT_PX, + SCREENSHOT_LABEL_MIN_ELEMENT_WIDTH_PX, TOOLBAR_DEFAULT_POSITION_RATIO, DEFAULT_ACTION_ID, } from "../constants.js"; @@ -3532,18 +3534,22 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { void viewportVersion(); const fileNames = screenshotLabelFileNames(); const labels: ScreenshotLabel[] = []; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; for (const candidate of candidates) { if (!isElementConnected(candidate.element)) continue; const rect = candidate.element.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) continue; + if (rect.width < SCREENSHOT_LABEL_MIN_ELEMENT_WIDTH_PX) continue; + if (rect.height < SCREENSHOT_LABEL_MIN_ELEMENT_HEIGHT_PX) continue; if (rect.right <= 0 || rect.bottom <= 0) continue; - if (rect.left >= window.innerWidth || rect.top >= window.innerHeight) continue; + if (rect.left >= viewportWidth || rect.top >= viewportHeight) continue; labels.push({ id: candidate.fiberId, x: rect.left, y: rect.top, + area: rect.width * rect.height, componentName: candidate.componentName, fileBaseName: fileNames.get(candidate.fiberId) ?? candidate.fileBaseName, }); @@ -3565,6 +3571,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (event.repeat) return; if (!isScreenshotTriggerKey(event)) return; if (!isScreenshotShortcutPressed(event)) return; + if (!isEnabled()) return; if (isPromptMode()) return; if (isCopying()) return; if (isKeyboardEventTriggeredByInput(event)) return; diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 6a65f3bd5..26ee9a34d 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -335,6 +335,7 @@ export interface ScreenshotLabel { id: number; x: number; y: number; + area: number; componentName: string; fileBaseName: string | null; } diff --git a/packages/react-grab/src/utils/collect-screenshot-labels.ts b/packages/react-grab/src/utils/collect-screenshot-labels.ts index 31e486357..5b13ca595 100644 --- a/packages/react-grab/src/utils/collect-screenshot-labels.ts +++ b/packages/react-grab/src/utils/collect-screenshot-labels.ts @@ -25,20 +25,26 @@ const collectFiberRoots = (): Set => { if (typedFiberRoots.size > 0) return typedFiberRoots; const collected = new Set(); - const walk = (element: Element): void => { + if (typeof document === "undefined") return collected; + + const elementsToVisit: Element[] = [document.body]; + while (elementsToVisit.length > 0) { + const element = elementsToVisit.pop(); + if (!element) continue; + const fiber = getFiberFromHostInstance(element); if (fiber) { let current: Fiber | null = fiber; while (current?.return) current = current.return; const root = current?.stateNode as FiberRootLike | undefined; if (root) collected.add(root); - return; + continue; } - for (const child of Array.from(element.children)) { - walk(child); + for (const child of element.children) { + elementsToVisit.push(child); } - }; - if (typeof document !== "undefined") walk(document.body); + } + return collected; }; @@ -103,34 +109,35 @@ export const collectScreenshotLabels = (): ScreenshotLabelCandidate[] => { const fiberRoots = collectFiberRoots(); const candidates: ScreenshotLabelCandidate[] = []; const seenElements = new WeakSet(); - - const visit = (fiber: Fiber | null | undefined): void => { - if (!fiber) return; - - if (isCompositeFiber(fiber)) { - const displayName = getDisplayName(fiber.type); - if (displayName && isUsefulComponentName(displayName)) { - const hostElement = findNearestHostElement(fiber); - if (hostElement && !seenElements.has(hostElement) && isElementOnscreen(hostElement)) { - seenElements.add(hostElement); - const fileName = getSyncFileName(fiber); - candidates.push({ - fiberId: getStableFiberId(fiber), - element: hostElement, - componentName: displayName, - fileBaseName: fileName ? getFileBaseName(fileName) : null, - }); - } - } - } - - visit(fiber.child); - visit(fiber.sibling); - }; + const walkStack: Array = []; for (const fiberRoot of fiberRoots) { if (!fiberRoot.current) continue; - visit(fiberRoot.current); + walkStack.push(fiberRoot.current); + } + + while (walkStack.length > 0) { + const fiber = walkStack.pop(); + if (!fiber) continue; + + if (fiber.sibling) walkStack.push(fiber.sibling); + if (fiber.child) walkStack.push(fiber.child); + + if (!isCompositeFiber(fiber)) continue; + const displayName = getDisplayName(fiber.type); + if (!displayName || !isUsefulComponentName(displayName)) continue; + + const hostElement = findNearestHostElement(fiber); + if (!hostElement || seenElements.has(hostElement) || !isElementOnscreen(hostElement)) continue; + + seenElements.add(hostElement); + const fileName = getSyncFileName(fiber); + candidates.push({ + fiberId: getStableFiberId(fiber), + element: hostElement, + componentName: displayName, + fileBaseName: fileName ? getFileBaseName(fileName) : null, + }); } return candidates;