Skip to content

feat(react-grab): render scan with copyable performance trace#441

Open
aidenybai wants to merge 16 commits into
mainfrom
feat/render-scan-trace
Open

feat(react-grab): render scan with copyable performance trace#441
aidenybai wants to merge 16 commits into
mainfrom
feat/render-scan-trace

Conversation

@aidenybai

@aidenybai aidenybai commented Jun 1, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a render scan to the toolbar (alongside Copy / Comment / Style). While active it outlines re-rendering components on a canvas overlay; when you stop, it copies an agent-readable performance trace to the clipboard and flashes a "Copied" pill on the toolbar.

  • Performance trace payload (for agents). On stop, copies JSON containing components ranked by total actualDuration (renderCount, avg/max/total durations) plus long-animation-frames (captured via a long-animation-frame PerformanceObserver, with blockingDuration and the scripts[] that blocked the frame). Models the react-scan/lite + LoAF correlation pattern so an agent can attribute jank to a component and a script.
  • Persisted scan state. isScanning is saved to sessionStorage (SSR-guarded), and restored on load (deferred to DOMContentLoaded) so an active scan survives dev-server HMR/reloads.
  • Toolbar feedback. A lightweight, presentational CopiedPill shown on stop, auto-hiding after the standard feedback duration.

Structure

Decomposed into focused modules rather than one mega-file:

  • core/scan-trace-recorder.ts — DOM-free profiling model (component profiles + LoAF observer + takeTrace); independently testable.
  • core/scanner.ts — canvas re-render outlines; composes the recorder via a single monomorphic fiber walk.
  • core/scan-controller.ts — lifecycle: reactive isScanning/scanCopied, clipboard copy on stop, sessionStorage persistence, restore.
  • components/copied-pill.tsx — static "copied" confirmation pill.
  • utils/serialize-scan-trace.ts, utils/scan-active-storage.ts — payload serializer + SSR-guarded storage.
  • Extracted shared drawRoundedRectangle / parseBorderRadiusValue out of overlay-canvas.tsx so the scanner reuses them.

Notes

  • actualDuration is only populated in React __PROFILE__ builds (default-on in dev); plain prod builds report 0, same caveat as react-scan/lite.
  • LoAF capture is Chromium-only; Safari/Firefox no-op gracefully.
  • Empty scans (no renders, no LoAFs) copy nothing and show no toast.

Test plan

  • Toggle scan from the toolbar; confirm re-render outlines appear with component name + render count.
  • Stop scan; confirm clipboard holds the JSON trace and the "Copied performance trace" pill appears, then auto-hides.
  • Reload mid-scan; confirm the scan resumes (sessionStorage), and a fresh tab does not auto-scan.
  • Verify SSR build (website-v2) has no window/sessionStorage access errors.
  • pnpm build && pnpm typecheck && pnpm lint && pnpm test

Note

Medium Risk
Introduces global React commit instrumentation and clipboard export of performance data; impact is mostly dev UX, but it touches core lifecycle and can affect timing in profiled React builds.

Overview
Adds a render scan to the react-grab toolbar: while active, a dedicated canvas overlay outlines re-rendering DOM nodes with grouped component labels that fade out; stopping copies a compact, agent-oriented text trace (per-commit fibers with self time, render reasons, and dev source locations, plus long-animation-frame entries when supported) and shows a short Copied pill.

Wires this through a createScanController / scanner stack using bippy commit instrumentation (non-secure so late-mounted React still works), stops scan on global deactivate, and registers a small scan plugin for teardown. Shared canvas helpers (drawRoundedRectangle, parseBorderRadiusValue) are extracted for reuse by the overlay and scanner.

Also loads /script.js in website-v2 via Next <Script strategy="beforeInteractive" /> instead of a deferred body tag, and adds Playwright coverage for scan start/stop and the copied toast.

Reviewed by Cursor Bugbot for commit a5cb520. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Adds a render scan to the react-grab toolbar that outlines re-rendering components and copies a compact, per-commit performance trace to the clipboard when you stop. Internals are consolidated into core/scanner.ts (recorder + canvas + controller), and apps/website-v2 now loads /script.js with Next <Script beforeInteractive>.

  • New Features

    • Toolbar “Scan” toggle draws canvas outlines with grouped, truncated labels and render counts; labels are cached per element. The button shows a spinner while active, a red stop icon on hover, a filled scan icon when idle, and a “Stop scanning” tooltip while active; it hides until a React renderer is instrumented and stays visible while scanning.
    • On stop, copies a terse log: commits slowest-first with each fiber’s name, self time, reason (props/state/hooks/context/parent/mount), and file:line:col; plus long-animation-frame entries with blocking duration, first UI event offset, and per-script details (url, function, char position, duration, reflow). Capped for size; empty scans copy nothing.
    • Shows a short “Copied” pill keyed per copy.
  • Bug Fixes

    • Stop the scan when react-grab is disabled/collapsed; plugin teardown routes through the controller so toolbar state stays in sync.
    • More accurate, robust trace: use per-fiber self time via bippy timings, improve context-change detection, and flush pending LoAF records on stop.
    • UI polish and stability: skip zero-area elements when drawing, keep the scan button stoppable while scanning, and add e2e coverage for start/stop and the copied toast.

Written for commit a5cb520. Summary will update on new commits.

Review in cubic

Adds a toolbar render scan that outlines re-rendering components on a
canvas overlay. Stopping the scan copies an agent-readable JSON trace
(per-component render cost ranked by actualDuration, plus
long-animation-frames captured via PerformanceObserver) to the clipboard
and flashes a "Copied" pill on the toolbar. Scan state persists across
reloads via sessionStorage (SSR-guarded) so it survives dev-server HMR.

Structured into focused modules: scan-trace-recorder (DOM-free profiling
model), scanner (canvas outlines), and scan-controller (lifecycle, copy,
persistence). Extracts shared drawRoundedRectangle/parseBorderRadiusValue
utils out of overlay-canvas.
@vercel

vercel Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-grab-storybook Ready Ready Preview, Comment Jun 1, 2026 5:34pm
react-grab-website Ready Ready Preview, Comment Jun 1, 2026 5:34pm

@pkg-pr-new

pkg-pr-new Bot commented Jun 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@react-grab/cli@441
npm i https://pkg.pr.new/grab@441
npm i https://pkg.pr.new/react-grab@441

commit: a5cb520

Comment thread packages/react-grab/src/core/scanner.ts
canvas.style.pointerEvents = "none";
canvas.style.zIndex = String(Z_INDEX_SCAN_CANVAS);
document.body.appendChild(canvas);
resizeCanvas();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Scan canvas lost on body swap

Medium Severity

The scan overlay canvas is appended to document.body once and ensureCanvas bails out if a canvas reference already exists. When the framework replaces document.body (a case mount-root already handles for the main host), the canvas stays on a detached node, so outlines disappear while scanning and recording continue.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f187830. Configure here.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Valid for frameworks that swap document.body (Astro view transitions, Turbo). It's the same single-mount limitation the pre-existing mount-root util solves for the main host; the scan canvas doesn't yet reuse that observer. Given body-swap is a narrow case and the fix means wiring the canvas into the mount-root re-attach path, I'm deferring it to a follow-up rather than expanding this PR's lifecycle surface. Outlines/recording are unaffected on the common (no body-swap) path.

Comment thread packages/react-grab/src/core/plugins/scan.ts Outdated
Comment thread packages/react-grab/src/core/scanner.ts Outdated
…utlines

- Route scan plugin cleanup through the controller's synced stop so
  isScanning and sessionStorage stay consistent after unregister/dispose
  (was calling scanner.stop() directly, desyncing toolbar + storage).
- drawOutline now skips elements with zero width OR height (was AND), so
  invisible elements no longer render a floating label.
Comment thread packages/react-grab/src/core/index.tsx Outdated
Comment thread packages/react-grab/src/core/scan-controller.ts Outdated
};

const ensureCanvas = (): void => {
if (canvas) return;

@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.

Canvas element may not be recreated if orphaned during framework body replacement

Fix on Vercel

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Same body-swap case Bugbot raised; deferring (see thread above). Note this one-liner alone doesn't fix it: ensureCanvas only runs on start(), so a swap during an active scan never re-invokes it — and falling through would create a second orphaned canvas. The real fix is wiring the scan canvas into the existing mount-root re-attach observer, which I'm doing as a follow-up rather than in this feature PR.

Comment thread packages/react-grab/src/core/scanner.ts
Comment thread packages/react-grab/src/core/scan-trace-recorder.ts Outdated
Comment thread packages/react-grab/src/core/index.tsx Outdated
Comment thread packages/react-grab/src/core/scan-controller.ts Outdated
Comment thread packages/react-grab/src/core/index.tsx Outdated
… hover

While scanning, the toolbar button shows a spinner by default and reveals
a red stop icon on hover, making the active state legible and the
click-to-stop affordance explicit.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

7 issues found across 19 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/react-grab/src/utils/scan-active-storage.ts">

<violation number="1" location="packages/react-grab/src/utils/scan-active-storage.ts:20">
P2: Do not silently swallow sessionStorage write failures; log a warning so scan persistence/restore issues are diagnosable.</violation>
</file>

<file name="packages/react-grab/src/utils/parse-border-radius-value.ts">

<violation number="1" location="packages/react-grab/src/utils/parse-border-radius-value.ts:3">
P2: Percentage border-radius values are parsed as raw pixels, so scan/overlay outlines render incorrect corner radii for elements that use `%` radii.</violation>
</file>

<file name="apps/website-v2/app/layout.tsx">

<violation number="1" location="apps/website-v2/app/layout.tsx:40">
P2: Gate the `beforeInteractive` React Grab script to development; loading it unconditionally includes dev instrumentation in production.</violation>
</file>

<file name="packages/react-grab/src/core/scanner.ts">

<violation number="1" location="packages/react-grab/src/core/scanner.ts:220">
P2: Reset `handleCommit` to the no-op `() => {}` in `stop()` so the disposed scanner's state is released and React commits no longer invoke the stale handler.</violation>
</file>

<file name="packages/react-grab/src/core/scan-trace-recorder.ts">

<violation number="1" location="packages/react-grab/src/core/scan-trace-recorder.ts:85">
P3: `begin()` creates a new PerformanceObserver without disconnecting any previously active one. If `begin()` is ever called before `end()` (the current scanner prevents this, but the recorder is documented as independently reusable), the orphaned observer continues firing and mutates `longAnimationFrames` after `begin()` has cleared it. Add `observer?.disconnect()` at the top of `begin()` for safety.</violation>

<violation number="2" location="packages/react-grab/src/core/scan-trace-recorder.ts:111">
P2: `fiber.selfBaseDuration` represents React's base (non-memoized) self-duration, not the actual self-duration from the current render. Storing it as `totalSelfDurationMs` is misleading — a memoized component that bails out will still accumulate its full base cost, overstating the actual time spent. Consider renaming to `totalBaseSelfDurationMs` or documenting the semantic difference in the `ScanComponentProfile` type.</violation>
</file>

<file name="packages/react-grab/src/core/plugins/scan.ts">

<violation number="1" location="packages/react-grab/src/core/plugins/scan.ts:8">
P2: Cleanup should go through the scan controller, not just `scanner.stop()`, or the UI/persisted scan state gets out of sync on unregister.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/react-grab/src/utils/scan-active-storage.ts Outdated
@@ -0,0 +1,5 @@
export const parseBorderRadiusValue = (borderRadius: string): number => {
if (!borderRadius) return 0;
const match = borderRadius.match(/^(\d+(?:\.\d+)?)/);

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: Percentage border-radius values are parsed as raw pixels, so scan/overlay outlines render incorrect corner radii for elements that use % radii.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/src/utils/parse-border-radius-value.ts, line 3:

<comment>Percentage border-radius values are parsed as raw pixels, so scan/overlay outlines render incorrect corner radii for elements that use `%` radii.</comment>

<file context>
@@ -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;
+};
</file context>

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" />
)}

animationFrameId = null;
}
outlines.clear();
canvas?.remove();

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: Reset handleCommit to the no-op () => {} in stop() so the disposed scanner's state is released and React commits no longer invoke the stale handler.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/src/core/scanner.ts, line 220:

<comment>Reset `handleCommit` to the no-op `() => {}` in `stop()` so the disposed scanner's state is released and React commits no longer invoke the stale handler.</comment>

<file context>
@@ -0,0 +1,233 @@
+      animationFrameId = null;
+    }
+    outlines.clear();
+    canvas?.remove();
+    canvas = null;
+    context = null;
</file context>

Comment thread packages/react-grab/src/core/scan-trace-recorder.ts Outdated
export const createScanPlugin = (scanner: ScannerController): Plugin => ({
name: SCAN_ACTION_ID,
setup: () => ({
cleanup: () => scanner.stop(),

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: Cleanup should go through the scan controller, not just scanner.stop(), or the UI/persisted scan state gets out of sync on unregister.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/src/core/plugins/scan.ts, line 8:

<comment>Cleanup should go through the scan controller, not just `scanner.stop()`, or the UI/persisted scan state gets out of sync on unregister.</comment>

<file context>
@@ -0,0 +1,10 @@
+export const createScanPlugin = (scanner: ScannerController): Plugin => ({
+  name: SCAN_ACTION_ID,
+  setup: () => ({
+    cleanup: () => scanner.stop(),
+  }),
+});
</file context>

Comment thread packages/react-grab/src/core/scan-trace-recorder.ts Outdated
}
scanner.start();
syncState(true);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copied pill persists after restart

Low Severity

When a stop copies the trace, scanCopied turns on the “Copied” pill. Starting a new scan via toggle does not clear scanCopied, so the confirmation can stay visible for the rest of FEEDBACK_DURATION_MS while scanning is active again.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 875c2a4. Configure here.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/react-grab/src/components/toolbar/index.tsx">

<violation number="1" location="packages/react-grab/src/components/toolbar/index.tsx:794">
P2: The active scan button now shows the stop icon only on mouse hover, which hides the stop affordance for keyboard/touch users and makes the control state less discoverable while scanning.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment on lines +794 to +798
hoveredActionId() === SCAN_ACTION_ID ? (
<IconStop size={14} class="text-[var(--rg-error-text)]" />
) : (
<IconLoader size={14} class={actionIconClass(true)} />
)

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: The active scan button now shows the stop icon only on mouse hover, which hides the stop affordance for keyboard/touch users and makes the control state less discoverable while scanning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/src/components/toolbar/index.tsx, line 794:

<comment>The active scan button now shows the stop icon only on mouse hover, which hides the stop affordance for keyboard/touch users and makes the control state less discoverable while scanning.</comment>

<file context>
@@ -790,7 +791,11 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
               icon={
                 props.isScanning ? (
-                  <IconStop size={14} class={actionIconClass(true)} />
+                  hoveredActionId() === SCAN_ACTION_ID ? (
+                    <IconStop size={14} class="text-[var(--rg-error-text)]" />
+                  ) : (
</file context>
Suggested change
hoveredActionId() === SCAN_ACTION_ID ? (
<IconStop size={14} class="text-[var(--rg-error-text)]" />
) : (
<IconLoader size={14} class={actionIconClass(true)} />
)
<IconStop size={14} class="text-[var(--rg-error-text)]" />

Comment thread packages/react-grab/src/core/scan-trace-recorder.ts Outdated
- Teardown no longer writes scan sessionStorage; only an explicit user
  toggle does, so persisted state can't be left inconsistent by cleanup
  ordering (controller stop and plugin cleanup now share one path).
- Skip restoring a persisted scan when no toolbar is available, so it can
  never resume with no way to stop it.
- Toolbar keys the "Copied" toast on a per-copy token so back-to-back
  copies replay the fade instead of getting stuck mid-animation.
- Document name-aggregation in the trace payload so agents aren't misled
  by distinct same-named components being summed.
- Add e2e spec covering start/stop, sessionStorage persistence, reload
  resume, and the copied toast.
Replaces the verbose JSON payload with a terse, token-efficient log
(positional columns + per-section legend) so it costs far fewer tokens
when pasted to an agent. Enriches LoAF capture to match react-stack:
adds each script's source char position and the frame's
firstUIEventTimestamp, and drops low-signal invoker/styleAndLayoutStart
fields.
})
.join(", ");

return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label;

@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.

Ellipsis character appended after slicing causes label to exceed SCAN_LABEL_MAX_CHARS limit, and dangling separators can appear before the ellipsis

Fix on Vercel

Reworks the scan trace from a per-component aggregate into what
react-scan/lite records: per commit, the fibers that rendered with their
actualDuration, why they re-rendered (change description: props/state/
hooks/context/parent-cascade/mount) and where they live (source
file:line), plus the long-animation-frames over the same window so an
agent can correlate a slow frame with the commit and component behind it.

Ports lite's change-description and fiber-source (via bippy + bippy/source),
computes the parent-cascade flag from the nearest composite ancestor, and
caps stored commits/fibers so the copied log stays compact. The clipboard
log now lists commits slowest-first with each fiber's self time, reason,
and source, followed by the LoAF section.
Comment thread packages/react-grab/src/utils/get-change-description.ts Outdated

const beginCommit = (timestamp: number): void => {
currentCommitTimestamp = timestamp;
commitCount += 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Trace header inflates commit count

Medium Severity

beginCommit increments commitCount for every instrumented React commit while scanning, but endCommit only appends to commits when at least one fiber is collected (and the array is capped at MAX_SCAN_TRACE_COMMITS). The copied log headline uses commitCount, so it can report far more commits than the detailed section lists—or show “none” while the count is non-zero.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6eb670f. Configure here.

Comment thread packages/react-grab/src/core/scan-trace-recorder.ts Outdated
Comment thread packages/react-grab/src/utils/scan-active-storage.ts Outdated
})
.join(", ");

return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label;

@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.

Ellipsis appended after slicing causes string to exceed SCAN_LABEL_MAX_CHARS limit, and truncation can leave dangling separators

Fix on Vercel

Comment thread packages/react-grab/src/core/scan-controller.ts Outdated
className={`${inter.variable} ${geistMono.variable} antialiased`}
>
<head>
<Script src="/script.js" strategy="beforeInteractive" />

@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

- Stop the scan when React Grab is disabled/collapsed (forceDeactivateAll)
  so the canvas + per-commit fiber walk + LoAF observer can't keep running
  with no UI to stop them.
- Fix didAnyContextChange: skip diverged context slots instead of aborting
  and wiping a real change, so the trace's "why" column isn't under-reported.
- Use accurate per-fiber self time via bippy getTimings (was selfBaseDuration),
  and drop the redundant max loop (the slowest fiber after sort is the total).
- Install the commit hook via bippy secure() for prod/version guards + error
  isolation instead of raw instrument().
- Flush observer.takeRecords() on stop so trailing long-animation-frames
  aren't dropped.
- Keep the scan button visible while scanning (not just when available) so a
  restored scan is always stoppable.
- Remove dead trace fields (startedAtEpochMs, per-fiber actualDurationMs,
  renderStartMs) and unused API (ScannerController.toggle/dispose,
  ScanController.scanner); toggle() now composes stop().
Comment thread packages/react-grab/src/core/scan-controller.ts Outdated
ensureCanvas();
window.addEventListener("resize", resizeCanvas);
scheduleFrame();
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Failed scan start leaks observer

Medium Severity

start() sets the scanning flag and calls recorder.begin() before ensureCanvas() appends to document.body. If canvas setup throws (no body yet, as mount-root already guards elsewhere), there is no rollback: recorder.end() never runs, the LoAF PerformanceObserver stays connected, and handleCommit keeps walking fibers until reload.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 78aa07a. Configure here.

Comment thread packages/react-grab/src/core/scan-controller.ts Outdated
})
.join(", ");

return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label;

@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.

Ellipsis character appended after slicing causes label to exceed SCAN_LABEL_MAX_CHARS limit, and truncation can leave dangling separators

Fix on Vercel

Comment thread packages/react-grab/src/utils/scan-active-storage.ts Outdated
className={`${inter.variable} ${geistMono.variable} antialiased`}
>
<head>
<Script src="/script.js" strategy="beforeInteractive" />

@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

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

2 issues found across 12 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/react-grab/src/utils/parse-border-radius-value.ts">

<violation number="1" location="packages/react-grab/src/utils/parse-border-radius-value.ts:3">
P2: Percentage border-radius values are parsed as raw pixels, so scan/overlay outlines render incorrect corner radii for elements that use `%` radii.</violation>
</file>

<file name="apps/website-v2/app/layout.tsx">

<violation number="1" location="apps/website-v2/app/layout.tsx:40">
P2: Gate the `beforeInteractive` React Grab script to development; loading it unconditionally includes dev instrumentation in production.</violation>
</file>

<file name="packages/react-grab/src/core/plugins/scan.ts">

<violation number="1" location="packages/react-grab/src/core/plugins/scan.ts:8">
P2: Cleanup should go through the scan controller, not just `scanner.stop()`, or the UI/persisted scan state gets out of sync on unregister.</violation>
</file>

<file name="packages/react-grab/src/core/scanner.ts">

<violation number="1" location="packages/react-grab/src/core/scanner.ts:220">
P2: Reset `handleCommit` to the no-op `() => {}` in `stop()` so the disposed scanner's state is released and React commits no longer invoke the stale handler.</violation>
</file>

<file name="packages/react-grab/src/components/toolbar/index.tsx">

<violation number="1" location="packages/react-grab/src/components/toolbar/index.tsx:794">
P2: The active scan button now shows the stop icon only on mouse hover, which hides the stop affordance for keyboard/touch users and makes the control state less discoverable while scanning.</violation>
</file>

<file name="packages/react-grab/src/utils/format-render-label.ts">

<violation number="1" location="packages/react-grab/src/utils/format-render-label.ts:26">
P3: Truncation can leave a dangling `", "` separator before the ellipsis, producing visually jarring labels like `"Foo, Bar, …"`.</violation>

<violation number="2" location="packages/react-grab/src/utils/format-render-label.ts:26">
P3: The ellipsis character is appended *after* slicing at `SCAN_LABEL_MAX_CHARS`, so the total string can reach 41 characters, exceeding the named constant's limit by one.</violation>
</file>

<file name="packages/react-grab/src/utils/get-fiber-source.ts">

<violation number="1" location="packages/react-grab/src/utils/get-fiber-source.ts:16">
P1: Pass the `_debugStack` Error itself (and null-check it) before parsing; using `.stack` here can crash or drop the fiber's own source location.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic


if (hasDebugStack(fiber)) {
try {
const ownerStack = formatOwnerStack(fiber._debugStack.stack);

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.

P1: Pass the _debugStack Error itself (and null-check it) before parsing; using .stack here can crash or drop the fiber's own source location.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/src/utils/get-fiber-source.ts, line 16:

<comment>Pass the `_debugStack` Error itself (and null-check it) before parsing; using `.stack` here can crash or drop the fiber's own source location.</comment>

<file context>
@@ -0,0 +1,27 @@
+
+  if (hasDebugStack(fiber)) {
+    try {
+      const ownerStack = formatOwnerStack(fiber._debugStack.stack);
+      if (ownerStack) {
+        const frame = parseStack(ownerStack)[0];
</file context>

Comment thread packages/react-grab/src/core/scanner.ts Outdated
Comment thread packages/react-grab/src/core/scanner.ts Outdated
aidenybai added 2 commits June 1, 2026 00:55
…Stop scanning" tooltip

handleCommit now takes only the FiberRoot. The scan button shows a
"Stop scanning" tooltip on hover while active (was suppressed).
Reloading no longer resumes a scan. Removes scan-active-storage and the
DOMContentLoaded restore path, so a scan is purely in-memory for the
session and toggled only by the user. Simplifies the controller (no
persistence options) and drops the now-moot reload-resume e2e test.
@cubic-dev-ai

cubic-dev-ai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

You're iterating quickly on this pull request. To help protect your rate limits, cubic has paused automatic reviews on new pushes for now—when you're ready for another review, comment @cubic-dev-ai review.


for (const [element, outline] of outlines) {
const elapsedSinceRenderMs = currentTimestamp - outline.lastRenderTimestamp;
if (elapsedSinceRenderMs >= SCAN_OUTLINE_DURATION_MS || !element.isConnected) {

@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.

The stop() method doesn't reset handleCommit to a no-op function, causing stale scanner handler closures to persist in memory after disposal

Fix on Vercel

})
.join(", ");

return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label;

@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.

The ellipsis character is appended after slicing at SCAN_LABEL_MAX_CHARS (40), causing the final string to exceed the 40-character limit. Additionally, truncation can leave a dangling ", " separator before the ellipsis, creating a visually jarring result.

Fix on Vercel

const begin = (): void => {
commits.length = 0;
longAnimationFrames.length = 0;
collected.length = 0;

@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.

PerformanceObserver resource leak when begin() is called twice without end() being called in between

Fix on Vercel

className={`${inter.variable} ${geistMono.variable} antialiased`}
>
<head>
<Script src="/script.js" strategy="beforeInteractive" />

@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

};

const ensureCanvas = (): void => {
if (canvas) return;

@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.

Canvas element may not be recreated if orphaned during framework body replacement - stale reference causes orphaned canvas to be reused

Fix on Vercel

className={`${inter.variable} ${geistMono.variable} antialiased`}
>
<head>
<Script src="/script.js" strategy="beforeInteractive" />

@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

})
.join(", ");

return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label;

@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.

Ellipsis character appended after slicing causes truncated label to exceed SCAN_LABEL_MAX_CHARS limit of 40 characters

Fix on Vercel

…comments

Merges scan-controller and scan-trace-recorder into core/scanner.ts
(recorder -> scanner -> controller), shrinking the public surface to
createScanController/ScanController. Also trims the scan feature's
comments down to only non-obvious "why" notes per AGENTS.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 6 total unresolved issues (including 4 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a5cb520. Configure here.

longAnimationFrames.length = 0;
collected.length = 0;
commitCount = 0;
scanStartTimestamp = performance.now();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Uncopied trace cleared on restart

Medium Severity

When a scan ends without a successful clipboard copy—via scanController.stop() (toolbar disable/collapse) or a failed copyContent on toolbar stop—the recorder still holds the trace. Starting a scan again runs recorder.begin(), which clears that data with no way to recover it.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a5cb520. Configure here.

};

const endCommit = (): void => {
if (collected.length === 0) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Scan commit count overcounts

Low Severity

commitCount increments on every React commit during a scan, but commits with no collected fibers are omitted from the commits array. The serialized header reports that inflated total, which misleads agents comparing it to the listed commit blocks.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a5cb520. Configure here.

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.

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

};

const ensureCanvas = (): void => {
if (canvas) return;

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
if (canvas) return;
if (canvas && canvas.isConnected) return;

Canvas element may not be recreated if orphaned during framework body replacement

Fix on Vercel

};

const begin = (): void => {
commits.length = 0;

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.

PerformanceObserver resource leak when begin() is called twice without end() being called in between

Fix on Vercel

const markInstrumentationActive = (): void => {
if (hasActiveInstrumentation) return;
hasActiveInstrumentation = true;
for (const listener of instrumentationListeners) listener();

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.

Global handleCommit function holds stale scanner closures after stop() is called, preventing garbage collection of scanner state variables

Fix on Vercel

})
.join(", ");

return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label;

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
return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label;
if (label.length > SCAN_LABEL_MAX_CHARS) {
let truncated = label.slice(0, SCAN_LABEL_MAX_CHARS - 1);
// Remove dangling ", " separator if present at the end
truncated = truncated.replace(/,\s*$/, "");
return `${truncated}…`;
}
return label;

Label truncation exceeds 40-character limit and can leave dangling comma separators

Fix on Vercel

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant