Skip to content

feat(react-grab): event log + session replay (devtools)#365

Draft
aidenybai wants to merge 3 commits into
mainfrom
cursor/event-log-devtools-f3b7
Draft

feat(react-grab): event log + session replay (devtools)#365
aidenybai wants to merge 3 commits into
mainfrom
cursor/event-log-devtools-f3b7

Conversation

@aidenybai

@aidenybai aidenybai commented May 20, 2026

Copy link
Copy Markdown
Owner

What

Adds an internal event log that records every dispatched store action so a session can be exported as JSON, pasted into a bug report, and replayed against the live store to reproduce the same UI state.

Driven by the brainstorm in the linked chat — scoped intentionally tight. No architectural rewrite, no Foldkit-ification, no plugin restructuring, no Subscriptions/Managed Resources/Submodels. Just the log and the repro mechanism.

How

Action proxy as the log boundary

createGrabStore now accepts an optional eventLog and wraps the returned actions object with a small proxy that calls eventLog.dispatch(name, args) before invoking the underlying action body. Internal action-to-action calls (e.g. toggledeactivate) use the unwrapped local reference, so the log never double-counts.

Element handles instead of live refs

Element args can't be JSON-serialized, so the log mints a stable rg-el-N handle the first time it sees each element. The registry keeps a WeakMap<Element, handle> for forward lookup, a WeakRef<Element> for reverse lookup, and a unique CSS selector (via the existing createElementSelector) as a fallback for replay.

On replaySession, handles are resolved by trying:

  1. The cached WeakRef if still alive and connected.
  2. document.querySelector(selector) against the live DOM.
  3. Otherwise null (actions are tolerant of null element args today).

Online coalescing

Without compaction, setPointer alone would fill the 5000-entry ring buffer in a few seconds. Dispatch checks the previous entry: if the name is in a small "idempotent setter" allowlist (setPointer, setDetectedElement, setInputText, setFrozenDragRect, updateContextMenuPosition, incrementViewportVersion) or the new args structurally equal the previous, the latest call replaces the prior entry and a coalescedCount is bumped. The dev panel renders this as setPointer ×127.

Things that depend on call sequence or count (addGrabbedBox, toggleFrozenElement, increment/decrement lock depth pairs, etc.) are intentionally not in the allowlist — they stay every call.

Public API additions

ReactGrabAPI gains four methods:

  • getSession(): ReactGrabSession — deep-cloned snapshot for export.
  • replaySession(session, options?): Promise<void> — resets the store (deactivate + clears) then dispatches each event in order; options.realtime: true honors original timestamps via setTimeout.
  • clearEventLog(): void
  • setEventLogRecording(value): void — pause/resume without losing the buffer.

The noop-api stub implements the same surface.

Optional devtools panel

Gated by window.__REACT_GRAB_DEVTOOLS__ = true set before init(). Lazy-imported only when the flag is set (mirrors the renderer's existing pattern, since solid-js/web touches window at module load and would break SSR). Mounts its own shadow root (independent of the main overlay) with inline styles and renders a floating event list. Buttons: pause/record, copy JSON, download JSON, replay from clipboard, clear. Each row shows relativeMs · actionName(args…) · ×count.

What this gets you

  • Bug repros as paste-able JSON. Hit a bug → open panel → copy → paste into an issue. Load locally and step through.
  • Tests as arrays of events. applyEvents(store, [...]) after a session is loaded; assert on the resulting Model.
  • Live coalesced timeline for "what just happened" debugging.

What this explicitly does NOT do

  • Lift Date.now() / document.activeElement / window.scroll* reads out of action bodies. Replay reproduces the structural state (selection, drag, freezes, context menu) but not byte-exact timestamps inside the Model. Good enough for ~95% of repros.
  • Subscriptions / Managed Resources / Submodels / Effect-style purity. Out of scope.
  • Ship a UI for end users. The panel is dev-only and gated.

Verified

  • pnpm --filter react-grab typecheck
  • pnpm --filter react-grab build
  • pnpm lint
  • pnpm format
  • pnpm test ✓ — all 511 Playwright e2e tests pass (including SSR compatibility — initial run had 13 SSR failures from the eager devtools-panel import; fixed with the lazy import).

Bundle impact

  • Main IIFE: +0.4 kB gzip vs main (event log + handle registry inlined).
  • Devtools panel: separate 2.25 kB gzip chunk, never loaded unless __REACT_GRAB_DEVTOOLS__ is set.

Files

  • packages/react-grab/src/core/event-log.ts (new) — registry + ring buffer + dispatch + replay.
  • packages/react-grab/src/components/devtools-panel.tsx (new) — gated, lazy-loaded Solid panel.
  • packages/react-grab/src/core/store.ts — accepts eventLog, wraps actions if present.
  • packages/react-grab/src/core/index.tsx — creates the log, exposes API methods, lazy-imports the panel when flagged.
  • packages/react-grab/src/core/noop-api.ts — implements the new methods.
  • packages/react-grab/src/types.ts — public types: ReactGrabSession, ReactGrabLoggedEvent, ReactGrabReplayOptions, etc.
  • packages/react-grab/src/index.ts — declares window.__REACT_GRAB_DEVTOOLS__.
  • packages/react-grab/src/constants.tsEVENT_LOG_RING_BUFFER_SIZE, EVENT_LOG_SCHEMA_VERSION.

Try it

window.__REACT_GRAB_DEVTOOLS__ = true;
// then load react-grab — a floating panel appears in the bottom-right.

// Programmatic use:
const session = window.__REACT_GRAB__.getSession();
console.log(JSON.stringify(session));

// Later, in a fresh tab:
await window.__REACT_GRAB__.replaySession(session);            // fast
await window.__REACT_GRAB__.replaySession(session, { realtime: true }); // honor original spacing
Open in Web Open in Cursor 

Summary by cubic

Add an internal event log with session export/replay and an optional, lazy-loaded devtools panel in react-grab to make bug repros and debugging easy. No end-user impact by default; the devtools panel only loads when enabled and stays SSR-safe.

  • New Features

    • Capture every dispatched store action via a proxy; internal action-to-action calls are not double-logged.
    • Serialize element args as stable handles (e.g., rg-el-1) with WeakRef and selector fallback; replay tolerates nulls.
    • Coalesce chatty setters (e.g., setPointer) and identical args to reduce noise; rows show ×count.
    • Export sessions as JSON and replay against the live store with optional realtime timing; store is reset before replay. Sessions include UA, URL, viewport, and a versioned schema.
    • New ReactGrabAPI methods: getSession, replaySession(session, options?), clearEventLog, setEventLogRecording.
    • Optional devtools panel (shadow DOM): pause/record, copy JSON, download, replay from clipboard, clear; enable with window.__REACT_GRAB_DEVTOOLS__ = true.
  • Bug Fixes

    • Devtools panel is lazy-imported and code-split, avoiding window access during SSR; loads only when window.__REACT_GRAB_DEVTOOLS__ is true.

Written for commit 174d66b. Summary will update on new commits. Review in cubic

Adds an internal event log that captures every dispatched store action,
serializes element references as stable handles (with selector fallback
for replay), and supports exporting/importing the session for bug repros.

Highlights:
- createEventLog() ring buffer wired into createGrabStore via an action
  proxy; nothing logs unless an action is invoked from outside the store
  (internal action-to-action calls are not double-logged).
- Pointer / detected-element / viewport-version churn is coalesced
  on dispatch so idle hover does not flood the buffer.
- Element args are walked recursively and replaced with { __rgHandle }
  markers; on replay we resolve via WeakRef first and selector second.
- New ReactGrabAPI methods: getSession(), replaySession(), clearEventLog(),
  setEventLogRecording().
- Optional floating devtools panel mounted into its own shadow root when
  window.__REACT_GRAB_DEVTOOLS__ === true. Lists recent events with
  coalescing counts and offers copy / download / replay / pause / clear.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@react-doctor

react-doctor Bot commented May 20, 2026

Copy link
Copy Markdown

64 score

Copy as prompt
Check if these React Review 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.

Run this before and after your changes to verify the result:
npx react-doctor@latest --verbose --diff

Do not modify the react-doctor configuration unless explicitly asked.
Fix the underlying code issues instead of changing or suppressing the rules.

React Review found 0 errors and 6 warnings. This PR leaves the React health score unchanged.

<file name="packages/react-grab/src/components/devtools-panel.tsx">

<violation number="1" location="packages/react-grab/src/components/devtools-panel.tsx:199">
Severity: Warning

Enforce a clickable non-interactive element has at least one keyboard event listener.

Visible, non-interactive elements with click handlers must have one of `keyup`, `keydown`, or `keypress` listener.

Rule: `click-events-have-key-events`
</violation>

<violation number="2" location="packages/react-grab/src/components/devtools-panel.tsx:199">
Severity: Warning

Static HTML elements with event handlers require a role.

Add a role attribute to this element, or use a semantic HTML element instead.

Rule: `no-static-element-interactions`
</violation>

<violation number="3" location="packages/react-grab/src/components/devtools-panel.tsx:284">
Severity: Warning

Multiple sequential element.style assignments — batch with cssText or classList for fewer reflows

Batch DOM/CSS reads and writes — interleaving them inside a loop causes layout thrashing. Read first, then write

Rule: `js-batch-dom-css`
</violation>

<violation number="4" location="packages/react-grab/src/components/devtools-panel.tsx:285">
Severity: Warning

Multiple sequential element.style assignments — batch with cssText or classList for fewer reflows

Batch DOM/CSS reads and writes — interleaving them inside a loop causes layout thrashing. Read first, then write

Rule: `js-batch-dom-css`
</violation>

<violation number="5" location="packages/react-grab/src/components/devtools-panel.tsx:286">
Severity: Warning

Multiple sequential element.style assignments — batch with cssText or classList for fewer reflows

Batch DOM/CSS reads and writes — interleaving them inside a loop causes layout thrashing. Read first, then write

Rule: `js-batch-dom-css`
</violation>

</file>

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

<violation number="1" location="packages/react-grab/src/core/event-log.ts:366">
Severity: Warning

await inside a for-loop runs the calls sequentially — for independent operations, collect them and use `await Promise.all(items.map(...))` to run them concurrently

Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently

Rule: `async-await-in-loop`
</violation>

</file>

Reviewed by reactreview for commit 174d66b. Configure here.

@vercel

vercel Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
react-grab-storybook Ready Ready Preview, Comment May 20, 2026 5:29pm
react-grab-website Ready Ready Preview, Comment May 20, 2026 5:29pm

@pkg-pr-new

pkg-pr-new Bot commented May 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 174d66b

solid-js/web's delegateEvents() runs at module evaluation and touches
window, which crashed Node imports of the dist bundle. Mirror the
renderer's existing pattern: import('../components/devtools-panel.js')
only when window.__REACT_GRAB_DEVTOOLS__ is set. The panel becomes its
own code-split chunk (~2.25 kB gzip) that never loads otherwise.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…ools-f3b7

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
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