Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/render-scan-trace.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion apps/website-v2/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -35,8 +36,10 @@ export default function RootLayout({
suppressHydrationWarning
className={`${inter.variable} ${geistMono.variable} antialiased`}
>
<head>
<Script src="/script.js" strategy="beforeInteractive" />

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.

P2: Gate the beforeInteractive React Grab script to development; loading it unconditionally includes dev instrumentation in production.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/website-v2/app/layout.tsx, line 40:

<comment>Gate the `beforeInteractive` React Grab script to development; loading it unconditionally includes dev instrumentation in production.</comment>

<file context>
@@ -35,8 +36,10 @@ export default function RootLayout({
       className={`${inter.variable} ${geistMono.variable} antialiased`}
     >
+      <head>
+        <Script src="/script.js" strategy="beforeInteractive" />
+      </head>
       <body>
</file context>
Suggested change
<Script src="/script.js" strategy="beforeInteractive" />
{process.env.NODE_ENV === "development" && (
<Script src="/script.js" strategy="beforeInteractive" />
)}

Comment thread
vercel[bot] marked this conversation as resolved.

@vercel vercel Bot Jun 1, 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.

React Grab script loads in all environments instead of being gated to development only

Fix on Vercel

@vercel vercel Bot Jun 1, 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.

React Grab script loads unconditionally in all environments instead of only in development

Fix on Vercel

@vercel vercel Bot Jun 1, 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.

React Grab instrumentation script is loaded unconditionally in all environments without checking NODE_ENV

Fix on Vercel

@vercel vercel Bot Jun 1, 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.

React Grab instrumentation script loads in all environments instead of being gated to development only

Fix on Vercel

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.

Suggested change
<Script src="/script.js" strategy="beforeInteractive" />
{process.env.NODE_ENV === "development" && (
<Script src="/script.js" strategy="beforeInteractive" />
)}

React Grab script is loaded unconditionally in all environments instead of only in development

Fix on Vercel

</head>
<body>
<script src="/script.js" defer />
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>{children}</TooltipProvider>
</ThemeProvider>
Expand Down
56 changes: 56 additions & 0 deletions packages/react-grab/e2e/scan.spec.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(
'[data-react-grab-toolbar-action="scan"]',
);
button?.click();
}, ATTRIBUTE_NAME);
};

const isScanCanvasPresent = (page: Page): Promise<boolean> =>
page.evaluate(() => Boolean(document.querySelector("[data-react-grab-scan-canvas]")));

const isScanCopiedVisible = (page: Page): Promise<boolean> =>
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);
});
});
27 changes: 27 additions & 0 deletions packages/react-grab/src/components/copied-pill.tsx
Original file line number Diff line number Diff line change
@@ -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<CopiedPillProps> = (props) => {
return (
<div
role="status"
aria-live="polite"
class="contain-layout shrink-0 flex items-center gap-0.5 py-1.5 px-2 w-fit h-fit max-w-[280px] rounded-full antialiased bg-[var(--rg-panel-bg)] [font-synthesis:none]"
style={{
filter: `drop-shadow(${PANEL_SHADOW})`,
animation: `rg-copied-pill-out ${FADE_DURATION_MS}ms ease-out ${FEEDBACK_DURATION_MS - FADE_DURATION_MS}ms forwards`,
}}
>
<IconCheck size={14} aria-hidden="true" class="text-[var(--rg-text-primary-85)] shrink-0" />
<span class="text-[var(--rg-text-primary)] text-[13px] leading-4 font-sans font-medium h-fit tabular-nums overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
{props.text}
</span>
</div>
);
};
27 changes: 27 additions & 0 deletions packages/react-grab/src/components/icons/icon-scan.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Component } from "solid-js";

interface IconScanProps {
size?: number;
class?: string;
}

export const IconScan: Component<IconScanProps> = (props) => {
const size = () => props.size ?? 14;

return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size()}
height={size()}
viewBox="0 0 24 24"
fill="currentColor"
class={props.class}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41L13.7 2.71a2.41 2.41 0 0 0-3.41 0ZM12 7.5a1 1 0 0 1 1 1v3a1 1 0 0 1-2 0v-3a1 1 0 0 1 1-1ZM13.05 15.5a1.05 1.05 0 1 0-2.1 0 1.05 1.05 0 0 0 2.1 0Z"
/>
</svg>
);
};
23 changes: 23 additions & 0 deletions packages/react-grab/src/components/icons/icon-stop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Component } from "solid-js";

interface IconStopProps {
size?: number;
class?: string;
}

export const IconStop: Component<IconStopProps> = (props) => {
const size = () => props.size ?? 14;

return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size()}
height={size()}
viewBox="0 0 24 24"
fill="currentColor"
class={props.class}
>
<rect x="5" y="5" width="14" height="14" rx="3.5" />
</svg>
);
};
40 changes: 2 additions & 38 deletions packages/react-grab/src/components/overlay-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -130,12 +132,6 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (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,
Expand Down Expand Up @@ -181,38 +177,6 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (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;
Expand Down
4 changes: 4 additions & 0 deletions packages/react-grab/src/components/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ export const ReactGrabRenderer: Component<ReactGrabRendererProps> = (props) => {
<Show when={props.toolbarVisible !== false}>
<Toolbar
isActive={props.isActive}
isScanning={props.isScanning}
scanAvailable={props.scanAvailable}
onToggleScan={props.onToggleScan}
scanCopiedToken={props.scanCopiedToken}
isContextMenuOpen={props.contextMenuPosition !== null}
onToggle={props.onToggleActive}
onActivateAction={props.onActivateAction}
Expand Down
65 changes: 64 additions & 1 deletion packages/react-grab/src/components/toolbar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { createEffect, createSignal, on, onCleanup, onMount, type Component } from "solid-js";
import { createEffect, createSignal, on, onCleanup, onMount, Show, type Component } from "solid-js";
import type { Position } from "../../types.js";
import { cn } from "../../utils/cn.js";
import { CopiedPill } from "../copied-pill.js";
import { loadToolbarState, saveToolbarState, type SnapEdge, type ToolbarState } from "./state.js";
import { IconSelect } from "../icons/icon-select.jsx";
import { IconComment } from "../icons/icon-comment.jsx";
import { IconStyle } from "../icons/icon-style.jsx";
import { IconScan } from "../icons/icon-scan.jsx";
import { IconStop } from "../icons/icon-stop.jsx";
import { IconLoader } from "../icons/icon-loader.jsx";
import { ToolbarActionButton } from "./toolbar-action-button.jsx";
import {
TOOLBAR_SNAP_MARGIN_PX,
Expand All @@ -19,6 +23,8 @@ import {
DEFAULT_ACTION_ID,
COMMENT_ACTION_ID,
EDIT_ACTION_ID,
SCAN_ACTION_ID,
LABEL_GAP_PX,
} from "../../constants.js";
import { freezeUpdates } from "../../utils/freeze-updates.js";
import { freezeGlobalAnimations, unfreezeGlobalAnimations } from "../../utils/freeze-animations.js";
Expand All @@ -39,6 +45,10 @@ import { accumulateRotationDeg } from "../../utils/accumulate-rotation.js";

interface ToolbarProps {
isActive?: boolean;
isScanning?: boolean;
scanAvailable?: boolean;
onToggleScan?: () => void;
scanCopiedToken?: number | null;
isContextMenuOpen?: boolean;
onToggle?: () => void;
onActivateAction?: (actionId: string) => void;
Expand Down Expand Up @@ -146,6 +156,20 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
}
};

const scanCopiedToastStyle = (): Record<string, string> => {
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();
};
Expand Down Expand Up @@ -281,6 +305,7 @@ export const Toolbar: Component<ToolbarProps> = (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";
Expand Down Expand Up @@ -655,6 +680,18 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
props.onSelectHoverChange?.(false);
}}
>
<Show when={props.scanCopiedToken} keyed>
{(scanCopiedToken) => (
<div
data-react-grab-scan-copied
data-scan-copied-token={scanCopiedToken}
class="absolute pointer-events-none"
style={scanCopiedToastStyle()}
>
<CopiedPill text="Copied" />
</div>
)}
</Show>
<ToolbarContent
isCollapsed={isCollapsed()}
snapEdge={snapEdge()}
Expand Down Expand Up @@ -747,6 +784,32 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
tooltipPosition={tooltipPosition()}
tooltip="Style"
/>
<Show when={props.scanAvailable || props.isScanning}>
<ToolbarActionButton
actionId={SCAN_ACTION_ID}
isToggle
label={props.isScanning ? "Stop scanning renders" : "Scan renders"}
isActive={Boolean(props.isScanning)}
class={actionButtonClass}
wrapperClass={actionButtonWrapperClass()}
onClick={handleScan}
{...createFreezeHandlers(SCAN_ACTION_ID, { shouldFreezeInteractions: false })}
icon={
props.isScanning ? (
hoveredActionId() === SCAN_ACTION_ID ? (
<IconStop size={14} class="text-[var(--rg-error-text)]" />
) : (
<IconLoader size={14} class={actionIconClass(true)} />
)
) : (
<IconScan size={14} class={actionIconClass(false)} />
)
}
tooltipVisible={isTooltipVisible(SCAN_ACTION_ID)}
tooltipPosition={tooltipPosition()}
tooltip={props.isScanning ? "Stop scanning" : "Scan"}
/>
</Show>
</>
}
/>
Expand Down
17 changes: 17 additions & 0 deletions packages/react-grab/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading