diff --git a/.changeset/render-scan-trace.md b/.changeset/render-scan-trace.md
new file mode 100644
index 000000000..4feccb918
--- /dev/null
+++ b/.changeset/render-scan-trace.md
@@ -0,0 +1,5 @@
+---
+"react-grab": minor
+---
+
+Add a render scan that highlights re-rendering components on a canvas overlay, and copies an agent-readable performance trace (per-commit fiber detail with render reasons and source, plus long-animation-frames) to the clipboard when you stop scanning. The toolbar shows a "Copied" pill on stop.
diff --git a/apps/website-v2/app/layout.tsx b/apps/website-v2/app/layout.tsx
index 593196d1f..87a615869 100644
--- a/apps/website-v2/app/layout.tsx
+++ b/apps/website-v2/app/layout.tsx
@@ -3,6 +3,7 @@ import { Geist_Mono, Inter } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";
+import Script from "next/script";
const inter = Inter({
variable: "--font-inter",
@@ -35,8 +36,10 @@ export default function RootLayout({
suppressHydrationWarning
className={`${inter.variable} ${geistMono.variable} antialiased`}
>
+
+
+
-
{children}
diff --git a/packages/react-grab/e2e/scan.spec.ts b/packages/react-grab/e2e/scan.spec.ts
new file mode 100644
index 000000000..c07371b5d
--- /dev/null
+++ b/packages/react-grab/e2e/scan.spec.ts
@@ -0,0 +1,56 @@
+import { test, expect, type ReactGrabPageObject } from "./fixtures.js";
+import type { Page } from "@playwright/test";
+
+const ATTRIBUTE_NAME = "data-react-grab";
+
+const waitForToolbar = async (reactGrab: ReactGrabPageObject) => {
+ await expect.poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }).toBe(true);
+};
+
+// The scan button is independent of the overlay/active flow, so it can't go
+// through the shared clickToolbarAction helper (which waits for activation).
+const clickScanButton = async (page: Page) => {
+ await page.evaluate((attrName) => {
+ const host = document.querySelector(`[${attrName}]`);
+ const root = host?.shadowRoot?.querySelector(`[${attrName}]`);
+ const button = root?.querySelector(
+ '[data-react-grab-toolbar-action="scan"]',
+ );
+ button?.click();
+ }, ATTRIBUTE_NAME);
+};
+
+const isScanCanvasPresent = (page: Page): Promise =>
+ page.evaluate(() => Boolean(document.querySelector("[data-react-grab-scan-canvas]")));
+
+const isScanCopiedVisible = (page: Page): Promise =>
+ page.evaluate((attrName) => {
+ const host = document.querySelector(`[${attrName}]`);
+ const root = host?.shadowRoot?.querySelector(`[${attrName}]`);
+ return Boolean(root?.querySelector("[data-react-grab-scan-copied]"));
+ }, ATTRIBUTE_NAME);
+
+test.describe("Render scan", () => {
+ test("starts and stops the scan canvas from the toolbar", async ({ reactGrab }) => {
+ await waitForToolbar(reactGrab);
+
+ await clickScanButton(reactGrab.page);
+ await expect.poll(() => isScanCanvasPresent(reactGrab.page), { timeout: 2000 }).toBe(true);
+ expect(await reactGrab.getToolbarActionPressed("scan")).toBe(true);
+
+ await clickScanButton(reactGrab.page);
+ await expect.poll(() => isScanCanvasPresent(reactGrab.page), { timeout: 2000 }).toBe(false);
+ expect(await reactGrab.getToolbarActionPressed("scan")).toBe(false);
+ });
+
+ test("copies a trace and shows the Copied toast on stop", async ({ reactGrab }) => {
+ await waitForToolbar(reactGrab);
+
+ await clickScanButton(reactGrab.page);
+ // Force a React commit so the trace is non-empty (no trace -> no toast).
+ await reactGrab.page.getByTestId("add-element-button").click();
+
+ await clickScanButton(reactGrab.page);
+ await expect.poll(() => isScanCopiedVisible(reactGrab.page), { timeout: 2000 }).toBe(true);
+ });
+});
diff --git a/packages/react-grab/src/components/copied-pill.tsx b/packages/react-grab/src/components/copied-pill.tsx
new file mode 100644
index 000000000..4026e9a71
--- /dev/null
+++ b/packages/react-grab/src/components/copied-pill.tsx
@@ -0,0 +1,27 @@
+import type { Component } from "solid-js";
+import { FADE_DURATION_MS, FEEDBACK_DURATION_MS, PANEL_SHADOW } from "../constants.js";
+import { IconCheck } from "./icons/icon-check.jsx";
+
+interface CopiedPillProps {
+ text: string;
+}
+
+// Static "copied" pill that self-fades on a CSS animation; parent only mounts it.
+export const CopiedPill: Component = (props) => {
+ return (
+
+
+
+ {props.text}
+
+
+ );
+};
diff --git a/packages/react-grab/src/components/icons/icon-scan.tsx b/packages/react-grab/src/components/icons/icon-scan.tsx
new file mode 100644
index 000000000..3ca938de3
--- /dev/null
+++ b/packages/react-grab/src/components/icons/icon-scan.tsx
@@ -0,0 +1,27 @@
+import type { Component } from "solid-js";
+
+interface IconScanProps {
+ size?: number;
+ class?: string;
+}
+
+export const IconScan: Component = (props) => {
+ const size = () => props.size ?? 14;
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/react-grab/src/components/icons/icon-stop.tsx b/packages/react-grab/src/components/icons/icon-stop.tsx
new file mode 100644
index 000000000..0302cb314
--- /dev/null
+++ b/packages/react-grab/src/components/icons/icon-stop.tsx
@@ -0,0 +1,23 @@
+import type { Component } from "solid-js";
+
+interface IconStopProps {
+ size?: number;
+ class?: string;
+}
+
+export const IconStop: Component = (props) => {
+ const size = () => props.size ?? 14;
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/react-grab/src/components/overlay-canvas.tsx b/packages/react-grab/src/components/overlay-canvas.tsx
index b505003bc..cfcf5b078 100644
--- a/packages/react-grab/src/components/overlay-canvas.tsx
+++ b/packages/react-grab/src/components/overlay-canvas.tsx
@@ -19,6 +19,8 @@ import {
import { nativeCancelAnimationFrame, nativeRequestAnimationFrame } from "../utils/native-raf.js";
import { supportsDisplayP3 } from "../utils/supports-display-p3.js";
import { adjustLerpForFrameDuration } from "../utils/adjust-lerp-for-frame-duration.js";
+import { parseBorderRadiusValue } from "../utils/parse-border-radius-value.js";
+import { drawRoundedRectangle } from "../utils/draw-rounded-rectangle.js";
const DEFAULT_LAYER_STYLE = {
borderColor: OVERLAY_BORDER_COLOR_DEFAULT,
@@ -130,12 +132,6 @@ export const OverlayCanvas: Component = (props) => {
}
};
- const parseBorderRadiusValue = (borderRadius: string): number => {
- if (!borderRadius) return 0;
- const match = borderRadius.match(/^(\d+(?:\.\d+)?)/);
- return match ? parseFloat(match[1]) : 0;
- };
-
const createAnimatedBounds = (
id: string,
bounds: OverlayBounds,
@@ -181,38 +177,6 @@ export const OverlayCanvas: Component = (props) => {
const resolveBoundsArray = (instance: SelectionLabelInstance): OverlayBounds[] =>
instance.boundsMultiple ?? [instance.bounds];
- const drawRoundedRectangle = (
- context: OffscreenCanvasRenderingContext2D,
- rectX: number,
- rectY: number,
- rectWidth: number,
- rectHeight: number,
- cornerRadius: number,
- fillColor: string,
- strokeColor: string,
- opacity: number = 1,
- ) => {
- if (rectWidth <= 0 || rectHeight <= 0) return;
-
- const maxCornerRadius = Math.min(rectWidth / 2, rectHeight / 2);
- const clampedCornerRadius = Math.min(cornerRadius, maxCornerRadius);
-
- const shouldSetGlobalAlpha = opacity !== 1;
- if (shouldSetGlobalAlpha) context.globalAlpha = opacity;
- context.beginPath();
- if (clampedCornerRadius > 0) {
- context.roundRect(rectX, rectY, rectWidth, rectHeight, clampedCornerRadius);
- } else {
- context.rect(rectX, rectY, rectWidth, rectHeight);
- }
- context.fillStyle = fillColor;
- context.fill();
- context.strokeStyle = strokeColor;
- context.lineWidth = 1;
- context.stroke();
- if (shouldSetGlobalAlpha) context.globalAlpha = 1;
- };
-
const renderDragLayer = () => {
const layer = layers.drag;
if (!layer.context) return;
diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx
index b6d8c83de..87ac7544b 100644
--- a/packages/react-grab/src/components/renderer.tsx
+++ b/packages/react-grab/src/components/renderer.tsx
@@ -143,6 +143,10 @@ export const ReactGrabRenderer: Component = (props) => {
void;
+ scanCopiedToken?: number | null;
isContextMenuOpen?: boolean;
onToggle?: () => void;
onActivateAction?: (actionId: string) => void;
@@ -146,6 +156,20 @@ export const Toolbar: Component = (props) => {
}
};
+ const scanCopiedToastStyle = (): Record => {
+ const gap = `calc(100% + ${LABEL_GAP_PX}px)`;
+ switch (tooltipPosition()) {
+ case "bottom":
+ return { top: gap, left: "50%", transform: "translateX(-50%)" };
+ case "left":
+ return { right: gap, top: "50%", transform: "translateY(-50%)" };
+ case "right":
+ return { left: gap, top: "50%", transform: "translateY(-50%)" };
+ default:
+ return { bottom: gap, left: "50%", transform: "translateX(-50%)" };
+ }
+ };
+
const stopEventPropagation = (event: Event) => {
event.stopImmediatePropagation();
};
@@ -281,6 +305,7 @@ export const Toolbar: Component = (props) => {
props.onActivateAction?.(COMMENT_ACTION_ID),
);
const handleStyle = drag.createDragAwareHandler(() => props.onActivateAction?.(EDIT_ACTION_ID));
+ const handleScan = drag.createDragAwareHandler(() => props.onToggleScan?.());
const actionButtonClass =
"group contain-layout flex items-center justify-center cursor-pointer interactive-scale a11y-hitbox";
@@ -655,6 +680,18 @@ export const Toolbar: Component = (props) => {
props.onSelectHoverChange?.(false);
}}
>
+
+ {(scanCopiedToken) => (
+
+
+
+ )}
+
= (props) => {
tooltipPosition={tooltipPosition()}
tooltip="Style"
/>
+
+
+ ) : (
+
+ )
+ ) : (
+
+ )
+ }
+ tooltipVisible={isTooltipVisible(SCAN_ACTION_ID)}
+ tooltipPosition={tooltipPosition()}
+ tooltip={props.isScanning ? "Stop scanning" : "Scan"}
+ />
+
>
}
/>
diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts
index e9f6a9568..0323c59a1 100644
--- a/packages/react-grab/src/constants.ts
+++ b/packages/react-grab/src/constants.ts
@@ -135,6 +135,23 @@ export const TOOLBAR_DEFAULT_POSITION_RATIO = 0.5;
export const DEFAULT_ACTION_ID = "copy";
export const COMMENT_ACTION_ID = "comment";
export const EDIT_ACTION_ID = "edit";
+export const SCAN_ACTION_ID = "scan";
+
+// One below the overlay canvas so selection/toolbar layers stay on top.
+export const Z_INDEX_SCAN_CANVAS = Z_INDEX_OVERLAY_CANVAS - 1;
+export const SCAN_OUTLINE_DURATION_MS = 900;
+export const LONG_ANIMATION_FRAME_ENTRY_TYPE = "long-animation-frame";
+export const MAX_SCAN_TRACE_COMMITS = 50;
+export const MAX_SCAN_TRACE_FIBERS_PER_COMMIT = 20;
+export const MAX_SCAN_TRACE_LOAF_ENTRIES = 100;
+export const MAX_SCAN_TRACE_LOAF_SCRIPTS = 10;
+export const SCAN_LABEL_TEXT_COLOR = "rgba(255, 255, 255, 0.95)";
+export const SCAN_LABEL_HEIGHT_PX = 15;
+export const SCAN_LABEL_PADDING_PX = 4;
+export const SCAN_LABEL_MAX_NAMES_PER_GROUP = 4;
+export const SCAN_LABEL_MAX_CHARS = 40;
+export const SCAN_LABEL_FONT =
+ "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
export const TOOLTIP_DELAY_MS = 400;
export const TOOLTIP_GRACE_PERIOD_MS = 800;
diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx
index f78945325..ad4f0ddfd 100644
--- a/packages/react-grab/src/core/index.tsx
+++ b/packages/react-grab/src/core/index.tsx
@@ -126,6 +126,8 @@ import { copyPlugin } from "./plugins/copy.js";
import { commentPlugin } from "./plugins/comment.js";
import { editPlugin } from "./plugins/edit.js";
import { openPlugin } from "./plugins/open.js";
+import { createScanPlugin } from "./plugins/scan.js";
+import { createScanController } from "./scanner.js";
import {
freezeAnimations,
freezeAllAnimations,
@@ -1503,6 +1505,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
if (isActivated()) {
deactivateRenderer();
}
+ scanController.stop();
clearCopyFeedbackCooldown();
};
@@ -1601,6 +1604,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
handleActivateAction(currentToolbarState()?.defaultAction ?? DEFAULT_ACTION_ID);
};
+ const scanController = createScanController();
+
const enterCommentModeForElement = (element: Element, positionX: number, positionY: number) => {
actions.setPendingCommentMode(false);
actions.clearInputText();
@@ -3587,6 +3592,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
toolbarVisible={pluginRegistry.store.theme.toolbar.enabled}
isActive={isActivated()}
onToggleActive={handleToggleActive}
+ isScanning={scanController.isScanning()}
+ scanAvailable={scanController.isScanAvailable()}
+ onToggleScan={scanController.toggle}
+ scanCopiedToken={scanController.scanCopiedToken()}
onActivateAction={handleActivateAction}
activeActionId={activeActionId()}
enabled={isEnabled()}
@@ -3760,6 +3769,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
for (const plugin of builtInPlugins) {
pluginRegistry.register(plugin, api);
}
+ pluginRegistry.register(createScanPlugin(scanController.stop), api);
setTimeout(() => {
isNextProjectRuntime(true);
diff --git a/packages/react-grab/src/core/plugins/scan.ts b/packages/react-grab/src/core/plugins/scan.ts
new file mode 100644
index 000000000..25b52614b
--- /dev/null
+++ b/packages/react-grab/src/core/plugins/scan.ts
@@ -0,0 +1,11 @@
+import type { Plugin } from "../../types.js";
+import { SCAN_ACTION_ID } from "../../constants.js";
+
+// Takes the controller's synced stop (not the raw scanner) so teardown keeps
+// isScanning in sync.
+export const createScanPlugin = (stopScan: () => void): Plugin => ({
+ name: SCAN_ACTION_ID,
+ setup: () => ({
+ cleanup: () => stopScan(),
+ }),
+});
diff --git a/packages/react-grab/src/core/scanner.ts b/packages/react-grab/src/core/scanner.ts
new file mode 100644
index 000000000..06e504099
--- /dev/null
+++ b/packages/react-grab/src/core/scanner.ts
@@ -0,0 +1,479 @@
+import {
+ instrument,
+ didFiberRender,
+ getDisplayName,
+ getNearestHostFiber,
+ getTimings,
+ isCompositeFiber,
+ isInstrumentationActive,
+ traverseRenderedFibers,
+ type Fiber,
+ type FiberRoot,
+} from "bippy";
+import { createSignal, onCleanup, type Accessor } from "solid-js";
+import {
+ FEEDBACK_DURATION_MS,
+ LONG_ANIMATION_FRAME_ENTRY_TYPE,
+ MAX_SCAN_TRACE_COMMITS,
+ MAX_SCAN_TRACE_FIBERS_PER_COMMIT,
+ MAX_SCAN_TRACE_LOAF_ENTRIES,
+ MAX_SCAN_TRACE_LOAF_SCRIPTS,
+ MIN_DEVICE_PIXEL_RATIO,
+ OVERLAY_BORDER_COLOR_DEFAULT,
+ OVERLAY_FILL_COLOR_DEFAULT,
+ SCAN_LABEL_FONT,
+ SCAN_LABEL_HEIGHT_PX,
+ SCAN_LABEL_PADDING_PX,
+ SCAN_LABEL_TEXT_COLOR,
+ SCAN_OUTLINE_DURATION_MS,
+ Z_INDEX_SCAN_CANVAS,
+} from "../constants.js";
+import type {
+ ScanCommit,
+ ScanLoafScript,
+ ScanLongAnimationFrame,
+ ScanRenderedFiber,
+ ScanTrace,
+} from "../types.js";
+import { copyContent } from "../utils/copy-content.js";
+import { createElementBounds } from "../utils/create-element-bounds.js";
+import { drawRoundedRectangle } from "../utils/draw-rounded-rectangle.js";
+import { formatRenderLabel } from "../utils/format-render-label.js";
+import { getChangeDescription } from "../utils/get-change-description.js";
+import { getFiberSource } from "../utils/get-fiber-source.js";
+import { hideFromThirdParties } from "../utils/hide-from-third-parties.js";
+import { isUsefulComponentName } from "../utils/is-useful-component-name.js";
+import { nativeCancelAnimationFrame, nativeRequestAnimationFrame } from "../utils/native-raf.js";
+import { parseBorderRadiusValue } from "../utils/parse-border-radius-value.js";
+import { serializeScanTrace } from "../utils/serialize-scan-trace.js";
+
+// ---------------------------------------------------------------------------
+// Trace recorder: per-commit fiber detail (actualDuration, why, where) + LoAFs.
+// ---------------------------------------------------------------------------
+
+// `long-animation-frame` fields aren't in the DOM lib types yet.
+interface LongAnimationFrameScriptTiming {
+ sourceURL?: string;
+ sourceFunctionName?: string;
+ sourceCharPosition?: number;
+ duration?: number;
+ forcedStyleAndLayoutDuration?: number;
+}
+
+interface LongAnimationFrameTiming extends PerformanceEntry {
+ blockingDuration?: number;
+ firstUIEventTimestamp?: number;
+ scripts?: LongAnimationFrameScriptTiming[];
+}
+
+interface CollectedFiber {
+ fiber: Fiber;
+ name: string;
+ actualDurationMs: number;
+ parentRendered: boolean;
+}
+
+interface ScanTraceRecorder {
+ begin: () => void;
+ end: () => void;
+ beginCommit: (timestamp: number) => void;
+ collectFiber: (fiber: Fiber, name: string, parentRendered: boolean) => void;
+ endCommit: () => void;
+ takeTrace: () => ScanTrace | null;
+}
+
+const isLongAnimationFrameSupported = (): boolean =>
+ typeof PerformanceObserver !== "undefined" &&
+ Boolean(PerformanceObserver.supportedEntryTypes?.includes(LONG_ANIMATION_FRAME_ENTRY_TYPE));
+
+const createScanTraceRecorder = (): ScanTraceRecorder => {
+ const commits: ScanCommit[] = [];
+ const longAnimationFrames: ScanLongAnimationFrame[] = [];
+ const collected: CollectedFiber[] = [];
+ let commitCount = 0;
+ let currentCommitTimestamp = 0;
+ let scanStartTimestamp = 0;
+ let observer: PerformanceObserver | null = null;
+
+ const ingestLoafEntries = (entries: PerformanceEntryList): void => {
+ for (const entry of entries) {
+ if (longAnimationFrames.length >= MAX_SCAN_TRACE_LOAF_ENTRIES) break;
+ const loafEntry = entry as LongAnimationFrameTiming;
+ const scripts: ScanLoafScript[] = (loafEntry.scripts ?? [])
+ .slice(0, MAX_SCAN_TRACE_LOAF_SCRIPTS)
+ .map((script) => ({
+ sourceURL: script.sourceURL ?? "",
+ sourceFunctionName: script.sourceFunctionName ?? "",
+ sourceCharPosition: script.sourceCharPosition ?? 0,
+ durationMs: script.duration ?? 0,
+ forcedStyleAndLayoutDurationMs: script.forcedStyleAndLayoutDuration ?? 0,
+ }));
+ longAnimationFrames.push({
+ startTimeMs: loafEntry.startTime,
+ durationMs: loafEntry.duration,
+ blockingDurationMs: loafEntry.blockingDuration ?? 0,
+ firstUIEventTimestampMs: loafEntry.firstUIEventTimestamp ?? 0,
+ scripts,
+ });
+ }
+ };
+
+ const begin = (): void => {
+ commits.length = 0;
+ longAnimationFrames.length = 0;
+ collected.length = 0;
+ commitCount = 0;
+ scanStartTimestamp = performance.now();
+ if (!isLongAnimationFrameSupported()) return;
+ observer = new PerformanceObserver((list) => ingestLoafEntries(list.getEntries()));
+ try {
+ observer.observe({ type: LONG_ANIMATION_FRAME_ENTRY_TYPE, buffered: false });
+ } catch {
+ observer = null;
+ }
+ };
+
+ const end = (): void => {
+ if (!observer) return;
+ // Drain buffered-but-undelivered entries (delivery is async) before disconnect.
+ ingestLoafEntries(observer.takeRecords());
+ observer.disconnect();
+ observer = null;
+ };
+
+ const beginCommit = (timestamp: number): void => {
+ currentCommitTimestamp = timestamp;
+ commitCount += 1;
+ collected.length = 0;
+ };
+
+ const collectFiber = (fiber: Fiber, name: string, parentRendered: boolean): void => {
+ collected.push({ fiber, name, actualDurationMs: fiber.actualDuration ?? 0, parentRendered });
+ };
+
+ const endCommit = (): void => {
+ if (collected.length === 0) return;
+ collected.sort((first, second) => second.actualDurationMs - first.actualDurationMs);
+
+ // actualDuration nests children, so the slowest fiber's is the commit total.
+ const totalActualDurationMs = collected[0].actualDurationMs;
+
+ // Enrich only the kept fibers, and now, while they're still this commit's
+ // live alternates.
+ const fibers: ScanRenderedFiber[] = collected
+ .slice(0, MAX_SCAN_TRACE_FIBERS_PER_COMMIT)
+ .map((entry) => ({
+ name: entry.name,
+ selfDurationMs: getTimings(entry.fiber).selfTime,
+ source: getFiberSource(entry.fiber),
+ change: getChangeDescription(entry.fiber, entry.parentRendered),
+ }));
+
+ commits.push({
+ timestampMs: currentCommitTimestamp,
+ totalActualDurationMs,
+ renderedFiberCount: collected.length,
+ fibers,
+ });
+ if (commits.length > MAX_SCAN_TRACE_COMMITS) commits.shift();
+ collected.length = 0;
+ };
+
+ const takeTrace = (): ScanTrace | null => {
+ if (commits.length === 0 && longAnimationFrames.length === 0) return null;
+ return {
+ durationMs: performance.now() - scanStartTimestamp,
+ commitCount,
+ commits: commits.slice(),
+ longAnimationFrames: longAnimationFrames.slice(),
+ };
+ };
+
+ return { begin, end, beginCommit, collectFiber, endCommit, takeTrace };
+};
+
+// ---------------------------------------------------------------------------
+// Scanner: a single global commit hook driving a canvas of re-render outlines.
+// ---------------------------------------------------------------------------
+
+interface ScanOutline {
+ nameCounts: Map;
+ label: string;
+ lastRenderTimestamp: number;
+}
+
+interface ScannerController {
+ start: () => void;
+ stop: () => void;
+ isScanning: () => boolean;
+ takeTrace: () => ScanTrace | null;
+}
+
+let handleCommit: (root: FiberRoot) => void = () => {};
+
+let hasActiveInstrumentation = isInstrumentationActive();
+const instrumentationListeners = new Set<() => void>();
+
+const markInstrumentationActive = (): void => {
+ if (hasActiveInstrumentation) return;
+ hasActiveInstrumentation = true;
+ for (const listener of instrumentationListeners) listener();
+};
+
+const isScanAvailable = (): boolean => hasActiveInstrumentation;
+
+const onScanAvailable = (listener: () => void): (() => void) => {
+ instrumentationListeners.add(listener);
+ return () => instrumentationListeners.delete(listener);
+};
+
+if (typeof window !== "undefined") {
+ // Not secure(): it tears the hook down if React isn't detected within ~100ms,
+ // which breaks apps that mount React after react-grab's script loads.
+ instrument({
+ name: "react-grab",
+ onActive: markInstrumentationActive,
+ onCommitFiberRoot(_rendererId, root) {
+ markInstrumentationActive();
+ return handleCommit(root);
+ },
+ });
+}
+
+const createScanner = (): ScannerController => {
+ let isScanning = false;
+ let canvas: HTMLCanvasElement | null = null;
+ let context: CanvasRenderingContext2D | null = null;
+ let viewportWidth = 0;
+ let viewportHeight = 0;
+ let devicePixelRatio = 1;
+ let animationFrameId: number | null = null;
+
+ const outlines = new Map();
+ const recorder = createScanTraceRecorder();
+
+ let currentCommitTimestamp = 0;
+
+ const didNearestCompositeAncestorRender = (fiber: Fiber): boolean => {
+ let ancestor = fiber.return;
+ while (ancestor) {
+ if (isCompositeFiber(ancestor)) return didFiberRender(ancestor);
+ ancestor = ancestor.return;
+ }
+ return false;
+ };
+
+ const recordRenderedFiber = (fiber: Fiber): void => {
+ if (!isCompositeFiber(fiber)) return;
+ const componentName = getDisplayName(fiber.type);
+ if (!componentName || !isUsefulComponentName(componentName)) return;
+
+ recorder.collectFiber(fiber, componentName, didNearestCompositeAncestorRender(fiber));
+
+ const hostFiber = getNearestHostFiber(fiber);
+ const element = hostFiber?.stateNode;
+ if (!(element instanceof HTMLElement)) return;
+
+ const existingOutline = outlines.get(element);
+ if (existingOutline) {
+ const { nameCounts } = existingOutline;
+ nameCounts.set(componentName, (nameCounts.get(componentName) ?? 0) + 1);
+ existingOutline.label = formatRenderLabel(nameCounts);
+ existingOutline.lastRenderTimestamp = currentCommitTimestamp;
+ } else {
+ const nameCounts = new Map([[componentName, 1]]);
+ outlines.set(element, {
+ nameCounts,
+ label: formatRenderLabel(nameCounts),
+ lastRenderTimestamp: currentCommitTimestamp,
+ });
+ }
+ };
+
+ handleCommit = (root: FiberRoot): void => {
+ if (!isScanning) return;
+ currentCommitTimestamp = performance.now();
+ recorder.beginCommit(currentCommitTimestamp);
+ traverseRenderedFibers(root, recordRenderedFiber);
+ recorder.endCommit();
+ scheduleFrame();
+ };
+
+ const resizeCanvas = (): void => {
+ if (!canvas) return;
+ devicePixelRatio = Math.max(window.devicePixelRatio || 1, MIN_DEVICE_PIXEL_RATIO);
+ viewportWidth = window.innerWidth;
+ viewportHeight = window.innerHeight;
+ canvas.width = viewportWidth * devicePixelRatio;
+ canvas.height = viewportHeight * devicePixelRatio;
+ canvas.style.width = `${viewportWidth}px`;
+ canvas.style.height = `${viewportHeight}px`;
+ context = canvas.getContext("2d");
+ if (context) context.scale(devicePixelRatio, devicePixelRatio);
+ };
+
+ const ensureCanvas = (): void => {
+ if (canvas) return;
+ canvas = document.createElement("canvas");
+ canvas.setAttribute("data-react-grab-scan-canvas", "");
+ hideFromThirdParties(canvas);
+ canvas.style.position = "fixed";
+ canvas.style.top = "0";
+ canvas.style.left = "0";
+ canvas.style.pointerEvents = "none";
+ canvas.style.zIndex = String(Z_INDEX_SCAN_CANVAS);
+ document.body.appendChild(canvas);
+ resizeCanvas();
+ };
+
+ const drawOutline = (element: HTMLElement, outline: ScanOutline, opacity: number): void => {
+ if (!context) return;
+ const elementBounds = createElementBounds(element);
+ if (elementBounds.width <= 0 || elementBounds.height <= 0) return;
+
+ drawRoundedRectangle(
+ context,
+ elementBounds.x,
+ elementBounds.y,
+ elementBounds.width,
+ elementBounds.height,
+ parseBorderRadiusValue(elementBounds.borderRadius),
+ OVERLAY_FILL_COLOR_DEFAULT,
+ OVERLAY_BORDER_COLOR_DEFAULT,
+ opacity,
+ );
+
+ const labelText = outline.label;
+ context.globalAlpha = opacity;
+ context.font = SCAN_LABEL_FONT;
+ const labelWidth = context.measureText(labelText).width + SCAN_LABEL_PADDING_PX * 2;
+ const labelTop =
+ elementBounds.y - SCAN_LABEL_HEIGHT_PX >= 0
+ ? elementBounds.y - SCAN_LABEL_HEIGHT_PX
+ : elementBounds.y;
+ context.fillStyle = OVERLAY_BORDER_COLOR_DEFAULT;
+ context.fillRect(elementBounds.x, labelTop, labelWidth, SCAN_LABEL_HEIGHT_PX);
+ context.fillStyle = SCAN_LABEL_TEXT_COLOR;
+ context.textBaseline = "middle";
+ context.fillText(
+ labelText,
+ elementBounds.x + SCAN_LABEL_PADDING_PX,
+ labelTop + SCAN_LABEL_HEIGHT_PX / 2,
+ );
+ context.globalAlpha = 1;
+ };
+
+ const renderFrame = (): void => {
+ animationFrameId = null;
+ if (!context) return;
+
+ context.clearRect(0, 0, viewportWidth, viewportHeight);
+
+ const currentTimestamp = performance.now();
+ let hasActiveOutlines = false;
+
+ for (const [element, outline] of outlines) {
+ const elapsedSinceRenderMs = currentTimestamp - outline.lastRenderTimestamp;
+ if (elapsedSinceRenderMs >= SCAN_OUTLINE_DURATION_MS || !element.isConnected) {
+ outlines.delete(element);
+ continue;
+ }
+ drawOutline(element, outline, 1 - elapsedSinceRenderMs / SCAN_OUTLINE_DURATION_MS);
+ hasActiveOutlines = true;
+ }
+
+ if (isScanning && hasActiveOutlines) scheduleFrame();
+ };
+
+ const scheduleFrame = (): void => {
+ if (animationFrameId !== null) return;
+ animationFrameId = nativeRequestAnimationFrame(renderFrame);
+ };
+
+ const start = (): void => {
+ if (isScanning) return;
+ isScanning = true;
+ recorder.begin();
+ ensureCanvas();
+ window.addEventListener("resize", resizeCanvas);
+ scheduleFrame();
+ };
+
+ const stop = (): void => {
+ if (!isScanning) return;
+ isScanning = false;
+ recorder.end();
+ window.removeEventListener("resize", resizeCanvas);
+ if (animationFrameId !== null) {
+ nativeCancelAnimationFrame(animationFrameId);
+ animationFrameId = null;
+ }
+ outlines.clear();
+ canvas?.remove();
+ canvas = null;
+ context = null;
+ };
+
+ return {
+ start,
+ stop,
+ isScanning: () => isScanning,
+ takeTrace: recorder.takeTrace,
+ };
+};
+
+// ---------------------------------------------------------------------------
+// Controller: reactive scan state, clipboard copy on stop, "copied" toast.
+// ---------------------------------------------------------------------------
+
+export interface ScanController {
+ isScanAvailable: Accessor;
+ isScanning: Accessor;
+ // Fresh token per copy so the toolbar can key (and replay) its toast.
+ scanCopiedToken: Accessor;
+ toggle: () => void;
+ stop: () => void;
+}
+
+export const createScanController = (): ScanController => {
+ const scanner = createScanner();
+ const [scanAvailable, setScanAvailable] = createSignal(isScanAvailable());
+ onCleanup(onScanAvailable(() => setScanAvailable(true)));
+ const [isScanning, setIsScanning] = createSignal(false);
+ const [scanCopiedToken, setScanCopiedToken] = createSignal(null);
+ let scanCopiedCount = 0;
+ let scanCopiedTimeout: ReturnType | undefined;
+
+ const flashCopied = () => {
+ scanCopiedCount += 1;
+ setScanCopiedToken(scanCopiedCount);
+ clearTimeout(scanCopiedTimeout);
+ scanCopiedTimeout = setTimeout(() => setScanCopiedToken(null), FEEDBACK_DURATION_MS);
+ };
+
+ const stop = () => {
+ if (!scanner.isScanning()) return;
+ scanner.stop();
+ setIsScanning(false);
+ };
+
+ const toggle = () => {
+ if (scanner.isScanning()) {
+ stop();
+ const trace = scanner.takeTrace();
+ if (trace && copyContent(serializeScanTrace(trace), { componentName: "ReactGrabScan" })) {
+ flashCopied();
+ }
+ return;
+ }
+ scanner.start();
+ setIsScanning(true);
+ };
+
+ onCleanup(() => {
+ clearTimeout(scanCopiedTimeout);
+ stop();
+ });
+
+ return { isScanAvailable: scanAvailable, isScanning, scanCopiedToken, toggle, stop };
+};
diff --git a/packages/react-grab/src/styles.css b/packages/react-grab/src/styles.css
index b21374578..c948b8ed7 100644
--- a/packages/react-grab/src/styles.css
+++ b/packages/react-grab/src/styles.css
@@ -337,6 +337,17 @@
will-change: transform, opacity;
}
+@keyframes rg-copied-pill-out {
+ from {
+ opacity: 1;
+ filter: drop-shadow(var(--rg-shadow)) blur(0);
+ }
+ to {
+ opacity: 0;
+ filter: drop-shadow(var(--rg-shadow)) blur(3px);
+ }
+}
+
.animate-shake-vertical {
--rg-shake-x: 0;
--rg-shake-y: 1;
diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts
index 8d5b56f16..494f17f59 100644
--- a/packages/react-grab/src/types.ts
+++ b/packages/react-grab/src/types.ts
@@ -490,6 +490,10 @@ export interface ReactGrabRendererProps {
toolbarVisible?: boolean;
isActive?: boolean;
onToggleActive?: () => void;
+ isScanning?: boolean;
+ scanAvailable?: boolean;
+ onToggleScan?: () => void;
+ scanCopiedToken?: number | null;
onActivateAction?: (actionId: string) => void;
activeActionId?: string | null;
enabled?: boolean;
@@ -585,6 +589,52 @@ export interface CompletionViewProps {
onShowContextMenu?: () => void;
}
+export interface ScanLoafScript {
+ sourceURL: string;
+ sourceFunctionName: string;
+ sourceCharPosition: number;
+ durationMs: number;
+ forcedStyleAndLayoutDurationMs: number;
+}
+
+export interface ScanLongAnimationFrame {
+ startTimeMs: number;
+ durationMs: number;
+ blockingDurationMs: number;
+ firstUIEventTimestampMs: number;
+ scripts: ScanLoafScript[];
+}
+
+export interface ScanFiberChange {
+ isFirstMount: boolean;
+ props: string[] | null;
+ state: boolean;
+ context: boolean;
+ hooks: number[];
+ parent: boolean;
+}
+
+export interface ScanRenderedFiber {
+ name: string;
+ selfDurationMs: number;
+ source: string | null;
+ change: ScanFiberChange | null;
+}
+
+export interface ScanCommit {
+ timestampMs: number;
+ totalActualDurationMs: number;
+ renderedFiberCount: number;
+ fibers: ScanRenderedFiber[];
+}
+
+export interface ScanTrace {
+ durationMs: number;
+ commitCount: number;
+ commits: ScanCommit[];
+ longAnimationFrames: ScanLongAnimationFrame[];
+}
+
export interface SelectionLabelProps {
tagName?: string;
componentName?: string;
diff --git a/packages/react-grab/src/utils/draw-rounded-rectangle.ts b/packages/react-grab/src/utils/draw-rounded-rectangle.ts
new file mode 100644
index 000000000..cb6d79db7
--- /dev/null
+++ b/packages/react-grab/src/utils/draw-rounded-rectangle.ts
@@ -0,0 +1,31 @@
+export const drawRoundedRectangle = (
+ context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
+ rectX: number,
+ rectY: number,
+ rectWidth: number,
+ rectHeight: number,
+ cornerRadius: number,
+ fillColor: string,
+ strokeColor: string,
+ opacity: number = 1,
+): void => {
+ if (rectWidth <= 0 || rectHeight <= 0) return;
+
+ const maxCornerRadius = Math.min(rectWidth / 2, rectHeight / 2);
+ const clampedCornerRadius = Math.min(cornerRadius, maxCornerRadius);
+
+ const shouldSetGlobalAlpha = opacity !== 1;
+ if (shouldSetGlobalAlpha) context.globalAlpha = opacity;
+ context.beginPath();
+ if (clampedCornerRadius > 0) {
+ context.roundRect(rectX, rectY, rectWidth, rectHeight, clampedCornerRadius);
+ } else {
+ context.rect(rectX, rectY, rectWidth, rectHeight);
+ }
+ context.fillStyle = fillColor;
+ context.fill();
+ context.strokeStyle = strokeColor;
+ context.lineWidth = 1;
+ context.stroke();
+ if (shouldSetGlobalAlpha) context.globalAlpha = 1;
+};
diff --git a/packages/react-grab/src/utils/format-render-label.ts b/packages/react-grab/src/utils/format-render-label.ts
new file mode 100644
index 000000000..291caa670
--- /dev/null
+++ b/packages/react-grab/src/utils/format-render-label.ts
@@ -0,0 +1,25 @@
+import { SCAN_LABEL_MAX_CHARS, SCAN_LABEL_MAX_NAMES_PER_GROUP } from "../constants.js";
+
+// Outline label for an element: names grouped by render count (e.g.
+// "Row, Cell ×3, List ×1"), most first, ellipsis-truncated.
+export const formatRenderLabel = (nameCounts: Map): string => {
+ const namesByCount = new Map();
+ for (const [name, count] of nameCounts) {
+ const names = namesByCount.get(count);
+ if (names) {
+ names.push(name);
+ } else {
+ namesByCount.set(count, [name]);
+ }
+ }
+
+ const label = Array.from(namesByCount)
+ .sort(([countA], [countB]) => countB - countA)
+ .map(([count, names]) => {
+ const joined = names.slice(0, SCAN_LABEL_MAX_NAMES_PER_GROUP).join(", ");
+ return count > 1 ? `${joined} ×${count}` : joined;
+ })
+ .join(", ");
+
+ return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label;
+};
diff --git a/packages/react-grab/src/utils/get-change-description.ts b/packages/react-grab/src/utils/get-change-description.ts
new file mode 100644
index 000000000..482b599e9
--- /dev/null
+++ b/packages/react-grab/src/utils/get-change-description.ts
@@ -0,0 +1,106 @@
+import {
+ ClassComponentTag,
+ type Fiber,
+ ForwardRefTag,
+ FunctionComponentTag,
+ MemoComponentTag,
+ SimpleMemoComponentTag,
+ traverseContexts,
+ traverseProps,
+ traverseState,
+} from "bippy";
+import type { ScanFiberChange } from "../types.js";
+
+const isCompositeTag = (tag: number): boolean =>
+ tag === FunctionComponentTag ||
+ tag === ClassComponentTag ||
+ tag === ForwardRefTag ||
+ tag === MemoComponentTag ||
+ tag === SimpleMemoComponentTag;
+
+const collectChangedProps = (fiber: Fiber): string[] => {
+ const changed: string[] = [];
+ traverseProps(fiber, (propName, nextValue, prevValue) => {
+ if (!Object.is(prevValue, nextValue)) changed.push(propName);
+ });
+ return changed;
+};
+
+const didAnyContextChange = (fiber: Fiber): boolean => {
+ let changed = false;
+ traverseContexts(fiber, (nextContext, prevContext) => {
+ if (!nextContext || !prevContext) return;
+ // Diverged dependency lists (conditional context read): skip, don't abort.
+ if (nextContext.context !== prevContext.context) return;
+ if (!Object.is(prevContext.memoizedValue, nextContext.memoizedValue)) {
+ changed = true;
+ return true;
+ }
+ });
+ return changed;
+};
+
+const didAnyClassStateChange = (fiber: Fiber): boolean => {
+ const previousState = fiber.alternate?.memoizedState;
+ const nextState = fiber.memoizedState;
+ if (
+ !previousState ||
+ !nextState ||
+ typeof previousState !== "object" ||
+ typeof nextState !== "object"
+ ) {
+ return previousState !== nextState;
+ }
+ const previousObject = previousState as Record;
+ const nextObject = nextState as Record;
+ for (const key of new Set([...Object.keys(previousObject), ...Object.keys(nextObject)])) {
+ if (!Object.is(previousObject[key], nextObject[key])) return true;
+ }
+ return false;
+};
+
+// Approximate: conflates useState with useMemo/useCallback/useEffect deps.
+const collectChangedHookIndices = (fiber: Fiber): number[] => {
+ const indices: number[] = [];
+ let index = 0;
+ traverseState(fiber, (nextState, prevState) => {
+ if (nextState && prevState && !Object.is(prevState.memoizedState, nextState.memoizedState)) {
+ indices.push(index);
+ }
+ index++;
+ });
+ return indices;
+};
+
+// Why a fiber re-rendered (props/state/context/hooks/mount/parent-cascade).
+// Ported from react-scan/lite; `parentRendered` is supplied by the caller.
+export const getChangeDescription = (
+ fiber: Fiber,
+ parentRendered: boolean,
+): ScanFiberChange | null => {
+ if (!isCompositeTag(fiber.tag)) return null;
+
+ if (fiber.alternate === null) {
+ return { isFirstMount: true, props: null, state: false, context: false, hooks: [], parent: false };
+ }
+
+ if (fiber.tag === ClassComponentTag) {
+ return {
+ isFirstMount: false,
+ props: collectChangedProps(fiber),
+ state: didAnyClassStateChange(fiber),
+ context: didAnyContextChange(fiber),
+ hooks: [],
+ parent: parentRendered,
+ };
+ }
+
+ return {
+ isFirstMount: false,
+ props: collectChangedProps(fiber),
+ state: false,
+ context: didAnyContextChange(fiber),
+ hooks: collectChangedHookIndices(fiber),
+ parent: parentRendered,
+ };
+};
diff --git a/packages/react-grab/src/utils/get-fiber-source.ts b/packages/react-grab/src/utils/get-fiber-source.ts
new file mode 100644
index 000000000..4c3b36146
--- /dev/null
+++ b/packages/react-grab/src/utils/get-fiber-source.ts
@@ -0,0 +1,25 @@
+import type { Fiber } from "bippy";
+import { formatOwnerStack, hasDebugSource, hasDebugStack, parseStack } from "bippy/source";
+
+// JSX call site as "file:line:col", or null. Sync, dev-only, no symbolication:
+// `_debugSource` (React 16-18) or the first `_debugStack` frame (React 19+).
+export const getFiberSource = (fiber: Fiber): string | null => {
+ if (hasDebugSource(fiber)) {
+ const { fileName, lineNumber, columnNumber } = fiber._debugSource;
+ return `${fileName}:${lineNumber}:${columnNumber}`;
+ }
+
+ if (hasDebugStack(fiber)) {
+ try {
+ const ownerStack = formatOwnerStack(fiber._debugStack.stack);
+ if (ownerStack) {
+ const frame = parseStack(ownerStack)[0];
+ if (frame?.fileName) {
+ return `${frame.fileName}:${frame.lineNumber}:${frame.columnNumber}`;
+ }
+ }
+ } catch {}
+ }
+
+ return null;
+};
diff --git a/packages/react-grab/src/utils/parse-border-radius-value.ts b/packages/react-grab/src/utils/parse-border-radius-value.ts
new file mode 100644
index 000000000..ff2cfce45
--- /dev/null
+++ b/packages/react-grab/src/utils/parse-border-radius-value.ts
@@ -0,0 +1,5 @@
+export const parseBorderRadiusValue = (borderRadius: string): number => {
+ if (!borderRadius) return 0;
+ const match = borderRadius.match(/^(\d+(?:\.\d+)?)/);
+ return match ? Number.parseFloat(match[1]) : 0;
+};
diff --git a/packages/react-grab/src/utils/serialize-scan-trace.ts b/packages/react-grab/src/utils/serialize-scan-trace.ts
new file mode 100644
index 000000000..3e5c380d2
--- /dev/null
+++ b/packages/react-grab/src/utils/serialize-scan-trace.ts
@@ -0,0 +1,77 @@
+import type { ScanFiberChange, ScanTrace } from "../types.js";
+
+const round1 = (value: number): number => Math.round(value * 10) / 10;
+
+const formatChange = (change: ScanFiberChange | null): string => {
+ if (!change) return "?";
+ if (change.isFirstMount) return "mount";
+ const parts: string[] = [];
+ if (change.props && change.props.length > 0) parts.push(`props:${change.props.join(",")}`);
+ if (change.state) parts.push("state");
+ if (change.hooks.length > 0) parts.push(`hooks:${change.hooks.join(",")}`);
+ if (change.context) parts.push("context");
+ if (change.parent) parts.push("parent");
+ return parts.length > 0 ? parts.join(" ") : "?";
+};
+
+// Compact, token-efficient log (not JSON) of the trace for pasting to an agent.
+export const serializeScanTrace = (trace: ScanTrace): string => {
+ const lines: string[] = [];
+
+ lines.push(
+ `react-grab render scan: ${(trace.durationMs / 1000).toFixed(1)}s, ` +
+ `${trace.commitCount} commits, ${trace.longAnimationFrames.length} long frames (>50ms)`,
+ );
+
+ lines.push("");
+ lines.push("commits — @start totalMs (rendered); fibers: name selfMs why @source, slowest first:");
+ if (trace.commits.length === 0) {
+ lines.push(" none");
+ } else {
+ const commits = trace.commits
+ .slice()
+ .sort((first, second) => second.totalActualDurationMs - first.totalActualDurationMs);
+ for (const commit of commits) {
+ lines.push(
+ ` @${round1(commit.timestampMs)} ${round1(commit.totalActualDurationMs)}ms` +
+ ` (${commit.renderedFiberCount} rendered)`,
+ );
+ for (const fiber of commit.fibers) {
+ const source = fiber.source ? ` @${fiber.source}` : "";
+ lines.push(
+ ` ${fiber.name} ${round1(fiber.selfDurationMs)}ms ${formatChange(fiber.change)}${source}`,
+ );
+ }
+ }
+ }
+
+ lines.push("");
+ lines.push(
+ "long animation frames — @start dur block (ms); scripts url:fn@char dur reflow:",
+ );
+ if (trace.longAnimationFrames.length === 0) {
+ lines.push(" none");
+ } else {
+ for (const frame of trace.longAnimationFrames) {
+ const uiWait =
+ frame.firstUIEventTimestampMs > 0
+ ? ` ui+${round1(frame.firstUIEventTimestampMs - frame.startTimeMs)}`
+ : "";
+ lines.push(
+ ` @${round1(frame.startTimeMs)} dur${round1(frame.durationMs)}` +
+ ` block${round1(frame.blockingDurationMs)}${uiWait}`,
+ );
+ for (const script of frame.scripts) {
+ const reflow =
+ script.forcedStyleAndLayoutDurationMs > 0
+ ? ` reflow${round1(script.forcedStyleAndLayoutDurationMs)}`
+ : "";
+ const url = script.sourceURL || "(inline)";
+ const fn = script.sourceFunctionName || "(anonymous)";
+ lines.push(` ${url}:${fn}@${script.sourceCharPosition} ${round1(script.durationMs)}ms${reflow}`);
+ }
+ }
+ }
+
+ return lines.join("\n");
+};