Skip to content

feat: add plugin toolbar entries API#269

Open
aidenybai wants to merge 19 commits intomainfrom
feat/toolbar-entries
Open

feat: add plugin toolbar entries API#269
aidenybai wants to merge 19 commits intomainfrom
feat/toolbar-entries

Conversation

@aidenybai
Copy link
Copy Markdown
Owner

@aidenybai aidenybai commented Mar 27, 2026

Summary

  • Add toolbarEntries to the plugin system, allowing plugins to register persistent buttons in the toolbar strip with emoji, SVG, or HTML icons
  • Support two entry modes: action-only (onClick without dropdown) and dropdown-based (onRender with a framework-agnostic container)
  • Provide a ToolbarEntryHandle per entry for dropdown control (open/close/toggle), display updates (setIcon/setBadge/setTooltip/setVisible), and full ReactGrabAPI access
  • Cache handles per entry ID for stable identity; clean up stale handles on plugin register/unregister
  • Add gym demo with 3 entries (debug panel, screenshot action, status indicator) registered globally via root layout

AGENTS.md compliance fixes applied

  • Replaced createEffect event-bus pattern with direct cleanup in registerPlugin/unregisterPlugin call sites
  • Replaced manual mountCleanup variable tracking with idiomatic onCleanup() inside the effect
  • Replaced IIFE in JSX with a derived function (activeToolbarEntryHandle)
  • Renamed all entry loop/callback variables to toolbarEntry for descriptive naming
  • Fixed handle cache leak: now cleans up all stale handles on plugin unregister, not just the active one

Test plan

  • pnpm build — no type errors
  • pnpm typecheck — passes
  • pnpm lint — 0 warnings, 0 errors
  • Gym /dashboard shows 3 toolbar entry buttons
  • Gym /toolbar-entries shows documentation page
  • Click 🐛 → debug panel dropdown opens with badge controls
  • Click SVG circle → logs screenshot to console (no dropdown)
  • Click gray dot → toggles icon color between green/red
  • Escape / click-outside dismisses dropdown
  • handle.setBadge(n) shows badge on button
  • Unregistering plugin removes its entries and auto-closes dropdown

Note

Medium Risk
Introduces a new plugin-driven toolbar surface (buttons + dropdown rendering) that touches core overlay/toolbar rendering and dismissal logic, which could affect UX interactions. Test updates reduce flakiness but also indicate timing-sensitive behavior around overlays/freeze mode.

Overview
Adds a plugin toolbar entries API so plugins can register persistent toolbar buttons with optional badges/visibility overrides and either an onClick action or an onRender dropdown panel (rendered into a raw DOM container via the new ToolbarEntryContainer).

Updates the core renderer/toolbar to display these entries, track the active entry/dropdown state, and suppress tooltips while a plugin dropdown is open; also exports supporting types and adds plugin priority constants.

Extends the Gym with a ToolbarEntriesProvider (Render Monitor + FPS Monitor) and a new /toolbar-entries performance test page, and refreshes READMEs to document plugin toolbar entries.

Includes minor CLI/test formatting cleanups and several e2e de-flakes (longer waits/polling) around element removal, focus trap prompt submission, and freeze/update behavior.

Written by Cursor Bugbot for commit 3ab872f. This will update automatically on new commits. Configure here.


Summary by cubic

Adds a plugin-driven toolbar entries API to react-grab so plugins can add persistent toolbar buttons with optional dropdowns and live updates. Refactors the core into 7 focused internal plugins and adds Render Monitor + FPS Monitor demos in the Gym.

  • New Features

    • toolbarEntries for plugin buttons (emoji/SVG/HTML, badges, visibility) with action (onClick) or dropdown (onRender) and per-entry ToolbarEntryHandle.
    • Public API: toggleToolbarEntry(entryId), closeToolbarEntry(). Export ToolbarEntry and ToolbarEntryHandle.
    • Gym: Render Monitor and FPS Monitor via a global ToolbarEntriesProvider; new /toolbar-entries page.
  • Bug Fixes

    • Popups are mutually exclusive; toggling an entry or unregistering a plugin closes any open dropdown. Restored toolbarEntryOverrides and cleanup via Solid reconcile; setBadge(undefined) clears badges.
    • Render Monitor: count only actual renders (flags-aware), forward all devtools args, and dispose rAF/unhook on unregister.
    • Stability: set keyboard toggle activation flag; add missing pointer move/down/up dispatchers; restore contextmenu listener; hoist signals to fix toolbar hover and drag preview tracking; hide selection label on toolbar hover; anchor dropdowns for comments/clear prompt; expose getDragBounds/isDragBoxVisible via getState().
    • Context menu and comments pin logic: continue processing after clearing arrow nav; use targeted popup dismissals. Increased e2e timeouts and switched to polling to reduce flakiness.

Written for commit 3ab872f. Summary will update on new commits.

@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Mar 27, 2026

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

Project Deployment Actions Updated (UTC)
react-grab-website Ready Ready Preview, Comment Mar 29, 2026 10:42am

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 27, 2026

Open in StackBlitz

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

commit: 3ab872f

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

5 issues found across 13 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/core/index.tsx">

<violation number="1" location="packages/react-grab/src/core/index.tsx:3898">
P2: `dismissAllPopups` correctly includes `dismissToolbarEntry()`, and `handleToggleToolbarEntry` dismisses other popups before opening — but the reverse is not true. The existing popup-opening functions (`handleToggleToolbarMenu`, `openCommentsDropdown`, `showClearPrompt`) were not updated to call `dismissToolbarEntry()`. Opening any of those while a toolbar entry dropdown is visible will leave both popups visible simultaneously.</violation>

<violation number="2" location="packages/react-grab/src/core/index.tsx:3903">
P2: Dismiss the active toolbar entry before opening a new one to ensure its `onClose` fires. Also consider adding `dismissToolbarEntry()` to the other existing popup handlers to maintain mutual exclusion.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/components/toolbar/toolbar-content.tsx:296">
P2: The nullish coalescing operator (`??`) prevents clearing badges. A plugin calling `handle.setBadge(undefined)` cannot hide an initial badge because `undefined ?? fallback` returns the fallback.</violation>
</file>

<file name="packages/gym/components/toolbar-entries-provider.tsx">

<violation number="1" location="packages/gym/components/toolbar-entries-provider.tsx:99">
P1: The click handler does not actually toggle the status, contradicting the test plan.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugin-registry.ts:199">
P2: SolidJS `setStore` deep-merges objects and does not delete missing keys. Set the removed keys to `undefined` to actually delete them from the store.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

1 issue found across 18 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/core/index.tsx">

<violation number="1" location="packages/react-grab/src/core/index.tsx:3858">
P2: Stale `toolbarEntryOverrides` are never cleaned up on plugin unregister. The old cleanup block in `plugin-registry.ts` was removed, and the new `unregisterPlugin` code here only dismisses the active dropdown. If a plugin is unregistered and later re-registered with the same entry IDs, overrides from the previous lifecycle (set via `handle.setIcon`, `handle.setBadge`, etc.) silently persist and override the fresh entry's properties. Add cleanup of `toolbarEntryOverrides` for the removed plugin's entry IDs, either here or back in the registry's `unregister` method.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

2 issues found across 14 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/gym/components/toolbar-entries-provider.tsx">

<violation number="1" location="packages/gym/components/toolbar-entries-provider.tsx:46">
P2: The `traverseFiber` function increments the render count for every component in the fiber tree unconditionally on every commit. This effectively counts 'components × commits' rather than identifying which components actually rendered. To accurately measure renders, verify if the fiber updated during the commit (e.g., by checking `fiber.memoizedProps !== fiber.alternate?.memoizedProps` or using `fiber.flags`).</violation>

<violation number="2" location="packages/gym/components/toolbar-entries-provider.tsx:86">
P1: When intercepting `onCommitFiberRoot`, the wrapper function explicitly forwards only `rendererID` and `fiberRoot`. If the original `onCommitFiberRoot` expects additional arguments (like `commitPriority` or `didError` in newer React versions), they will be dropped, which can break React DevTools. Use rest parameters to forward all arguments.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown
Contributor

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

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/gym/components/toolbar-entries-provider.tsx">

<violation number="1" location="packages/gym/components/toolbar-entries-provider.tsx:65">
P2: This props/state equality check misses real React renders, so the render monitor undercounts parent- and context-driven updates.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

pluginRegistry.updateToolbarEntry(entryId, { badge }),
setVisible: (isVisible) =>
pluginRegistry.updateToolbarEntry(entryId, { isVisible }),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

New handle created each call defeats documented caching

Low Severity

getToolbarEntryHandle creates a brand-new object on every invocation, contradicting the PR description's claim of "cache handles per entry ID for stable identity." While currently functional (methods operate by entryId), the lack of a Map-based cache means every call to activeToolbarEntryHandle() or handleToggleToolbarEntry allocates a fresh object. If any consumer later relies on referential identity (e.g., in a reactive comparison or as a Map key), it will silently break.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

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

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/gym/components/toolbar-entries-provider.tsx">

<violation number="1" location="packages/gym/components/toolbar-entries-provider.tsx:57">
P2: `fiber.flags > 0` is not a reliable re-render signal; static fiber flags will make the render monitor overcount memoized components.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

fiber.memoizedProps !== fiber.alternate.memoizedProps ||
fiber.memoizedState !== fiber.alternate.memoizedState
);
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Render monitor flags check overcounts fiber renders

Medium Severity

The didFiberRender function's fiber.flags && fiber.flags > 0 check is far too broad. React fiber flags is a bitfield where many bits (like Ref, Placement, ChildDeletion, ContentReset) can be set on fibers that didn't actually re-render their component function. This causes the render monitor to significantly overcount renders, directly contradicting the PR's stated fix of "count only fibers that actually re-rendered." A more targeted check like fiber.flags & 1 (the PerformedWork bit) would match only fibers whose component function was actually called.

Fix in Cursor Fix in Web

handle.setBadge(totalRenderCount);
}
};
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Monitoring state inconsistent when devtools hook missing

Low Severity

startMonitoring sets isMonitoring = true on line 87 before checking if __REACT_DEVTOOLS_GLOBAL_HOOK__ exists on line 96. When the hook is absent, the function returns early with isMonitoring stuck as true but no actual monitoring installed. The toolbar button icon turns red (active appearance) even though nothing is being tracked, and the badge never updates — misleading the user into thinking monitoring is working.

Fix in Cursor Fix in Web

Allow plugins to register persistent buttons in the toolbar strip via
`toolbarEntries`. Entries support emoji/SVG/HTML icons, optional dropdown
containers (onRender), action-only buttons (onClick), badges, visibility
control, and lifecycle hooks (onOpen/onClose). Handles are cached per
entry and provide full ReactGrabAPI access plus dropdown and display
control. Includes gym demo with debug panel, screenshot action, and
status indicator entries registered globally.
Remove redundant onOpen/onClose lifecycle hooks, eliminate
toolbarEntryOverrides prop threading through 3 component layers,
and drop the handle cache + stale cleanup bookkeeping.
Pre-merging overrides via object spread broke <For> referential
identity and caused the container effect to re-fire onRender on
every badge/icon change, resetting closure state.
Render monitor hooks into __REACT_DEVTOOLS_GLOBAL_HOOK__ to count
component renders with per-component breakdown. FPS meter tracks
frame timing via rAF with sparkline graph and drop detection.
Test page has intentionally laggy components to exercise both.
- Add dismissToolbarEntry() to all popup openers for mutual exclusion
- Fix badge clearing: use "in" check instead of ?? so setBadge(undefined)
  properly clears badges instead of falling through to the original value
- Restore toolbarEntryOverrides cleanup on plugin unregister to prevent
  stale overrides persisting across re-registrations
SolidJS setStore deep-merges objects, so removed keys persist. Using
reconcile ensures stale overrides are actually deleted when a plugin
is unregistered. Also includes previously uncommitted README updates
and script.js changes.
- Use rest params for onCommitFiberRoot to forward all arguments
- Only count fibers that actually rendered (check alternate props/state)
- Remove dead patchedRoots WeakSet code
- Add dispose() to stop rAF loop and unhook devtools on plugin unregister
The props/state reference equality check alone misses renders triggered
by context changes or parent re-renders where React sets flags on the
fiber without changing memoizedProps/memoizedState references.
setActiveToolbarEntryId(entryId);
openTrackedDropdown(setToolbarEntryDropdownPosition);
}
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Calling handle.open() from onClick causes infinite recursion

Medium Severity

If a plugin's onClick callback calls handle.open() or handle.toggle(), it re-enters handleToggleToolbarEntry before setActiveToolbarEntryId has been called. Because the active ID hasn't been set yet, the guard in handle.open() (activeToolbarEntryId() !== entryId) passes again, triggering another handleToggleToolbarEntryonClickhandle.open() cycle, resulting in a stack overflow. The same applies to action-only entries (no onRender), where onClick is called unconditionally.

Fix in Cursor Fix in Web

Split the monolithic core/index.tsx (~5000 lines) into a plugin
architecture with 7 focused plugins communicating via SharedPluginApi
and interceptor chain:

- keyboard-plugin: hold activation, keydown/keyup handling, Escape
- pointer-plugin: mouse/touch tracking, element detection, drag, copy
- navigation-plugin: arrow key navigation, action cycling
- toolbar-plugin: toolbar state, activation/deactivation, freeze effects
- menus-plugin: context menu, comments dropdown
- prompt-plugin: prompt mode, agent integration
- copy-pipeline: copy execution, clipboard, feedback labels

Also fixes:
- Set wasActivatedByToggle during keyboard hold activation so toggle-mode
  semantics (Escape to dismiss, modifier release ignored) work correctly
- Add missing pointermove/pointerdown/pointerup event dispatchers to the
  interceptor chain (selection box rendering depended on these)
- Fix flaky freeze-updates test by replacing fixed timeout with
  waitForFunction for element count assertion

// HACK: defer to next frame so idle preview label clears visually before "copied" appears
nativeRequestAnimationFrame(() => {
if (!isElementConnected(element)) return;
Copy link
Copy Markdown
Contributor

@vercel vercel bot Mar 28, 2026

Choose a reason for hiding this comment

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

Calling handle.open() or handle.toggle() from within an onClick callback causes infinite recursion because activeToolbarEntryId is set after onClick executes

Fix on Vercel

const didFiberRender = (fiber: FiberNode): boolean => {
if (!fiber.alternate) return true;
// Check flags for context-driven and parent-driven updates
if (fiber.flags && fiber.flags > 0) return 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.

Overly broad fiber flags check inflates render counts

Medium Severity

The didFiberRender check fiber.flags && fiber.flags > 0 treats any fiber with any non-zero flag as having re-rendered. React fiber flags include Passive (2048, set on any component using useEffect), Ref (512), Snapshot (1024), and many others that don't indicate an actual re-render. Even React DevTools' own didFiberRender was recently fixed (facebook/react#33434) because checking just the PerformedWork flag (value 1) was insufficient — and flags > 0 is far broader than that. This causes the Render Monitor to massively over-count renders, making the tool unreliable.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

16 issues found across 13 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/core/plugins/toolbar-plugin.ts">

<violation number="1" location="packages/react-grab/src/core/plugins/toolbar-plugin.ts:178">
P1: Guard `open()`/`toggle()` so action-only entries do not re-enter their own `onClick` handler.</violation>

<violation number="2" location="packages/react-grab/src/core/plugins/toolbar-plugin.ts:186">
P2: Ignore `setIcon`/`setTooltip`/`setBadge`/`setVisible` calls after an entry has been unregistered, or stale handles can resurrect overrides for reused entry IDs.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugins/menus-plugin.ts:531">
P1: Preserve the full frozen selection when entering prompt mode; this collapses multi-select actions down to one element.</violation>
</file>

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

<violation number="1">
P2: Mirror the active toolbar-entry cleanup in `registerPlugin`. Re-registering a plugin with the same name can remove the currently open entry and leave a stale/empty dropdown mounted.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugins/navigation-plugin.ts:131">
P1: Resolve selection source from the effective selection, not `targetElement`, or keyboard navigation can leave `selectionFilePath` pointing at the previously hovered element.</violation>

<violation number="2" location="packages/react-grab/src/core/plugins/navigation-plugin.ts:353">
P2: Ignore bare Shift while prompt mode is open; otherwise typing with Shift in the prompt toggles inspect mode and can unfreeze the current selection.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugins/prompt-plugin.ts:602">
P1: This reads the current comments dropdown position instead of computing a new anchor, so toggling Comments never opens the dropdown.</violation>

<violation number="2" location="packages/react-grab/src/core/plugins/prompt-plugin.ts:617">
P2: This rereads `clearPromptPosition()` after dismissing popups, so the clear-comments confirmation never becomes visible.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugins/copy-pipeline.ts:385">
P2: Only show grabbed-box success feedback after the copy has actually succeeded.</violation>

<violation number="2" location="packages/react-grab/src/core/plugins/copy-pipeline.ts:389">
P1: Await async `onElementSelect` interceptors even when other elements still fall back to the normal copy path.</violation>

<violation number="3" location="packages/react-grab/src/core/plugins/copy-pipeline.ts:519">
P2: Don't gate copy feedback labels on `theme.grabbedBoxes.enabled`.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugins/keyboard-plugin.ts:234">
P2: Pending hold cancellation only works for Ctrl/Meta shortcuts. Custom activation keys like `g` or `Space` keep their timer alive after another key is pressed, so the overlay can still activate unexpectedly.</violation>

<violation number="2" location="packages/react-grab/src/core/plugins/keyboard-plugin.ts:434">
P1: Releasing Shift/Alt is ignored for multi-modifier shortcuts, so combos like `Ctrl+Shift+g` stay active until Ctrl/Meta is released.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugin-registry.ts:392">
P1: Wire `contextmenu` into the interceptor dispatcher; handlers registered through this new chain never run.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugins/pointer-plugin.ts:93">
P2: Clearing the debounced drag pointer on every move makes drag previews vanish during continuous drags.</violation>

<violation number="2" location="packages/react-grab/src/core/plugins/pointer-plugin.ts:105">
P3: These pending-selection locals are never assigned, so the default-action/context-menu path is unreachable dead code.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment on lines +178 to +185
open: () => {
if (activeToolbarEntryId() !== entryId)
handleToggleToolbarEntry(entryId);
},
close: () => {
if (activeToolbarEntryId() === entryId) dismissToolbarEntry();
},
toggle: () => handleToggleToolbarEntry(entryId),
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P1: Guard open()/toggle() so action-only entries do not re-enter their own onClick 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/plugins/toolbar-plugin.ts, line 178:

<comment>Guard `open()`/`toggle()` so action-only entries do not re-enter their own `onClick` handler.</comment>

<file context>
@@ -0,0 +1,415 @@
+    const getToolbarEntryHandle = (entryId: string): ToolbarEntryHandle => ({
+      api,
+      isOpen: () => activeToolbarEntryId() === entryId,
+      open: () => {
+        if (activeToolbarEntryId() !== entryId)
+          handleToggleToolbarEntry(entryId);
</file context>
Suggested change
open: () => {
if (activeToolbarEntryId() !== entryId)
handleToggleToolbarEntry(entryId);
},
close: () => {
if (activeToolbarEntryId() === entryId) dismissToolbarEntry();
},
toggle: () => handleToggleToolbarEntry(entryId),
open: () => {
const toolbarEntry = registry.store.toolbarEntries.find(
(toolbarEntry) => toolbarEntry.id === entryId,
);
if (toolbarEntry?.onRender && activeToolbarEntryId() !== entryId) {
handleToggleToolbarEntry(entryId);
}
},
close: () => {
if (activeToolbarEntryId() === entryId) dismissToolbarEntry();
},
toggle: () => {
const toolbarEntry = registry.store.toolbarEntries.find(
(toolbarEntry) => toolbarEntry.id === entryId,
);
if (toolbarEntry?.onRender) {
handleToggleToolbarEntry(entryId);
}
},
Fix with Cubic

onBeforePrompt?.();
shared.preparePromptMode?.(position, element);
actions.setPointer({ x: position.x, y: position.y });
actions.setFrozenElement(element);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P1: Preserve the full frozen selection when entering prompt mode; this collapses multi-select actions down to one element.

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/menus-plugin.ts, line 531:

<comment>Preserve the full frozen selection when entering prompt mode; this collapses multi-select actions down to one element.</comment>

<file context>
@@ -0,0 +1,660 @@
+        onBeforePrompt?.();
+        shared.preparePromptMode?.(position, element);
+        actions.setPointer({ x: position.x, y: position.y });
+        actions.setFrozenElement(element);
+        shared.activatePromptMode?.();
+        if (!isActivated()) {
</file context>
Fix with Cubic


createEffect(
on(
() => targetElement(),
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P1: Resolve selection source from the effective selection, not targetElement, or keyboard navigation can leave selectionFilePath pointing at the previously hovered element.

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/navigation-plugin.ts, line 131:

<comment>Resolve selection source from the effective selection, not `targetElement`, or keyboard navigation can leave `selectionFilePath` pointing at the previously hovered element.</comment>

<file context>
@@ -0,0 +1,386 @@
+
+    createEffect(
+      on(
+        () => targetElement(),
+        (element) => {
+          const currentVersion = ++selectionSourceRequestVersion;
</file context>
Fix with Cubic

}
}
await waitUntilNextFrame();
if (unhandledElements.length > 0) {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P1: Await async onElementSelect interceptors even when other elements still fall back to the normal copy path.

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/copy-pipeline.ts, line 389:

<comment>Await async `onElementSelect` interceptors even when other elements still fall back to the normal copy path.</comment>

<file context>
@@ -0,0 +1,774 @@
+        }
+      }
+      await waitUntilNextFrame();
+      if (unhandledElements.length > 0) {
+        await copyWithFallback(
+          unhandledElements,
</file context>
Fix with Cubic

pendingResults.push(pendingResult);
}
if (registry.store.theme.grabbedBoxes.enabled) {
showTemporaryGrabbedBox(createElementBounds(element), element);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P2: Only show grabbed-box success feedback after the copy has actually succeeded.

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/copy-pipeline.ts, line 385:

<comment>Only show grabbed-box success feedback after the copy has actually succeeded.</comment>

<file context>
@@ -0,0 +1,774 @@
+          pendingResults.push(pendingResult);
+        }
+        if (registry.store.theme.grabbedBoxes.enabled) {
+          showTemporaryGrabbedBox(createElementBounds(element), element);
+        }
+      }
</file context>
Fix with Cubic


const computedLabelInstances = createMemo(() => {
if (!registry.store.theme.enabled) return [];
if (!registry.store.theme.grabbedBoxes.enabled) return [];
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P2: Don't gate copy feedback labels on theme.grabbedBoxes.enabled.

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/copy-pipeline.ts, line 519:

<comment>Don't gate copy feedback labels on `theme.grabbedBoxes.enabled`.</comment>

<file context>
@@ -0,0 +1,774 @@
+
+    const computedLabelInstances = createMemo(() => {
+      if (!registry.store.theme.enabled) return [];
+      if (!registry.store.theme.grabbedBoxes.enabled) return [];
+      void store.viewportVersion;
+      const currentIds = new Set(
</file context>
Fix with Cubic


if (!isTargetKeyCombination(event, registry.store.options)) {
if (
(event.metaKey || event.ctrlKey) &&
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P2: Pending hold cancellation only works for Ctrl/Meta shortcuts. Custom activation keys like g or Space keep their timer alive after another key is pressed, so the overlay can still activate unexpectedly.

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/keyboard-plugin.ts, line 234:

<comment>Pending hold cancellation only works for Ctrl/Meta shortcuts. Custom activation keys like `g` or `Space` keep their timer alive after another key is pressed, so the overlay can still activate unexpectedly.</comment>

<file context>
@@ -0,0 +1,593 @@
+
+      if (!isTargetKeyCombination(event, registry.store.options)) {
+        if (
+          (event.metaKey || event.ctrlKey) &&
+          !MODIFIER_KEYS.includes(event.key) &&
+          !isEnterCode(event.code)
</file context>
Fix with Cubic

if (dragPreviewDebounceTimerId !== null) {
clearTimeout(dragPreviewDebounceTimerId);
}
setDebouncedDragPointer(null);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P2: Clearing the debounced drag pointer on every move makes drag previews vanish during continuous drags.

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/pointer-plugin.ts, line 93:

<comment>Clearing the debounced drag pointer on every move makes drag previews vanish during continuous drags.</comment>

<file context>
@@ -0,0 +1,696 @@
+      if (dragPreviewDebounceTimerId !== null) {
+        clearTimeout(dragPreviewDebounceTimerId);
+      }
+      setDebouncedDragPointer(null);
+      dragPreviewDebounceTimerId = window.setTimeout(() => {
+        setDebouncedDragPointer({ x: clientX, y: clientY });
</file context>
Fix with Cubic

() => isDragging(),
);

let isPendingContextMenuSelect = false;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P3: These pending-selection locals are never assigned, so the default-action/context-menu path is unreachable dead code.

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/pointer-plugin.ts, line 105:

<comment>These pending-selection locals are never assigned, so the default-action/context-menu path is unreachable dead code.</comment>

<file context>
@@ -0,0 +1,696 @@
+      () => isDragging(),
+    );
+
+    let isPendingContextMenuSelect = false;
+    let pendingDefaultActionId: string | null = null;
+
</file context>
Fix with Cubic

- Extract duplicate label fade logic from copy-pipeline and menus plugins
  into shared utils/label-fade-manager.ts
- Remove dead _keyboardSelectedElement variable from navigation-plugin
- Hoist duplicate isFromOverlay computation in keyboard-plugin
- Strip excessive non-HACK comments from keyboard-plugin
- Rename dragBoundsVal to firstFrozenBounds in pointer-plugin
- Extract plugin priority magic numbers to constants.ts
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

1 issue found across 9 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/label-fade-manager.ts">

<violation number="1" location="packages/react-grab/src/utils/label-fade-manager.ts:40">
P1: Track the fade-completion timeout too; otherwise `cancel()`/`schedule()` cannot stop a pending removal after the label starts fading.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

const timeoutId = window.setTimeout(() => {
timeouts.delete(instanceId);
actions.updateLabelInstance(instanceId, "fading");
setTimeout(() => {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 28, 2026

Choose a reason for hiding this comment

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

P1: Track the fade-completion timeout too; otherwise cancel()/schedule() cannot stop a pending removal after the label starts fading.

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/label-fade-manager.ts, line 40:

<comment>Track the fade-completion timeout too; otherwise `cancel()`/`schedule()` cannot stop a pending removal after the label starts fading.</comment>

<file context>
@@ -0,0 +1,50 @@
+    const timeoutId = window.setTimeout(() => {
+      timeouts.delete(instanceId);
+      actions.updateLabelInstance(instanceId, "fading");
+      setTimeout(() => {
+        timeouts.delete(instanceId);
+        actions.removeLabelInstance(instanceId);
</file context>
Fix with Cubic

The label-fade-manager extraction accidentally removed this import
that is still used in 3 other places in the file.
- Close active toolbar entry before unregistering plugin (prevents stale state)
- Add getPluginToolbarEntryIds to plugin registry for entry ownership lookup
- Dismiss current entry before opening new one in toolbar toggle (mutual exclusion)
- Make dispose cleanup unconditional in gym toolbar-entries-provider
const didFiberRender = (fiber: FiberNode): boolean => {
if (!fiber.alternate) return true;
// Check flags for context-driven and parent-driven updates
if (fiber.flags && fiber.flags > 0) return 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.

Render monitor overcounts due to overly broad flags check

Medium Severity

didFiberRender uses fiber.flags > 0 to detect re-renders, but this matches nearly every fiber in a committed tree — not just those that actually re-rendered. React fiber flags include Placement (2), Update (4), Passive (2048), Ref (512), etc. The flag that indicates a component actually executed its render function is PerformedWork (value 1), which requires a bitwise check like fiber.flags & 1. With > 0, the render monitor will massively overcount renders, making the devtool's data misleading.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

2 issues found across 9 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/core/index.tsx">

<violation number="1">
P2: This closes the active toolbar entry by plugin-local ID match, which breaks when multiple plugins contribute the same entry ID.</violation>
</file>

<file name="packages/gym/components/toolbar-entries-provider.tsx">

<violation number="1" location="packages/gym/components/toolbar-entries-provider.tsx:148">
P2: Keep the render-monitor cleanup conditional. The new direct `dispose: stopMonitoring` can reset `onCommitFiberRoot` during unmount even when this entry is no longer active, clobbering a newer hook wrapper.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

id: "render-monitor",
icon: ICON_RENDER,
tooltip: "Render Monitor",
dispose: stopMonitoring,
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 29, 2026

Choose a reason for hiding this comment

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

P2: Keep the render-monitor cleanup conditional. The new direct dispose: stopMonitoring can reset onCommitFiberRoot during unmount even when this entry is no longer active, clobbering a newer hook wrapper.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/gym/components/toolbar-entries-provider.tsx, line 148:

<comment>Keep the render-monitor cleanup conditional. The new direct `dispose: stopMonitoring` can reset `onCommitFiberRoot` during unmount even when this entry is no longer active, clobbering a newer hook wrapper.</comment>

<file context>
@@ -131,9 +145,7 @@ const createRenderMonitorEntry = (): ToolbarEntry & { dispose: () => void } => {
-    dispose: () => {
-      if (isMonitoring) stopMonitoring();
-    },
+    dispose: stopMonitoring,
     onClick: (handle) => {
       if (isMonitoring) {
</file context>
Suggested change
dispose: stopMonitoring,
dispose: () => {
if (isMonitoring) stopMonitoring();
},
Fix with Cubic

…ration

- Add missing contextmenu window event listener to interceptor chain
- Fix SolidJS signal tracking for isToolbarSelectHovered and
  hasDragPreviewBounds by hoisting signals to index.tsx (memos defined
  before plugin setup couldn't track signals created during setup)
- Add isSelectionSuppressed check to selectionLabelVisible in
  navigation-plugin (toolbar hover should hide label in non-frozen mode)
- Use deferHideContextMenu for onContextMenuHide renderer prop
- Wire openTrackedDropdown/stopTrackingDropdownPosition via shared API
  for comments dropdown and clear prompt positioning
- Expose getDragBounds and isDragBoxVisible via shared for getState()
… logic

- Fix contextmenu handler to continue processing after clearing arrow
  navigation (matching main's behavior instead of returning early)
- Fix openCommentsDropdown/showClearPrompt to use targeted popup
  dismissals instead of dismissAllPopups, which was resetting
  isCommentsHoverOpen and breaking pin-on-click
- Increase e2e test timeouts and replace hardcoded waits with polling
  to reduce flakiness
}
| undefined;

if (!hook) 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.

Render monitor silently fails with stale active state

Medium Severity

startMonitoring sets isMonitoring = true before checking whether __REACT_DEVTOOLS_GLOBAL_HOOK__ exists. If the hook is absent, the function returns early but isMonitoring remains true. The toolbar button icon turns red (appears active), but no renders are ever counted. Subsequent calls to startMonitoring also bail out early because isMonitoring is already true. The flag needs to be reset to false when the hook isn't found.

Fix in Cursor Fix in Web

fiber.memoizedProps !== fiber.alternate.memoizedProps ||
fiber.memoizedState !== fiber.alternate.memoizedState
);
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fiber flags check overcounts renders significantly

Medium Severity

didFiberRender treats any non-zero fiber.flags as evidence of a render. React fiber flags include many non-render-related bits like Placement, ChildDeletion, Snapshot, Passive, etc. This causes the render monitor to massively overcount by reporting fibers that were merely placed, had children deleted, or had passive effects scheduled — not just those that actually re-rendered.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

3 issues found across 10 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/e2e/freeze-updates.spec.ts">

<violation number="1" location="packages/react-grab/e2e/freeze-updates.spec.ts:34">
P2: Replace the new fixed 500ms sleep with a state-based wait for prompt mode to exit.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugins/pointer-plugin.ts:606">
P2: Returning to the normal context-menu flow after clearing arrow navigation lets overlay right-clicks act on the underlying page element.</violation>
</file>

<file name="packages/react-grab/e2e/edge-cases.spec.ts">

<violation number="1" location="packages/react-grab/e2e/edge-cases.spec.ts:49">
P2: Replace this fixed sleep with a deterministic wait on the removed element or overlay state; otherwise this test will stay flaky in slower CI runs.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


await reactGrab.pressEscape();
await reactGrab.page.waitForTimeout(200);
await reactGrab.page.waitForTimeout(500);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 29, 2026

Choose a reason for hiding this comment

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

P2: Replace the new fixed 500ms sleep with a state-based wait for prompt mode to exit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/e2e/freeze-updates.spec.ts, line 34:

<comment>Replace the new fixed 500ms sleep with a state-based wait for prompt mode to exit.</comment>

<file context>
@@ -25,13 +25,13 @@ test.describe("Freeze Updates", () => {
 
       await reactGrab.pressEscape();
-      await reactGrab.page.waitForTimeout(200);
+      await reactGrab.page.waitForTimeout(500);
 
       const countAfterExit = await getElementCount();
</file context>
Suggested change
await reactGrab.page.waitForTimeout(500);
await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false);
Fix with Cubic

event,
"data-react-grab-ignore-events",
);
if (isFromOverlay && ctx.shared.hasArrowNavigation?.()) {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 29, 2026

Choose a reason for hiding this comment

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

P2: Returning to the normal context-menu flow after clearing arrow navigation lets overlay right-clicks act on the underlying page element.

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/pointer-plugin.ts, line 606:

<comment>Returning to the normal context-menu flow after clearing arrow navigation lets overlay right-clicks act on the underlying page element.</comment>

<file context>
@@ -603,8 +603,9 @@ export const pointerPlugin: InternalPlugin = {
         "data-react-grab-ignore-events",
       );
-      if (isFromOverlay) {
+      if (isFromOverlay && ctx.shared.hasArrowNavigation?.()) {
         ctx.shared.clearArrowNavigation?.();
+      } else if (isFromOverlay) {
</file context>
Fix with Cubic

await reactGrab.waitForSelectionBox();

await reactGrab.removeElement("[data-testid='toggleable-element']");
await reactGrab.page.waitForTimeout(100);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 29, 2026

Choose a reason for hiding this comment

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

P2: Replace this fixed sleep with a deterministic wait on the removed element or overlay state; otherwise this test will stay flaky in slower CI runs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/e2e/edge-cases.spec.ts, line 49:

<comment>Replace this fixed sleep with a deterministic wait on the removed element or overlay state; otherwise this test will stay flaky in slower CI runs.</comment>

<file context>
@@ -46,6 +46,7 @@ test.describe("Edge Cases", () => {
       await reactGrab.waitForSelectionBox();
 
       await reactGrab.removeElement("[data-testid='toggleable-element']");
+      await reactGrab.page.waitForTimeout(100);
 
       await reactGrab.hoverElement("li:first-child");
</file context>
Suggested change
await reactGrab.page.waitForTimeout(100);
await reactGrab.page.waitForFunction(
() => !document.querySelector("[data-testid='toggleable-element']"),
);
Fix with Cubic

- Remove unnecessary comment in didFiberRender
- Prefix blocking comment with HACK: per convention
- Convert page component to arrow function
- Rename `previous` to `previousCount` for clarity
Copy link
Copy Markdown

@cursor cursor bot left a comment

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 1 potential issue.

Fix All in Cursor

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

if (currentFps > 0) {
minFps = Math.min(minFps, currentFps);
maxFps = Math.max(maxFps, currentFps);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FPS monitor min always reports 1

Low Severity

The minFps stat always displays 1 because minFps is updated starting from the very first measurement frame, when only a single timestamp exists in the 1-second sliding window. On the second frame currentFps is 1, so minFps immediately locks to 1 and never recovers, making the "Min" display in the FPS Monitor dropdown permanently meaningless.

Fix in Cursor Fix in Web

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