feat(react-grab): render scan with copyable performance trace#441
feat(react-grab): render scan with copyable performance trace#441aidenybai wants to merge 16 commits into
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
commit: |
| canvas.style.pointerEvents = "none"; | ||
| canvas.style.zIndex = String(Z_INDEX_SCAN_CANVAS); | ||
| document.body.appendChild(canvas); | ||
| resizeCanvas(); |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit f187830. Configure here.
There was a problem hiding this comment.
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.
…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.
| }; | ||
|
|
||
| const ensureCanvas = (): void => { | ||
| if (canvas) return; |
There was a problem hiding this comment.
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.
… 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.
There was a problem hiding this comment.
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
| @@ -0,0 +1,5 @@ | |||
| export const parseBorderRadiusValue = (borderRadius: string): number => { | |||
| if (!borderRadius) return 0; | |||
| const match = borderRadius.match(/^(\d+(?:\.\d+)?)/); | |||
There was a problem hiding this comment.
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" /> |
There was a problem hiding this comment.
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>
| <Script src="/script.js" strategy="beforeInteractive" /> | |
| {process.env.NODE_ENV === "development" && ( | |
| <Script src="/script.js" strategy="beforeInteractive" /> | |
| )} |
| animationFrameId = null; | ||
| } | ||
| outlines.clear(); | ||
| canvas?.remove(); |
There was a problem hiding this comment.
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>
| export const createScanPlugin = (scanner: ScannerController): Plugin => ({ | ||
| name: SCAN_ACTION_ID, | ||
| setup: () => ({ | ||
| cleanup: () => scanner.stop(), |
There was a problem hiding this comment.
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>
| } | ||
| scanner.start(); | ||
| syncState(true); | ||
| }; |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 875c2a4. Configure here.
There was a problem hiding this comment.
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
| hoveredActionId() === SCAN_ACTION_ID ? ( | ||
| <IconStop size={14} class="text-[var(--rg-error-text)]" /> | ||
| ) : ( | ||
| <IconLoader size={14} class={actionIconClass(true)} /> | ||
| ) |
There was a problem hiding this comment.
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>
| 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)]" /> |
- 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; |
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.
|
|
||
| const beginCommit = (timestamp: number): void => { | ||
| currentCommitTimestamp = timestamp; | ||
| commitCount += 1; |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 6eb670f. Configure here.
| }) | ||
| .join(", "); | ||
|
|
||
| return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label; |
| className={`${inter.variable} ${geistMono.variable} antialiased`} | ||
| > | ||
| <head> | ||
| <Script src="/script.js" strategy="beforeInteractive" /> |
- 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().
| ensureCanvas(); | ||
| window.addEventListener("resize", resizeCanvas); | ||
| scheduleFrame(); | ||
| }; |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 78aa07a. Configure here.
| }) | ||
| .join(", "); | ||
|
|
||
| return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label; |
| className={`${inter.variable} ${geistMono.variable} antialiased`} | ||
| > | ||
| <head> | ||
| <Script src="/script.js" strategy="beforeInteractive" /> |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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>
…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.
|
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 |
|
|
||
| for (const [element, outline] of outlines) { | ||
| const elapsedSinceRenderMs = currentTimestamp - outline.lastRenderTimestamp; | ||
| if (elapsedSinceRenderMs >= SCAN_OUTLINE_DURATION_MS || !element.isConnected) { |
| }) | ||
| .join(", "); | ||
|
|
||
| return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label; |
| const begin = (): void => { | ||
| commits.length = 0; | ||
| longAnimationFrames.length = 0; | ||
| collected.length = 0; |
| className={`${inter.variable} ${geistMono.variable} antialiased`} | ||
| > | ||
| <head> | ||
| <Script src="/script.js" strategy="beforeInteractive" /> |
| }; | ||
|
|
||
| const ensureCanvas = (): void => { | ||
| if (canvas) return; |
| className={`${inter.variable} ${geistMono.variable} antialiased`} | ||
| > | ||
| <head> | ||
| <Script src="/script.js" strategy="beforeInteractive" /> |
| }) | ||
| .join(", "); | ||
|
|
||
| return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label; |
…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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 6 total unresolved issues (including 4 from previous reviews).
❌ 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(); |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit a5cb520. Configure here.
| }; | ||
|
|
||
| const endCommit = (): void => { | ||
| if (collected.length === 0) return; |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit a5cb520. Configure here.
| className={`${inter.variable} ${geistMono.variable} antialiased`} | ||
| > | ||
| <head> | ||
| <Script src="/script.js" strategy="beforeInteractive" /> |
There was a problem hiding this comment.
| }; | ||
|
|
||
| const ensureCanvas = (): void => { | ||
| if (canvas) return; |
| }; | ||
|
|
||
| const begin = (): void => { | ||
| commits.length = 0; |
| const markInstrumentationActive = (): void => { | ||
| if (hasActiveInstrumentation) return; | ||
| hasActiveInstrumentation = true; | ||
| for (const listener of instrumentationListeners) listener(); |
| }) | ||
| .join(", "); | ||
|
|
||
| return label.length > SCAN_LABEL_MAX_CHARS ? `${label.slice(0, SCAN_LABEL_MAX_CHARS)}…` : label; |
There was a problem hiding this comment.
| 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


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.
actualDuration(renderCount,avg/max/totaldurations) pluslong-animation-frames(captured via along-animation-framePerformanceObserver, withblockingDurationand thescripts[]that blocked the frame). Models the react-scan/lite + LoAF correlation pattern so an agent can attribute jank to a component and a script.isScanningis saved tosessionStorage(SSR-guarded), and restored on load (deferred toDOMContentLoaded) so an active scan survives dev-server HMR/reloads.CopiedPillshown 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: reactiveisScanning/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.drawRoundedRectangle/parseBorderRadiusValueout ofoverlay-canvas.tsxso the scanner reuses them.Notes
actualDurationis only populated in React__PROFILE__builds (default-on in dev); plain prod builds report0, same caveat as react-scan/lite.Test plan
window/sessionStorageaccess errors.pnpm build && pnpm typecheck && pnpm lint && pnpm testNote
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-grabtoolbar: 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/scannerstack 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.jsin 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-grabtoolbar that outlines re-rendering components and copies a compact, per-commit performance trace to the clipboard when you stop. Internals are consolidated intocore/scanner.ts(recorder + canvas + controller), andapps/website-v2now loads/script.jswith Next<Script beforeInteractive>.New Features
file:line:col; pluslong-animation-frameentries with blocking duration, first UI event offset, and per-script details (url, function, char position, duration, reflow). Capped for size; empty scans copy nothing.Bug Fixes
react-grabis disabled/collapsed; plugin teardown routes through the controller so toolbar state stays in sync.bippytimings, improve context-change detection, and flush pending LoAF records on stop.Written for commit a5cb520. Summary will update on new commits.