Skip to content

feat: add plugin toolbar entries API#269

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

feat: add plugin toolbar entries API#269
aidenybai wants to merge 11 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
Adds a new plugin-facing toolbar entry API and new dropdown rendering path in the overlay UI, which could impact toolbar interactions and popup dismissal behavior. Risk is mitigated by localized changes and updated tests/docs, but it introduces new state and DOM rendering hooks.

Overview
Adds a plugin toolbar entries API to react-grab, letting plugins register persistent toolbar buttons with optional dropdown panels via toolbarEntries, plus a ToolbarEntryHandle to control open/close state and dynamically update icon/tooltip/badge/visibility.

Updates the renderer/core to manage a mutually-exclusive toolbar-entry dropdown (new ToolbarEntryContainer), wire entry clicks through the toolbar UI, track active entry state, and clean up per-entry overrides when plugins unregister; also exposes toggleToolbarEntry/closeToolbarEntry on the public API.

Includes a gym demo and performance test page (/toolbar-entries) that registers FPS/Render monitor entries, and refreshes plugin documentation in root/packaged READMEs; remaining changes are mostly formatting/cleanup in CLI/tests/docs.

Written by Cursor Bugbot for commit 191dedc. 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 dropdown UIs, live icon/badge updates, and programmatic control. Ships a Render Monitor and FPS Monitor in the gym with a new /toolbar-entries page, and updates the Plugins docs.

  • New Features

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

    • Popups are mutually exclusive: opening comments/toolbar menu/clear prompt dismisses any open toolbar entry.
    • setBadge(undefined) clears badges. Restores toolbarEntryOverrides reactivity and cleans overrides on plugin unregister using Solid reconcile.
    • Render Monitor accuracy and cleanup: count only fibers that actually re-rendered, check fiber flags, forward all devtools args, and unhook on unregister.

Written for commit 191dedc. 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 28, 2026 7:06am

@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: 191dedc

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.
Move toolbar entries to top of plugins section, consolidate
actions and hooks examples, move manual installation below
plugins. Remove all em-dashes from repo.
- 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.
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.

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

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