Skip to content

bench(react-grab): event-listener pattern comparison (function-per-event vs handleEvent)#345

Draft
aidenybai wants to merge 3 commits into
mainfrom
cursor/event-handler-pattern-34e4
Draft

bench(react-grab): event-listener pattern comparison (function-per-event vs handleEvent)#345
aidenybai wants to merge 3 commits into
mainfrom
cursor/event-handler-pattern-34e4

Conversation

@aidenybai
Copy link
Copy Markdown
Owner

@aidenybai aidenybai commented May 16, 2026

Adds a headless-Chromium synthetic benchmark comparing the two ways of registering DOM event listeners:

  1. function-per-event — one handler function per event type (the style currently used everywhere in the codebase).
  2. EventListenerObject — a single object with a handleEvent method that dispatches on event.type, reused across many addEventListener calls.

Why this PR exists

I originally adopted the EventListenerObject pattern across the codebase for ergonomic reasons. The bench shows it's actually a measurable perf regression on the dispatch hot path, so the call-site changes have been reverted. The bench itself is kept as durable infra so future "should we try this pattern" questions can be answered with numbers instead of vibes.

Run it

pnpm --filter react-grab bench:event-listener

Files:

  • packages/react-grab/bench/event-listener-pattern.html — self-contained bench page
  • packages/react-grab/bench/run.mjs — Playwright/Chromium runner that prints results to stdout
  • packages/react-grab/package.json — adds the bench:event-listener script

Headline numbers

Headless Chromium 147 (Linux x86_64), median of 7 trials, positive delta = handleEvent slower.

Dispatch (200,000 events)

scenario function-per-event EventListenerObject delta
drag-like (3 types, 2 handlers) 70.30 ms 94.80 ms +34.9%
viewport-like (3 types, 1 handler) 70.80 ms 88.40 ms +24.9%
dismiss-like (3 types, 2 handlers) 64.60 ms 88.90 ms +37.6%
freeze-mouse (8 types, 1 handler) 72.70 ms 94.80 ms +30.4%

Add + remove (50,000 cycles)

scenario function-per-event EventListenerObject delta
drag-like 132.60 ms 160.50 ms +21.0%
viewport-like 58.50 ms 56.00 ms -4.3%
dismiss-like 178.50 ms 200.00 ms +12.0%
freeze-mouse 196.60 ms 222.10 ms +13.0%

Practical impact at our event rates

Per-event overhead of handleEvent is roughly (94.80 − 70.30) ms / 200,000 ≈ 120 ns/event. At a sustained 120 Hz pointermove / scroll that's ~14 µs/sec, well under 0.001% of a 16.67 ms frame — invisible in real UI. But there's no real-world scenario where it's faster, so the call-site adoption isn't worth keeping.

pnpm typecheck and pnpm lint pass.

Open in Web Open in Cursor 

Summary by cubic

Adopted the DOM EventListenerObject (handleEvent) pattern in react-grab via a new createEventListener utility and added a headless benchmark to compare it with per-event functions. Uses one listener identity for multiple event types to improve add/remove symmetry and type safety with no behavior changes.

  • Refactors

    • Added utils/create-event-listener.ts to wrap a typed handler map into an EventListenerObject.
    • Applied to multi-event bindings:
      • register-overlay-dismiss (keydown, mousedown, touchstart; consistent capture).
      • create-toolbar-drag (pointermove, pointerup, pointercancel with AbortController).
      • create-anchored-dropdown (window/visualViewport resize, scroll).
      • components/selection-label (window/visualViewport resize, scroll; keydown when expandable).
      • components/toolbar (window/visualViewport resize, scroll).
  • New Features

    • Added a headless Chromium benchmark and script bench:event-listener to compare function-per-event vs handleEvent across common scenarios. Run with: pnpm --filter react-grab bench:event-listener.

Written for commit 4a9d0a3. Summary will update on new commits. Review in cubic

Adds a tiny utility that wraps a type-safe per-event-type handler map in a
DOM EventListenerObject. This lets a single listener identity cover multiple
event types via the native handleEvent dispatch, simplifying add/remove
symmetry across viewport, drag, and dismiss listeners.

Applies the pattern to:
- register-overlay-dismiss (keydown / mousedown / touchstart)
- create-toolbar-drag (pointermove / pointerup / pointercancel)
- create-anchored-dropdown (viewport resize / scroll)
- selection-label (window + visualViewport viewport listeners + keydown)
- toolbar (window + visualViewport viewport listeners)

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 16, 2026

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

Project Deployment Actions Updated (UTC)
react-grab-storybook Ready Ready Preview, Comment May 16, 2026 1:46am
react-grab-website Ready Ready Preview, Comment May 16, 2026 1:46am

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

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

commit: 349210d

Headless-Chromium benchmark comparing function-per-event registration vs a
single EventListenerObject with handleEvent across four representative
scenarios (drag, viewport, dismiss, freeze-mouse).

Run with: pnpm --filter react-grab bench:event-listener

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@cursor cursor Bot changed the title feat(react-grab): adopt EventListenerObject (handleEvent) pattern bench(react-grab): event-listener pattern comparison (function-per-event vs handleEvent) May 16, 2026
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.

2 participants