Skip to content

Add time machine plugin: record state history and travel back/forward in time#519

Open
aidenybai wants to merge 6 commits into
mainfrom
aiden/time-machine-plugin-5fda
Open

Add time machine plugin: record state history and travel back/forward in time#519
aidenybai wants to merge 6 commits into
mainfrom
aiden/time-machine-plugin-5fda

Conversation

@aidenybai

@aidenybai aidenybai commented Jul 2, 2026

Copy link
Copy Markdown
Owner

What

A new built-in time machine plugin that uses bippy to record the history of exactly what happened in the host React app (every useState/useReducer change, per commit), and a scrubber panel with a per-component timeline — modeled on the style panel's editable-value stepper and transitions.dev Refine's time ruler — that travels left/right through all of that history. Every entry also carries its commit's render cost and any long animation frames it sat inside, so the timeline doubles as a performance view: janky moments show up as red dots you can scrub straight onto.

Time machine panel with per-component timeline, scrubbed back via the playhead
Time machine opened from the toolbar, rewinding live app state

Rewinding the react-grab website's homepage demo on a production Next build:

Time machine rewinding the website homepage demo

Live at now vs rewound onto a perf-flagged entry (red dot, 182ms frame badge, grayscale Live pill):

Panel live at now with red Live pill
Panel rewound onto a perf-flagged entry

How it works

Recording (core/time-machine-recorder.ts)

  • instrument(secure({ onCommitFiberRoot })) observes every commit; traverseRenderedFibers visits only fibers that actually rendered.
  • Each rendered fiber's hook list is diffed against its alternate in lockstep, so no baseline bookkeeping is needed. useState and useReducer hooks are recorded (identified by queue.lastRenderedReducer, with a behavioral probe classifying which of the two since prod minifies basicStateReducer's name). useSyncExternalStore is deliberately excluded: its state lives in an external store that has no generic setter, and React's consistency check re-reads getSnapshot() after every render, immediately reverting any forced hook value — so its changes are neither recorded nor restored.
  • All hook changes within one commit accumulate into a single timeline entry, and animation-tick bursts coalesce (short unconditional window + longer queue-keyed window for delayed transition-cleanup commits) so every timeline position is a settled moment. Commits within 200ms of real pointer/keyboard input never coalesce — two quick clicks stay two scrub steps.
  • Works against production React builds (dangerouslyRunInProduction + the reducer probe). Each entry stores the previous and next value of every changed hook (undo/redo both ways); history is bounded and entries hold queues/fibers via WeakRef. The recorder is dead-code-eliminated from the display-only demo build (IS_DEMO).

Performance attribution

  • Every commit's render cost is summed alongside its hook diff — one getTimings(fiber).selfTime per rendered composite fiber, so each component is counted once — and stored on the timeline entry (renderCount, renderDurationMs). React only populates durations in profiling builds (default-on in dev); plain prod builds report 0 and never false-flag.
  • A long-animation-frame PerformanceObserver (Chromium; no-ops gracefully elsewhere) runs for the recorder's lifetime. LoAF entries deliver asynchronously after the frame ends, so each is attributed by time window: the newest history entry whose commit timestamp falls inside the frame (± slack for the Date.now()/performance.timeOrigin drift) is charged its duration and blockingDuration. Coalesced bursts accumulate both metrics.
  • An entry is flagged (hasPerfIssue) when it sat inside a LoAF or its render cost exceeded one 60fps frame budget (16ms).

Travelling

  • useState hooks replay through their own queue.dispatch(() => value) — exact because basicStateReducer applies function actions to current state. React DevTools' overrideHookState bridge was not viable: under React Refresh (Vite/Next dev) the injected renderer is never retained anywhere reachable.
  • useReducer hooks can't be forced through dispatch (actions feed the app's reducer), so travel writes memoizedState/baseState directly on both the fiber and its alternate (making the forced commit diff-invisible to the recorder) plus queue.lastRenderedState (keeping eager-dispatch bailouts truthful), then forces a re-render: preferably by "wiggling" a sibling useState queue (sentinel dispatch immediately restored — nets to zero), falling back to the nearest stateful ancestor for reducer-only components. Both the written fiber's and the wiggled fiber's memoizedProps identities are invalidated (the same trick DevTools uses) so React's render-phase bailout can't discard the forced render.
  • Travel-induced commits are recognized (ordered per-queue expectations with TTL) and consumed instead of re-recorded; changing state while rewound truncates the redo tail — the timeline forks like undo/redo.
  • The React-updates freeze is suspended while the panel is open (it would buffer the travel dispatches).

Time freeze while rewound

  • Animation clock (core/time-machine-animation-clock.ts): every running CSS/WAAPI animation is paused; infinite loops seek to where they were at the rewound moment (spinners visibly turn backward as you scrub), finite transitions hold their settled end pose, and a global animation-play-state: paused + transition: none stylesheet makes scrub steps snap instead of tween. Returning to now (or closing) restores each animation to its captured time and resumes it seamlessly.
  • Page clock (core/time-machine-page-clock.ts): apps also animate through requestAnimationFrame loops, setTimeout chains, and setInterval tickers that keep mutating state while rewound. The page's scheduling clock is suspended while rewound: rAF/timeout callbacks park and replay in order when time resumes; interval ticks drop. Wrappers install at recorder start so pre-existing tickers are caught; React's MessageChannel work loop is untouched, and react-grab's own UI schedules through captured native timers/rAF so scrubbing keeps working.
  • Interaction snapshot (core/time-machine-interaction-snapshot.ts): each entry captures hover/focus/active styling at record time (the moment it was genuinely active); rewinding pins the current entry's captured styling back on as !important inline styles. Purely visual; focus never moves. Pins swap as you scrub and release at now/close/dispose.

UI (components/time-machine-panel/)

  • Timeline (timeline.tsx): one lane per component (capped, with an overflow lane), each recorded change drawn as a dot on a shared axis, applied changes lit and future ones dimmed, plus a draggable playhead that scrubs the whole app as you drag. Entry-index axis so bursts stay scrubbable. Perf-flagged entries draw as red dots (--rg-error-text).
  • Live pill: red with a pulsing dot while the cursor sits at now; grayscale while rewound; clicking it travels back to now. Sits next to the relative-time clock, which gains a red perf badge (182ms frame / 23ms render) when the current entry is flagged.
  • Toolbar button (clock icon): opens the scrubber with no element selection and the app fully live; outside clicks don't dismiss. "Time Machine" context-menu action (shortcut H) opens element-scoped with the page frozen; disabled until any change is recorded.
  • ValueStepper + StepArrow controls: ←/→ (hold-to-repeat, Shift×10) steps one change at a time; label shows the changed component and a clock shows how long ago that moment was.
  • Deactivating grab mode closes the panel; disposing tears down the recorder, clocks, pins, and the LoAF observer.

The plugin is registered as a built-in and also exported as timeMachinePlugin.

Production React support

secure() silently uninstalls commit handlers on prod builds (now runs with dangerouslyRunInProduction + the behavioral reducer probe), and production react-dom only talks to the DevTools hook if it exists when react-dom evaluates — integrations whose script loads late need bippy's install-hook-only shim early in the document. The earlier website-side workaround was dropped after #472 rewrote the site (it no longer runs the live overlay against itself).

Openstory demo fix

The openstory freeze/live-updates demos threw TypeError: e is not a function on load. This was a pre-existing storybook build issue (reproduces identically on main), surfaced while validating this branch's preview: the openstory CLI merges an inline, optionless vite-plugin-solid instance with the config file's plugins, so the exclude: [/\.react\.tsx$/] on the configured solid() never stopped the duplicate from compiling the *.react.tsx demo components as Solid JSX — which React then rendered and crashed on. apps/openstory/vite.config.ts now pre-compiles those files' JSX with the React automatic runtime in an enforce: "pre" transform, leaving no JSX behind for either solid instance. Both demos now load, render, and freeze/unfreeze correctly in dev and production builds.

Tests

  • E2e suite e2e/time-machine.spec.ts (20 tests) covering: disabled action before history, toolbar open without selection, live recording while open, H shortcut, context-menu opening, timeline lanes + playhead scrubbing, useState travel, useReducer travel on a component with a sibling useState and on a reducer-only component, useSyncExternalStore exclusion (not recorded, not travelled), hover styling pinned while rewound, animations freezing/resuming, end-of-history no-ops, dismiss keeping travelled state, close-on-deactivate, timeline forking, the Live pill graying out while rewound and returning to now on click, and a janky commit (80ms main-thread block during render) being flagged as a perf issue on the timeline. New e2e fixtures (ReducerSection, JankyCounter) exercise the forced-render paths, the external-store case, and slow-render/LoAF flagging.
  • Verified against a production website build: zero garbled scrub positions across the full timeline; page content holds byte-identical for 7+ seconds while rewound and resumes at now/close; spinner currentTime runs backward while scrubbing back.
  • Openstory: freeze-demo and live-updates stories verified error-free in dev and production builds; freeze-updates confirmed to pause/resume the live counter with react-grab active.
  • Full suite: 774 Playwright e2e tests pass, 58 react-grab unit tests pass; pnpm lint, pnpm typecheck, pnpm format clean.

To show artifacts inline, enable in settings.

Open in Web Open in Cursor 

Summary by cubic

Adds a built-in Time Machine to react-grab that records every useState/useReducer change via bippy and lets you scrub back/forward to restore exact app state, including in production. While rewound, it freezes animations and the page clock, pins interaction visuals, and resumes cleanly at “now” or on close.

  • New Features

    • Recorder: per-commit history of useState/useReducer with coalesced animation bursts, cap 200, prev/next values, travel commits ignored, redo tail truncated; production-safe via reducer probe; useSyncExternalStore excluded; demo build omits recorder.
    • Travel: useState via queue.dispatch(() => value); useReducer by writing hook state on both fiber buffers and forcing a bailout-proof re-render.
    • Clocks: while rewound, pauses CSS/WAAPI and rAF/timeout/interval; loops seek to the rewound moment, finite transitions hold end pose; parked callbacks replay on resume; stepper hold-to-repeat uses native timers routed through pre-wrapper functions.
    • Interactions: captures hover/focus/active computed styles at record time and pins them visually while rewound.
    • UI: toolbar clock button opens a mini scrubber with the app live; context‑menu “Time Machine” (H) opens element‑scoped with the page frozen; timeline per component with draggable playhead; exported as timeMachinePlugin with TimeMachinePanelState and TimeMachineTimelineEntry types.
    • Performance: each entry records render count/duration and any long animation frames; heavy entries are flagged (red dots with a compact perf badge) and a pulsing “Live” pill jumps back to now.
  • Bug Fixes

    • Dispose fully tears down time machine state and releases animation/page clocks; reopening while rewound re‑freezes the page clock; wrapped timers preserve this binding and replayed callbacks re‑park through wrappers.
    • OpenStory demos: pre‑compile *.react.tsx with the React runtime to avoid vite-plugin-solid transforming them as Solid JSX, preventing runtime crashes.

Written for commit 1f38fe9. Summary will update on new commits.

Review in cubic

@vercel

vercel Bot commented Jul 2, 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 Jul 3, 2026 6:16am
react-grab-website Ready Ready Preview, Comment Jul 3, 2026 6:16am

@pkg-pr-new

pkg-pr-new Bot commented Jul 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 1f38fe9

Comment thread packages/react-grab/src/core/time-machine-recorder.ts

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

5 issues found across 15 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="apps/website/.gitignore">

<violation number="1" location="apps/website/.gitignore:43">
P3: The `public/install-hook.js` entry sits under the `# typescript` section header but is a generated JS build artifact, not a TypeScript file. Consider moving it under `# production` (with `/build`) or adding a dedicated `# generated scripts` section so `.gitignore` sections stay self-descriptive.</violation>
</file>

<file name="packages/react-grab/src/core/time-machine-recorder.ts">

<violation number="1" location="packages/react-grab/src/core/time-machine-recorder.ts:156">
P2: When the panel is open and the cursor is rewound, this early return silently drops commits from the timeline — but the React-updates freeze is also suspended (to allow travel dispatches to commit), which means external state updates (timers, network callbacks) still commit normally. The result is state drift: the app's actual state moves on while the scrubber stays fixed, and subsequent travel steps restore values that no longer correspond to what's on screen.

Consider either (a) still recording these entries (perhaps into a separate "background" log that doesn't fork the timeline) so the scrubber can account for drift, or (b) selectively re-enabling the freeze for non-travel dispatches while the panel is open so external updates are buffered until dismissal.</violation>

<violation number="2" location="packages/react-grab/src/core/time-machine-recorder.ts:250">
P1: Scrubbing across multiple entries for the same hook can land on an intermediate state when the final target equals the currently rendered value. The no-op check should not skip when a pending travel update for this queue has already been queued in the same travel operation.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugins/time-machine.ts:21">
P2: History can still be invoked via the toolbar default action or bare `H` shortcut before any entries exist because those paths bypass `ContextMenuAction.enabled`. Adding the same `hasTimeMachineHistory()` guard inside `onAction` would keep disabled-before-history behavior consistent across invocation paths.</violation>
</file>

<file name="packages/react-grab/src/core/time-machine.ts">

<violation number="1" location="packages/react-grab/src/core/time-machine.ts:56">
P2: Disposing React Grab while History is open can leave the recorder thinking the panel is still open, causing later rewound changes to be dropped instead of forking the timeline. The cleanup only unsubscribes from history; it should also clear `setTimeMachinePanelOpen(false)` when `state()` is still open.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/react-grab/src/core/time-machine-recorder.ts Outdated
shortcut: "H",
shortcutModifier: false,
showInToolbarMenu: true,
enabled: () => hasTimeMachineHistory(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: History can still be invoked via the toolbar default action or bare H shortcut before any entries exist because those paths bypass ContextMenuAction.enabled. Adding the same hasTimeMachineHistory() guard inside onAction would keep disabled-before-history behavior consistent across invocation paths.

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/time-machine.ts, line 21:

<comment>History can still be invoked via the toolbar default action or bare `H` shortcut before any entries exist because those paths bypass `ContextMenuAction.enabled`. Adding the same `hasTimeMachineHistory()` guard inside `onAction` would keep disabled-before-history behavior consistent across invocation paths.</comment>

<file context>
@@ -0,0 +1,30 @@
+          shortcut: "H",
+          shortcutModifier: false,
+          showInToolbarMenu: true,
+          enabled: () => hasTimeMachineHistory(),
+          onAction: (context) => {
+            context.enterTimeMachine?.();
</file context>

Comment thread packages/react-grab/src/core/time-machine.ts
Comment thread packages/react-grab/src/core/time-machine-recorder.ts
Comment thread apps/website/.gitignore Outdated
*.tsbuildinfo
next-env.d.ts
.env*.local
public/install-hook.js

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The public/install-hook.js entry sits under the # typescript section header but is a generated JS build artifact, not a TypeScript file. Consider moving it under # production (with /build) or adding a dedicated # generated scripts section so .gitignore sections stay self-descriptive.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/website/.gitignore, line 43:

<comment>The `public/install-hook.js` entry sits under the `# typescript` section header but is a generated JS build artifact, not a TypeScript file. Consider moving it under `# production` (with `/build`) or adding a dedicated `# generated scripts` section so `.gitignore` sections stay self-descriptive.</comment>

<file context>
@@ -40,3 +40,4 @@ yarn-error.log*
 *.tsbuildinfo
 next-env.d.ts
 .env*.local
+public/install-hook.js
</file context>

@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Deployment failed with the following error:

The `vercel.json` schema validation failed with the following message: `buildCommand` should NOT be longer than 256 characters

Learn More: https://vercel.com/docs/concepts/projects/project-configuration

Comment thread packages/react-grab/src/core/index.tsx
Comment thread packages/react-grab/src/core/time-machine-recorder.ts
Comment thread packages/react-grab/src/core/time-machine-recorder.ts

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 7 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/time-machine-recorder.ts">

<violation number="1" location="packages/react-grab/src/core/time-machine-recorder.ts:192">
P3: Timeline entries for batched commits can show the wrong component: later changed fibers are included in `changes`, but `componentName` remains the first changed fiber only. Consider tracking multiple component names or using a generic batched label when a commit spans more than one component.</violation>
</file>

<file name="apps/website/vercel.json">

<violation number="1" location="apps/website/vercel.json:2">
P2: The vercel.json buildCommand duplicates the package.json build script verbatim. Any future build pipeline change (adding a step, changing a copy path) must be updated in two places or builds will silently diverge between local and Vercel. Consider restoring the delegation approach: set buildCommand back to "pnpm run build" so vercel.json simply defers to the canonical build script. If the delegation was reverted due to working-directory or 256-char issues, add a brief comment explaining the constraint so maintainers know not to consolidate.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread apps/website/vercel.json Outdated
const changes = collectHookChanges(fiber);
if (!changes) return;
if (commitChanges) {
commitChanges.push(...changes);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: Timeline entries for batched commits can show the wrong component: later changed fibers are included in changes, but componentName remains the first changed fiber only. Consider tracking multiple component names or using a generic batched label when a commit spans more than one component.

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/time-machine-recorder.ts, line 192:

<comment>Timeline entries for batched commits can show the wrong component: later changed fibers are included in `changes`, but `componentName` remains the first changed fiber only. Consider tracking multiple component names or using a generic batched label when a commit spans more than one component.</comment>

<file context>
@@ -176,21 +176,40 @@ const recordEntry = (fiber: Fiber, changes: TimeMachineHookChange[]): void => {
-    recordEntry(fiber, changes);
+  if (!changes) return;
+  if (commitChanges) {
+    commitChanges.push(...changes);
+  } else {
+    commitChanges = changes;
</file context>

Comment thread packages/react-grab/src/core/time-machine-recorder.ts
Comment thread packages/react-grab/src/core/time-machine-recorder.ts

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 3 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="apps/website/.gitignore">

<violation number="1" location="apps/website/.gitignore:43">
P3: The `public/install-hook.js` entry sits under the `# typescript` section header but is a generated JS build artifact, not a TypeScript file. Consider moving it under `# production` (with `/build`) or adding a dedicated `# generated scripts` section so `.gitignore` sections stay self-descriptive.</violation>
</file>

<file name="packages/react-grab/src/core/time-machine-recorder.ts">

<violation number="1" location="packages/react-grab/src/core/time-machine-recorder.ts:156">
P2: When the panel is open and the cursor is rewound, this early return silently drops commits from the timeline — but the React-updates freeze is also suspended (to allow travel dispatches to commit), which means external state updates (timers, network callbacks) still commit normally. The result is state drift: the app's actual state moves on while the scrubber stays fixed, and subsequent travel steps restore values that no longer correspond to what's on screen.

Consider either (a) still recording these entries (perhaps into a separate "background" log that doesn't fork the timeline) so the scrubber can account for drift, or (b) selectively re-enabling the freeze for non-travel dispatches while the panel is open so external updates are buffered until dismissal.</violation>

<violation number="2" location="packages/react-grab/src/core/time-machine-recorder.ts:192">
P3: Timeline entries for batched commits can show the wrong component: later changed fibers are included in `changes`, but `componentName` remains the first changed fiber only. Consider tracking multiple component names or using a generic batched label when a commit spans more than one component.</violation>

<violation number="3" location="packages/react-grab/src/core/time-machine-recorder.ts:250">
P1: Scrubbing across multiple entries for the same hook can land on an intermediate state when the final target equals the currently rendered value. The no-op check should not skip when a pending travel update for this queue has already been queued in the same travel operation.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugins/time-machine.ts:21">
P2: History can still be invoked via the toolbar default action or bare `H` shortcut before any entries exist because those paths bypass `ContextMenuAction.enabled`. Adding the same `hasTimeMachineHistory()` guard inside `onAction` would keep disabled-before-history behavior consistent across invocation paths.</violation>
</file>

<file name="apps/website/vercel.json">

<violation number="1" location="apps/website/vercel.json:2">
P2: The vercel.json buildCommand duplicates the package.json build script verbatim. Any future build pipeline change (adding a step, changing a copy path) must be updated in two places or builds will silently diverge between local and Vercel. Consider restoring the delegation approach: set buildCommand back to "pnpm run build" so vercel.json simply defers to the canonical build script. If the delegation was reverted due to working-directory or 256-char issues, add a brief comment explaining the constraint so maintainers know not to consolidate.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread apps/website/next.config.ts Outdated
Comment thread apps/website/next.config.ts Outdated
Comment thread packages/react-grab/src/core/plugins/time-machine.ts

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 6 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="apps/website/.gitignore">

<violation number="1" location="apps/website/.gitignore:43">
P3: The `public/install-hook.js` entry sits under the `# typescript` section header but is a generated JS build artifact, not a TypeScript file. Consider moving it under `# production` (with `/build`) or adding a dedicated `# generated scripts` section so `.gitignore` sections stay self-descriptive.</violation>
</file>

<file name="packages/react-grab/src/core/time-machine-recorder.ts">

<violation number="1" location="packages/react-grab/src/core/time-machine-recorder.ts:156">
P2: When the panel is open and the cursor is rewound, this early return silently drops commits from the timeline — but the React-updates freeze is also suspended (to allow travel dispatches to commit), which means external state updates (timers, network callbacks) still commit normally. The result is state drift: the app's actual state moves on while the scrubber stays fixed, and subsequent travel steps restore values that no longer correspond to what's on screen.

Consider either (a) still recording these entries (perhaps into a separate "background" log that doesn't fork the timeline) so the scrubber can account for drift, or (b) selectively re-enabling the freeze for non-travel dispatches while the panel is open so external updates are buffered until dismissal.</violation>

<violation number="2" location="packages/react-grab/src/core/time-machine-recorder.ts:192">
P3: Timeline entries for batched commits can show the wrong component: later changed fibers are included in `changes`, but `componentName` remains the first changed fiber only. Consider tracking multiple component names or using a generic batched label when a commit spans more than one component.</violation>

<violation number="3" location="packages/react-grab/src/core/time-machine-recorder.ts:250">
P1: Scrubbing across multiple entries for the same hook can land on an intermediate state when the final target equals the currently rendered value. The no-op check should not skip when a pending travel update for this queue has already been queued in the same travel operation.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugins/time-machine.ts:21">
P2: History can still be invoked via the toolbar default action or bare `H` shortcut before any entries exist because those paths bypass `ContextMenuAction.enabled`. Adding the same `hasTimeMachineHistory()` guard inside `onAction` would keep disabled-before-history behavior consistent across invocation paths.</violation>
</file>

<file name="apps/website/vercel.json">

<violation number="1" location="apps/website/vercel.json:2">
P2: The vercel.json buildCommand duplicates the package.json build script verbatim. Any future build pipeline change (adding a step, changing a copy path) must be updated in two places or builds will silently diverge between local and Vercel. Consider restoring the delegation approach: set buildCommand back to "pnpm run build" so vercel.json simply defers to the canonical build script. If the delegation was reverted due to working-directory or 256-char issues, add a brief comment explaining the constraint so maintainers know not to consolidate.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread apps/website/public/install-hook.js Outdated

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

Copy link
Copy Markdown
Contributor

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="apps/website/.gitignore">

<violation number="1" location="apps/website/.gitignore:43">
P3: The `public/install-hook.js` entry sits under the `# typescript` section header but is a generated JS build artifact, not a TypeScript file. Consider moving it under `# production` (with `/build`) or adding a dedicated `# generated scripts` section so `.gitignore` sections stay self-descriptive.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugins/time-machine.ts:17">
P2: The comment here claims that `IS_DEMO` folds at build time and the recorder is dead-code-eliminated from the demo bundle, but that is not guaranteed by the gating in this file. `timeMachinePlugin` still statically imports `hasTimeMachineHistory` and `stopTimeMachineRecorder` from `time-machine-recorder` and references them in the returned action and cleanup, so the recorder module remains in the bundle. If that module or any of its imports (global clock/animation interceptors, bippy instrumentation) have load-time side effects, the demo build will execute them—exactly the kind of side effect demo mode is meant to avoid. Either restructure the plugin so the recorder is only imported in non-demo builds (e.g., dynamic import with no-op stubs for the demo), or remove the inaccurate dead-code-elimination claim.</violation>

<violation number="2" location="packages/react-grab/src/core/plugins/time-machine.ts:21">
P2: History can still be invoked via the toolbar default action or bare `H` shortcut before any entries exist because those paths bypass `ContextMenuAction.enabled`. Adding the same `hasTimeMachineHistory()` guard inside `onAction` would keep disabled-before-history behavior consistent across invocation paths.</violation>
</file>

<file name="packages/react-grab/src/core/time-machine-recorder.ts">

<violation number="1" location="packages/react-grab/src/core/time-machine-recorder.ts:110">
P2: The TTL-based expiry for travel expectations can misclassify delayed travel commits as real state changes. `TIME_MACHINE_TRAVEL_EXPECTATION_TTL_MS` is only 1000ms, and `dropExpiredExpectations` removes expectations older than that before `consumeTravelExpectation` checks them. If a travel-induced React commit is delayed by concurrent rendering, Suspense, hydration, background-tab throttling, or a CPU stall, the expectation will expire before the commit lands, and the commit will be recorded as a genuine app change instead of being ignored. This can corrupt the timeline with bogus entries or fork/truncate the redo tail unexpectedly. Consider replacing the wall-clock TTL with a more robust signal (e.g., batch-scoped expectations), or at least extending the window and documenting the limitation.</violation>

<violation number="2" location="packages/react-grab/src/core/time-machine-recorder.ts:192">
P3: Timeline entries for batched commits can show the wrong component: later changed fibers are included in `changes`, but `componentName` remains the first changed fiber only. Consider tracking multiple component names or using a generic batched label when a commit spans more than one component.</violation>

<violation number="3" location="packages/react-grab/src/core/time-machine-recorder.ts:202">
P1: Using `Array.prototype.findLastIndex` in `consumeTravelExpectation` introduces an ES2023 runtime dependency that the project does not appear to polyfill or target. If the recorder runs on an older browser/WebView without ES2023 support, this line will throw during travel commit processing, causing the recorder to miss expectation settlement and leaving the time machine broken in those environments. A manual reverse loop gives the same "last match" semantics without the compatibility risk.</violation>

<violation number="4" location="packages/react-grab/src/core/time-machine-recorder.ts:245">
P2: Clearing `pendingTravelValues` inside `stopTimeMachineRecorder` can race with asynchronous React commits. When a travel value is dispatched via `queue.dispatch(() => value)`, the expectation is recorded before the actual commit. If the recorder is stopped and restarted before React commits that update, the expectation is discarded while recording is active again, so `handleCommitFiberRoot` treats the travel-induced state diff as a real application change and records it as history. That could populate the timeline with the time machine's own restore operations and corrupt undo/redo forking. Consider tracking in-flight travel dispatches (e.g., a counter incremented in `applyHookValue` and decremented when the expected commit is consumed or errored) and delaying new history recording until all pending travel commits settle, rather than unconditionally discarding the expectation map on stop.</violation>

<violation number="5" location="packages/react-grab/src/core/time-machine-recorder.ts:287">
P2: When animation- or timer-driven commits are coalesced into the previous timeline entry, the recorder updates the entry's timestamp and final hook values but keeps the `interactionSnapshot` from the original commit. When the user scrubs back to that entry, the time machine applies that stored snapshot as hover/focus/active pins, so the visual interaction state may not reflect the actual moment the entry now represents. Consider refreshing `lastEntry.interactionSnapshot` as part of the coalescing update so the snapshot stays aligned with the updated timestamp and values.</violation>

<violation number="6" location="packages/react-grab/src/core/time-machine-recorder.ts:329">
P2: The new interaction-snapshot pins are only released when the panel closes or the timeline returns to the present. `stopTimeMachineRecorder` is a public teardown path (used as the plugin `cleanup` and called directly in `index.tsx`), but it resets recorder state without releasing those pins. If a caller stops the recorder while rewound without first closing the panel, hover/focus/active styles can stay pinned on the page. Consider adding `releaseInteractionPins()` (and `releaseAnimationClock()` for symmetry) to `stopTimeMachineRecorder` so its teardown is self-contained and doesn't depend on external panel-close ordering.</violation>

<violation number="7" location="packages/react-grab/src/core/time-machine-recorder.ts:345">
P2: Expired travel expectations should be pruned at the start of `applyHookValue` before the no-op check. Right now `settleTravelExpectations` and `consumeTravelExpectation` clean up expired expectations, but `applyHookValue` reads the raw tail expectation directly. If a prior travel batch never produced a commit that touched the queue, a stale, expired expectation can remain and suppress a later restore to the same value, leaving the app state out of sync with the scrubber cursor. Consider calling `dropExpiredExpectations` on the retrieved expectation list before comparing the tail, and fall back to `queue.lastRenderedState` when the list is empty after pruning.</violation>

<violation number="8" location="packages/react-grab/src/core/time-machine-recorder.ts:366">
P2: The user-input attribution used to prevent coalescing only records `pointerdown`, `pointerup`, and `keydown`. State changes triggered by hover/enter/leave/move handlers (for example, tooltips, drag-over states, or hover counters) can therefore be folded into the previous timeline entry if they occur within the settle window and touch a hook queue the previous entry already changed, making the scrubber skip a genuine user moment. Consider adding `pointerenter` and `pointerleave` (and possibly `pointermove`, with care for noise) to `USER_INPUT_EVENTS` so the coalescing logic correctly identifies these as user-driven commits.</violation>

<violation number="9" location="packages/react-grab/src/core/time-machine-recorder.ts:376">
P2: The recorder installs permanent global event listeners and page-clock interception without cleanup. While `isInstrumented`/`isInstalled` prevent stacking within one module instance, dev/HMR or re-init can reset those flags and re-run setup, causing duplicate capture listeners and stacked wrappers around `requestAnimationFrame`, `setTimeout`, and `setInterval`. This is a real lifecycle/leak risk and could also leave stale `lastUserInputAtMs` closures.

I'd recommend tracking the listener handles so they can be removed in `stopTimeMachineRecorder`, and making the page-clock interception idempotent across module reloads (for example, with a global `window` flag) so re-initialization doesn't stack scheduler wrappers.</violation>

<violation number="10" location="packages/react-grab/src/core/time-machine-recorder.ts:382">
P2: The recorder's stop path may leave the page in a frozen/rewound state. `stopTimeMachineRecorder()` resets history and recording flags but does not release `freezePageClock()` / `syncAnimationClock()` / interaction pins. If the recorder is stopped while the cursor is in the past (e.g. via the plugin's own `cleanup` path, or any future caller that doesn't first call `setTimeMachinePanelOpen(false)`), timers, animations, and hover/focus styling will remain frozen after the time machine is supposed to be gone. Consider making `stopTimeMachineRecorder()` defensive by releasing the page clock, animation clock, and interaction pins before clearing state.</violation>

<violation number="11" location="packages/react-grab/src/core/time-machine-recorder.ts:392">
P2: When the time machine cursor is fully rewound to 0, the animation clock is anchored to the first recorded commit's timestamp (`history[0].timestamp`). Cursor 0 actually represents the state *before* that first commit, because the code applies the entry's `previousValue` to revert to it. Freezing animations at the first-commit timestamp means any animation that was already running before the first state change appears visually offset by the time between the initial render and the first commit. Consider recording the recorder start time in `startTimeMachineRecorder` and using it as the animation anchor when `travelCursor === 0`, falling back to the entry timestamp for all other rewound positions.</violation>

<violation number="12" location="packages/react-grab/src/core/time-machine-recorder.ts:405">
P2: Scrubbing the time-machine timeline can show the wrong hover/focus/active visuals because the captured interaction pins are applied synchronously, before React has necessarily committed the rewound state. If the travel update remounts the pinned element (or any ancestor), the style is applied to the stale DOM node and is lost on the committed node, causing a flicker or missing interaction state.

The same commit-timing concern is already handled for animations via a multi-frame `settleSweep` in `time-machine-animation-clock.ts`; interaction pins should follow a similar pattern: apply the snapshot after the next paint/raf cycle and/or schedule a short post-commit sweep so the pins land on the actual rewound DOM. Consider replacing the direct `applyInteractionSnapshot(...)` call with a deferred/synced helper that mirrors the animation clock's sweep behavior.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/index.tsx:1525">
P2: The History toolbar action bypasses the normal `handleActivateAction` state machine and does not clear pending selection state. If a user is mid-flow (prompt mode, pending comment, or pending toolbar action) and clicks the History button, `toggleToolbarTimeMachine` opens the panel but leaves `pendingDefaultActionId`, `pendingToolbarActionId`, and prompt/comment state intact. Because `toolbarActiveActionId` prioritizes `timeMachine.isOpen()`, the toolbar then highlights History as the active action while the renderer still carries the stale state. This can produce unexpected behavior when the user later clicks an element or toggles another action.

Suggestion: before opening the History panel from the toolbar, clear any pending toolbar selection state and exit prompt/comment modes, or explicitly decide to deactivate the active renderer first. For example, call `clearPendingToolbarSelection()` and `actions.exitPromptMode()`/`actions.setPendingCommentMode(false)` inside `toggleToolbarTimeMachine` (or before the early return in `handleActivateAction`), matching how other full-popup teardown paths clean up state.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/components/time-machine-panel/index.tsx:145">
P1: When the Time Machine panel is opened from the toolbar (no `element`), it intentionally stays open during pointer interactions with the live app, making it a non-modal utility. However, the container still declares `aria-modal="true"`, which tells screen readers that the background is inert. This ARIA/behavior mismatch can confuse assistive technology users and make the background app appear inaccessible while it is still interactive. Consider making `aria-modal` reflect the panel mode—set it to `"true"` only when `props.state.element` is present, and omit it (or set `"false"`) in the persistent toolbar mode.</violation>

<violation number="2" location="packages/react-grab/src/components/time-machine-panel/index.tsx:155">
P1: When the Time Machine panel is opened from the toolbar (no selected element), the code intentionally keeps pointer clicks on the page alive, but it still captures ArrowLeft/ArrowRight globally in the capture phase. That means any host app keyboard shortcut, canvas control, list navigation, or other arrow-key interaction will be blocked while the panel remains open, creating an inconsistent experience for the new persistent mode. I’d suggest scoping the arrow-key scrubbing shortcut so it only activates when focus is actually inside the panel in the toolbar-opened case, rather than swallowing all non-input arrow events on the page.</violation>
</file>

<file name="packages/react-grab/src/components/icons/icon-history.tsx">

<violation number="1" location="packages/react-grab/src/components/icons/icon-history.tsx:12">
P3: The new `IconHistory` component renders an inline SVG without marking it as decorative or supporting an accessible name. In its current toolbar use, the parent `ToolbarActionButton` provides `aria-label="State history"`, so the SVG is purely decorative, but an unlabeled SVG inside a button can still be exposed as an unnamed graphic by some assistive tech. Add `aria-hidden="true"` to the root `<svg>` so the icon is not announced separately from the button. If the icon might ever be used on its own, consider exposing a `title` prop that toggles the hidden state instead.</violation>
</file>

<file name="packages/react-grab/src/core/time-machine-animation-clock.ts">

<violation number="1" location="packages/react-grab/src/core/time-machine-animation-clock.ts:107">
P1: Newborn animations are incorrectly marked as paused by the time machine, which can cause them to be force-played when the clock is released. In `captureNewbornAnimation`, `didPause` is hard-coded to `true` even when the animation was not running when captured. On release, `releaseAnimationClock` calls `play()` for every record with `didPause: true`, so an animation that was originally paused or finished will be incorrectly started once the time machine closes. Consider recording `didPause` based on whether the animation was actually running when captured (e.g., `const wasRunning = animation.playState === "running"`) and only calling `play()` for animations the clock actually paused.</violation>

<violation number="2" location="packages/react-grab/src/core/time-machine-animation-clock.ts:157">
P2: Stale animation records are never pruned while the time machine is rewound, so repeated scrubbing or remounting can accumulate cancelled/detached `Animation` objects. Because the `recordByAnimation` map holds strong references and `syncAnimationClock` iterates the whole `frozenAnimationRecords` array every step, the list grows and each scrub does more work over time. Consider pruning dead animations (for example, those with `playState === 'idle'` or a null `effect`) from the array and map before seeking, so the set stays bounded to currently live animations.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/components/time-machine-panel/timeline.tsx:129">
P2: The timeline track is exposed as a `slider` (with `role="slider"`, `aria-valuemin`, `aria-valuemax`, and `aria-valuenow`) but is not keyboard-focusable (`tabIndex={-1}`) and has no keyboard handler. This creates an accessibility mismatch: screen readers will announce an interactive slider, yet keyboard users cannot focus or operate this element directly. Since the ARIA `slider` pattern requires focus and keyboard support, either remove the slider role from this visual-only track or make it focusable (`tabIndex={0}`) and add arrow-key handling that calls `onTravel`.</violation>
</file>

<file name="packages/react-grab/src/core/time-machine-interaction-snapshot.ts">

<violation number="1" location="packages/react-grab/src/core/time-machine-interaction-snapshot.ts:37">
P2: The overlay exclusion check is too narrow for focused elements. `collectFocusedElements` descends into shadow roots, but `isOverlayElement` only looks at the focused element itself, not its shadow host or ancestors. If a React Grab toolbar or Time Machine panel is rendered as a shadow host carrying `data-react-grab`, a focused control inside it would be recorded as a host-app interaction pin and could receive `!important` inline focus styles during scrubbing. Consider checking whether the element is inside a `data-react-grab` host, or whether any ancestor/shadow host has the overlay attribute.</violation>

<violation number="2" location="packages/react-grab/src/core/time-machine-interaction-snapshot.ts:72">
P2: The `:hover` snapshot capture can exhaust the interaction-element cap on layout ancestors before reaching the actual interactive target. `document.querySelectorAll(':hover')` returns the full hovered ancestor chain in document order (root to leaf), and the `capture` loop caps at `TIME_MACHINE_MAX_INTERACTION_ELEMENTS = 24` without prioritizing leaf elements or elements with actual hover styles. In a deeply nested UI, `html`/`body`/wrapper divs can consume the budget and leave the real hovered button/input unrecorded, making the scrubbed replay miss the hover visual that accompanied the state change. Consider capturing `:hover` matches in reverse document order (leaf-to-root) or filtering to elements whose computed hover styles differ from their non-hover state, so the cap is spent on the elements that actually matter.</violation>

<violation number="3" location="packages/react-grab/src/core/time-machine-interaction-snapshot.ts:73">
P2: The interaction snapshot can miss focus-specific visuals for controls that are simultaneously focused and hovered/active. Because the recorder deduplicates by element before the property set is considered, a focused element that was already captured as `:active` or `:hover` is skipped entirely in the focus pass. That leaves focus-only properties like `outline-offset`, `ring-color`, and `ring-width` unpinned, so the element may not visually reflect its focused state when scrubbing back. Consider tracking which properties have already been captured per element and merging the missing focus properties into the existing pin instead of skipping the element.</violation>

<violation number="4" location="packages/react-grab/src/core/time-machine-interaction-snapshot.ts:122">
P2: Interaction snapshots are tied to exact DOM element identity via `WeakRef`, so the visual replay of hover/focus/active states is silently lost when React remounts equivalent elements during time travel. The recorder dispatches restored hook values before applying the snapshot, which can trigger unmount/remount of conditional or keyed subtrees. Once the original element is disconnected, `applyInteractionSnapshot` simply skips it and the pinned interaction styles never reach the replacement element. Consider documenting this scope limitation or adding a fallback mapping strategy (e.g., stable selector or React fiber identity) so equivalent remounted elements can still receive the captured interaction visuals.</violation>
</file>

<file name="packages/react-grab/src/constants.ts">

<violation number="1" location="packages/react-grab/src/constants.ts:358">
P2: The 700ms settle coalescing window can merge semantically distinct async state updates on the same hook queue, causing intermediate application states to disappear from the timeline. In `time-machine-recorder.ts`, `tryCoalesceIntoLastEntry` coalesces any commit within 120ms unconditionally and any commit within 700ms when all changed queues already appear in the previous entry (`areAllQueuesInEntry`). That predicate only checks queue identity and elapsed time, so debounced input normalization, delayed server reconciliation, or chained effects on the same control within 700ms are treated as transition cleanup and swallowed. Consider tightening the settle window or adding a stronger signal (e.g., requiring the commit to match transition-cleanup behavior) before coalescing in the 120–700ms range.</violation>

<violation number="2" location="packages/react-grab/src/constants.ts:363">
P2: The hard-coded coalescing windows (`120ms` burst, `700ms` settle, `200ms` input attribution) can collapse legitimate delayed user actions into the prior timeline entry, which hurts time-machine history correctness. Because the attribution window only guards the first 200ms after input, a debounced input, async validation, or network-driven commit that lands later but still within 700ms can be merged if it touches the same hook queue(s). Consider exposing these windows as plugin configuration options so apps with longer interaction delays can preserve accurate history, or at least document the heuristic limitation and add test coverage for delayed user-driven commits.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

// recording the host page's commits and wrapping its global scheduling
// clock from inside it would be exactly the kind of side effect demo
// mode exists to rule out. IS_DEMO folds at build time, so the recorder
// is dead-code-eliminated from the demo bundle.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The comment here claims that IS_DEMO folds at build time and the recorder is dead-code-eliminated from the demo bundle, but that is not guaranteed by the gating in this file. timeMachinePlugin still statically imports hasTimeMachineHistory and stopTimeMachineRecorder from time-machine-recorder and references them in the returned action and cleanup, so the recorder module remains in the bundle. If that module or any of its imports (global clock/animation interceptors, bippy instrumentation) have load-time side effects, the demo build will execute them—exactly the kind of side effect demo mode is meant to avoid. Either restructure the plugin so the recorder is only imported in non-demo builds (e.g., dynamic import with no-op stubs for the demo), or remove the inaccurate dead-code-elimination claim.

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/time-machine.ts, line 17:

<comment>The comment here claims that `IS_DEMO` folds at build time and the recorder is dead-code-eliminated from the demo bundle, but that is not guaranteed by the gating in this file. `timeMachinePlugin` still statically imports `hasTimeMachineHistory` and `stopTimeMachineRecorder` from `time-machine-recorder` and references them in the returned action and cleanup, so the recorder module remains in the bundle. If that module or any of its imports (global clock/animation interceptors, bippy instrumentation) have load-time side effects, the demo build will execute them—exactly the kind of side effect demo mode is meant to avoid. Either restructure the plugin so the recorder is only imported in non-demo builds (e.g., dynamic import with no-op stubs for the demo), or remove the inaccurate dead-code-elimination claim.</comment>

<file context>
@@ -9,7 +10,12 @@ import {
+    // recording the host page's commits and wrapping its global scheduling
+    // clock from inside it would be exactly the kind of side effect demo
+    // mode exists to rule out. IS_DEMO folds at build time, so the recorder
+    // is dead-code-eliminated from the demo bundle.
+    if (!IS_DEMO) startTimeMachineRecorder();
     return {
</file context>

Comment thread packages/react-grab/src/core/time-machine-recorder.ts
Comment thread packages/react-grab/src/core/time-machine-page-clock.ts
Comment thread packages/react-grab/src/core/time-machine-page-clock.ts Outdated
Comment thread packages/react-grab/src/utils/native-timers.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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 using default effort 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.

Reviewed by Cursor Bugbot for commit bd0971f. Configure here.

Comment thread packages/react-grab/src/core/time-machine.ts

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

Copy link
Copy Markdown
Contributor

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="apps/website/.gitignore">

<violation number="1" location="apps/website/.gitignore:43">
P3: The `public/install-hook.js` entry sits under the `# typescript` section header but is a generated JS build artifact, not a TypeScript file. Consider moving it under `# production` (with `/build`) or adding a dedicated `# generated scripts` section so `.gitignore` sections stay self-descriptive.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/plugins/time-machine.ts:17">
P2: The comment here claims that `IS_DEMO` folds at build time and the recorder is dead-code-eliminated from the demo bundle, but that is not guaranteed by the gating in this file. `timeMachinePlugin` still statically imports `hasTimeMachineHistory` and `stopTimeMachineRecorder` from `time-machine-recorder` and references them in the returned action and cleanup, so the recorder module remains in the bundle. If that module or any of its imports (global clock/animation interceptors, bippy instrumentation) have load-time side effects, the demo build will execute them—exactly the kind of side effect demo mode is meant to avoid. Either restructure the plugin so the recorder is only imported in non-demo builds (e.g., dynamic import with no-op stubs for the demo), or remove the inaccurate dead-code-elimination claim.</violation>

<violation number="2" location="packages/react-grab/src/core/plugins/time-machine.ts:21">
P2: History can still be invoked via the toolbar default action or bare `H` shortcut before any entries exist because those paths bypass `ContextMenuAction.enabled`. Adding the same `hasTimeMachineHistory()` guard inside `onAction` would keep disabled-before-history behavior consistent across invocation paths.</violation>
</file>

<file name="packages/react-grab/src/core/time-machine-recorder.ts">

<violation number="1" location="packages/react-grab/src/core/time-machine-recorder.ts:110">
P2: The TTL-based expiry for travel expectations can misclassify delayed travel commits as real state changes. `TIME_MACHINE_TRAVEL_EXPECTATION_TTL_MS` is only 1000ms, and `dropExpiredExpectations` removes expectations older than that before `consumeTravelExpectation` checks them. If a travel-induced React commit is delayed by concurrent rendering, Suspense, hydration, background-tab throttling, or a CPU stall, the expectation will expire before the commit lands, and the commit will be recorded as a genuine app change instead of being ignored. This can corrupt the timeline with bogus entries or fork/truncate the redo tail unexpectedly. Consider replacing the wall-clock TTL with a more robust signal (e.g., batch-scoped expectations), or at least extending the window and documenting the limitation.</violation>

<violation number="2" location="packages/react-grab/src/core/time-machine-recorder.ts:192">
P3: Timeline entries for batched commits can show the wrong component: later changed fibers are included in `changes`, but `componentName` remains the first changed fiber only. Consider tracking multiple component names or using a generic batched label when a commit spans more than one component.</violation>

<violation number="3" location="packages/react-grab/src/core/time-machine-recorder.ts:202">
P1: Using `Array.prototype.findLastIndex` in `consumeTravelExpectation` introduces an ES2023 runtime dependency that the project does not appear to polyfill or target. If the recorder runs on an older browser/WebView without ES2023 support, this line will throw during travel commit processing, causing the recorder to miss expectation settlement and leaving the time machine broken in those environments. A manual reverse loop gives the same "last match" semantics without the compatibility risk.</violation>

<violation number="4" location="packages/react-grab/src/core/time-machine-recorder.ts:245">
P2: Clearing `pendingTravelValues` inside `stopTimeMachineRecorder` can race with asynchronous React commits. When a travel value is dispatched via `queue.dispatch(() => value)`, the expectation is recorded before the actual commit. If the recorder is stopped and restarted before React commits that update, the expectation is discarded while recording is active again, so `handleCommitFiberRoot` treats the travel-induced state diff as a real application change and records it as history. That could populate the timeline with the time machine's own restore operations and corrupt undo/redo forking. Consider tracking in-flight travel dispatches (e.g., a counter incremented in `applyHookValue` and decremented when the expected commit is consumed or errored) and delaying new history recording until all pending travel commits settle, rather than unconditionally discarding the expectation map on stop.</violation>

<violation number="5" location="packages/react-grab/src/core/time-machine-recorder.ts:287">
P2: When animation- or timer-driven commits are coalesced into the previous timeline entry, the recorder updates the entry's timestamp and final hook values but keeps the `interactionSnapshot` from the original commit. When the user scrubs back to that entry, the time machine applies that stored snapshot as hover/focus/active pins, so the visual interaction state may not reflect the actual moment the entry now represents. Consider refreshing `lastEntry.interactionSnapshot` as part of the coalescing update so the snapshot stays aligned with the updated timestamp and values.</violation>

<violation number="6" location="packages/react-grab/src/core/time-machine-recorder.ts:329">
P2: The new interaction-snapshot pins are only released when the panel closes or the timeline returns to the present. `stopTimeMachineRecorder` is a public teardown path (used as the plugin `cleanup` and called directly in `index.tsx`), but it resets recorder state without releasing those pins. If a caller stops the recorder while rewound without first closing the panel, hover/focus/active styles can stay pinned on the page. Consider adding `releaseInteractionPins()` (and `releaseAnimationClock()` for symmetry) to `stopTimeMachineRecorder` so its teardown is self-contained and doesn't depend on external panel-close ordering.</violation>

<violation number="7" location="packages/react-grab/src/core/time-machine-recorder.ts:345">
P2: Expired travel expectations should be pruned at the start of `applyHookValue` before the no-op check. Right now `settleTravelExpectations` and `consumeTravelExpectation` clean up expired expectations, but `applyHookValue` reads the raw tail expectation directly. If a prior travel batch never produced a commit that touched the queue, a stale, expired expectation can remain and suppress a later restore to the same value, leaving the app state out of sync with the scrubber cursor. Consider calling `dropExpiredExpectations` on the retrieved expectation list before comparing the tail, and fall back to `queue.lastRenderedState` when the list is empty after pruning.</violation>

<violation number="8" location="packages/react-grab/src/core/time-machine-recorder.ts:366">
P2: The user-input attribution used to prevent coalescing only records `pointerdown`, `pointerup`, and `keydown`. State changes triggered by hover/enter/leave/move handlers (for example, tooltips, drag-over states, or hover counters) can therefore be folded into the previous timeline entry if they occur within the settle window and touch a hook queue the previous entry already changed, making the scrubber skip a genuine user moment. Consider adding `pointerenter` and `pointerleave` (and possibly `pointermove`, with care for noise) to `USER_INPUT_EVENTS` so the coalescing logic correctly identifies these as user-driven commits.</violation>

<violation number="9" location="packages/react-grab/src/core/time-machine-recorder.ts:376">
P2: The recorder installs permanent global event listeners and page-clock interception without cleanup. While `isInstrumented`/`isInstalled` prevent stacking within one module instance, dev/HMR or re-init can reset those flags and re-run setup, causing duplicate capture listeners and stacked wrappers around `requestAnimationFrame`, `setTimeout`, and `setInterval`. This is a real lifecycle/leak risk and could also leave stale `lastUserInputAtMs` closures.

I'd recommend tracking the listener handles so they can be removed in `stopTimeMachineRecorder`, and making the page-clock interception idempotent across module reloads (for example, with a global `window` flag) so re-initialization doesn't stack scheduler wrappers.</violation>

<violation number="10" location="packages/react-grab/src/core/time-machine-recorder.ts:382">
P2: The recorder's stop path may leave the page in a frozen/rewound state. `stopTimeMachineRecorder()` resets history and recording flags but does not release `freezePageClock()` / `syncAnimationClock()` / interaction pins. If the recorder is stopped while the cursor is in the past (e.g. via the plugin's own `cleanup` path, or any future caller that doesn't first call `setTimeMachinePanelOpen(false)`), timers, animations, and hover/focus styling will remain frozen after the time machine is supposed to be gone. Consider making `stopTimeMachineRecorder()` defensive by releasing the page clock, animation clock, and interaction pins before clearing state.</violation>

<violation number="11" location="packages/react-grab/src/core/time-machine-recorder.ts:392">
P2: When the time machine cursor is fully rewound to 0, the animation clock is anchored to the first recorded commit's timestamp (`history[0].timestamp`). Cursor 0 actually represents the state *before* that first commit, because the code applies the entry's `previousValue` to revert to it. Freezing animations at the first-commit timestamp means any animation that was already running before the first state change appears visually offset by the time between the initial render and the first commit. Consider recording the recorder start time in `startTimeMachineRecorder` and using it as the animation anchor when `travelCursor === 0`, falling back to the entry timestamp for all other rewound positions.</violation>

<violation number="12" location="packages/react-grab/src/core/time-machine-recorder.ts:405">
P2: Scrubbing the time-machine timeline can show the wrong hover/focus/active visuals because the captured interaction pins are applied synchronously, before React has necessarily committed the rewound state. If the travel update remounts the pinned element (or any ancestor), the style is applied to the stale DOM node and is lost on the committed node, causing a flicker or missing interaction state.

The same commit-timing concern is already handled for animations via a multi-frame `settleSweep` in `time-machine-animation-clock.ts`; interaction pins should follow a similar pattern: apply the snapshot after the next paint/raf cycle and/or schedule a short post-commit sweep so the pins land on the actual rewound DOM. Consider replacing the direct `applyInteractionSnapshot(...)` call with a deferred/synced helper that mirrors the animation clock's sweep behavior.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/core/index.tsx:1525">
P2: The History toolbar action bypasses the normal `handleActivateAction` state machine and does not clear pending selection state. If a user is mid-flow (prompt mode, pending comment, or pending toolbar action) and clicks the History button, `toggleToolbarTimeMachine` opens the panel but leaves `pendingDefaultActionId`, `pendingToolbarActionId`, and prompt/comment state intact. Because `toolbarActiveActionId` prioritizes `timeMachine.isOpen()`, the toolbar then highlights History as the active action while the renderer still carries the stale state. This can produce unexpected behavior when the user later clicks an element or toggles another action.

Suggestion: before opening the History panel from the toolbar, clear any pending toolbar selection state and exit prompt/comment modes, or explicitly decide to deactivate the active renderer first. For example, call `clearPendingToolbarSelection()` and `actions.exitPromptMode()`/`actions.setPendingCommentMode(false)` inside `toggleToolbarTimeMachine` (or before the early return in `handleActivateAction`), matching how other full-popup teardown paths clean up state.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/components/time-machine-panel/index.tsx:145">
P1: When the Time Machine panel is opened from the toolbar (no `element`), it intentionally stays open during pointer interactions with the live app, making it a non-modal utility. However, the container still declares `aria-modal="true"`, which tells screen readers that the background is inert. This ARIA/behavior mismatch can confuse assistive technology users and make the background app appear inaccessible while it is still interactive. Consider making `aria-modal` reflect the panel mode—set it to `"true"` only when `props.state.element` is present, and omit it (or set `"false"`) in the persistent toolbar mode.</violation>

<violation number="2" location="packages/react-grab/src/components/time-machine-panel/index.tsx:155">
P1: When the Time Machine panel is opened from the toolbar (no selected element), the code intentionally keeps pointer clicks on the page alive, but it still captures ArrowLeft/ArrowRight globally in the capture phase. That means any host app keyboard shortcut, canvas control, list navigation, or other arrow-key interaction will be blocked while the panel remains open, creating an inconsistent experience for the new persistent mode. I’d suggest scoping the arrow-key scrubbing shortcut so it only activates when focus is actually inside the panel in the toolbar-opened case, rather than swallowing all non-input arrow events on the page.</violation>
</file>

<file name="packages/react-grab/src/components/icons/icon-history.tsx">

<violation number="1" location="packages/react-grab/src/components/icons/icon-history.tsx:12">
P3: The new `IconHistory` component renders an inline SVG without marking it as decorative or supporting an accessible name. In its current toolbar use, the parent `ToolbarActionButton` provides `aria-label="State history"`, so the SVG is purely decorative, but an unlabeled SVG inside a button can still be exposed as an unnamed graphic by some assistive tech. Add `aria-hidden="true"` to the root `<svg>` so the icon is not announced separately from the button. If the icon might ever be used on its own, consider exposing a `title` prop that toggles the hidden state instead.</violation>
</file>

<file name="packages/react-grab/src/core/time-machine-animation-clock.ts">

<violation number="1" location="packages/react-grab/src/core/time-machine-animation-clock.ts:107">
P1: Newborn animations are incorrectly marked as paused by the time machine, which can cause them to be force-played when the clock is released. In `captureNewbornAnimation`, `didPause` is hard-coded to `true` even when the animation was not running when captured. On release, `releaseAnimationClock` calls `play()` for every record with `didPause: true`, so an animation that was originally paused or finished will be incorrectly started once the time machine closes. Consider recording `didPause` based on whether the animation was actually running when captured (e.g., `const wasRunning = animation.playState === "running"`) and only calling `play()` for animations the clock actually paused.</violation>

<violation number="2" location="packages/react-grab/src/core/time-machine-animation-clock.ts:157">
P2: Stale animation records are never pruned while the time machine is rewound, so repeated scrubbing or remounting can accumulate cancelled/detached `Animation` objects. Because the `recordByAnimation` map holds strong references and `syncAnimationClock` iterates the whole `frozenAnimationRecords` array every step, the list grows and each scrub does more work over time. Consider pruning dead animations (for example, those with `playState === 'idle'` or a null `effect`) from the array and map before seeking, so the set stays bounded to currently live animations.</violation>
</file>

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

<violation number="1" location="packages/react-grab/src/components/time-machine-panel/timeline.tsx:129">
P2: The timeline track is exposed as a `slider` (with `role="slider"`, `aria-valuemin`, `aria-valuemax`, and `aria-valuenow`) but is not keyboard-focusable (`tabIndex={-1}`) and has no keyboard handler. This creates an accessibility mismatch: screen readers will announce an interactive slider, yet keyboard users cannot focus or operate this element directly. Since the ARIA `slider` pattern requires focus and keyboard support, either remove the slider role from this visual-only track or make it focusable (`tabIndex={0}`) and add arrow-key handling that calls `onTravel`.</violation>
</file>

<file name="packages/react-grab/src/core/time-machine-interaction-snapshot.ts">

<violation number="1" location="packages/react-grab/src/core/time-machine-interaction-snapshot.ts:37">
P2: The overlay exclusion check is too narrow for focused elements. `collectFocusedElements` descends into shadow roots, but `isOverlayElement` only looks at the focused element itself, not its shadow host or ancestors. If a React Grab toolbar or Time Machine panel is rendered as a shadow host carrying `data-react-grab`, a focused control inside it would be recorded as a host-app interaction pin and could receive `!important` inline focus styles during scrubbing. Consider checking whether the element is inside a `data-react-grab` host, or whether any ancestor/shadow host has the overlay attribute.</violation>

<violation number="2" location="packages/react-grab/src/core/time-machine-interaction-snapshot.ts:72">
P2: The `:hover` snapshot capture can exhaust the interaction-element cap on layout ancestors before reaching the actual interactive target. `document.querySelectorAll(':hover')` returns the full hovered ancestor chain in document order (root to leaf), and the `capture` loop caps at `TIME_MACHINE_MAX_INTERACTION_ELEMENTS = 24` without prioritizing leaf elements or elements with actual hover styles. In a deeply nested UI, `html`/`body`/wrapper divs can consume the budget and leave the real hovered button/input unrecorded, making the scrubbed replay miss the hover visual that accompanied the state change. Consider capturing `:hover` matches in reverse document order (leaf-to-root) or filtering to elements whose computed hover styles differ from their non-hover state, so the cap is spent on the elements that actually matter.</violation>

<violation number="3" location="packages/react-grab/src/core/time-machine-interaction-snapshot.ts:73">
P2: The interaction snapshot can miss focus-specific visuals for controls that are simultaneously focused and hovered/active. Because the recorder deduplicates by element before the property set is considered, a focused element that was already captured as `:active` or `:hover` is skipped entirely in the focus pass. That leaves focus-only properties like `outline-offset`, `ring-color`, and `ring-width` unpinned, so the element may not visually reflect its focused state when scrubbing back. Consider tracking which properties have already been captured per element and merging the missing focus properties into the existing pin instead of skipping the element.</violation>

<violation number="4" location="packages/react-grab/src/core/time-machine-interaction-snapshot.ts:122">
P2: Interaction snapshots are tied to exact DOM element identity via `WeakRef`, so the visual replay of hover/focus/active states is silently lost when React remounts equivalent elements during time travel. The recorder dispatches restored hook values before applying the snapshot, which can trigger unmount/remount of conditional or keyed subtrees. Once the original element is disconnected, `applyInteractionSnapshot` simply skips it and the pinned interaction styles never reach the replacement element. Consider documenting this scope limitation or adding a fallback mapping strategy (e.g., stable selector or React fiber identity) so equivalent remounted elements can still receive the captured interaction visuals.</violation>
</file>

<file name="packages/react-grab/src/constants.ts">

<violation number="1" location="packages/react-grab/src/constants.ts:358">
P2: The 700ms settle coalescing window can merge semantically distinct async state updates on the same hook queue, causing intermediate application states to disappear from the timeline. In `time-machine-recorder.ts`, `tryCoalesceIntoLastEntry` coalesces any commit within 120ms unconditionally and any commit within 700ms when all changed queues already appear in the previous entry (`areAllQueuesInEntry`). That predicate only checks queue identity and elapsed time, so debounced input normalization, delayed server reconciliation, or chained effects on the same control within 700ms are treated as transition cleanup and swallowed. Consider tightening the settle window or adding a stronger signal (e.g., requiring the commit to match transition-cleanup behavior) before coalescing in the 120–700ms range.</violation>

<violation number="2" location="packages/react-grab/src/constants.ts:363">
P2: The hard-coded coalescing windows (`120ms` burst, `700ms` settle, `200ms` input attribution) can collapse legitimate delayed user actions into the prior timeline entry, which hurts time-machine history correctness. Because the attribution window only guards the first 200ms after input, a debounced input, async validation, or network-driven commit that lands later but still within 700ms can be merged if it touches the same hook queue(s). Consider exposing these windows as plugin configuration options so apps with longer interaction delays can preserve accurate history, or at least document the heuristic limitation and add test coverage for delayed user-driven commits.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread apps/openstory/vite.config.ts
cursoragent and others added 5 commits July 2, 2026 21:15
… in time

Records every useState change per commit via bippy instrumentation and adds
a scrubber panel with a per-component timeline (dots per recorded change,
draggable playhead) that travels the app back and forward through history.

- Travel replays exact values through each hook's own queue.dispatch;
  travel-induced commits are recognized and not re-recorded; changing state
  while rewound forks the timeline like undo/redo
- Works against production React builds (behavioral basicStateReducer probe,
  secure() with dangerouslyRunInProduction)
- While rewound, the page's clock freezes: CSS/WAAPI animations pause (loops
  seek to the rewound moment, finite transitions hold their settled pose),
  and rAF/timeout/interval scheduling is suspended so JS-driven animation
  engines hold still; parked callbacks replay when time resumes
- Animation-tick state bursts coalesce into single settled-moment entries;
  commits near real user input always stay distinct scrub steps
- Each entry captures hover/focus/active styling at record time and rewinding
  pins it back on, purely visually
- Toolbar clock button opens the panel with no element selection and the app
  fully live; the Time Machine context-menu action (shortcut H) opens it
  element-scoped with the page frozen; Escape or toggling closes it and
  deactivate/dispose tear everything down
- Recorder is dead-code-eliminated from the display-only demo build

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

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…re-park replayed callbacks through wrappers

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

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
The openstory CLI merges an inline optionless vite-plugin-solid instance
with the config file's plugins, so the exclude on the configured solid()
never stopped the duplicate from compiling *.react.tsx demo files as
Solid JSX. React then rendered Solid-compiled output and crashed
("TypeError: e is not a function" in the freeze/live-updates demos).
An enforce:pre transform now compiles those files' JSX with the React
automatic runtime first, leaving no JSX for either solid instance.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…o time machine

Each timeline entry now carries its commit's render count and self-time
(bippy getTimings) and any long-animation-frames whose window contains the
entry's commit (PerformanceObserver, attributed asynchronously by time
window). Entries over a frame budget or inside a LoAF are flagged: their
timeline dots turn red and the panel header shows a compact perf badge.
The header also gains a Live pill — red and pulsing at now, grayscale
while rewound, clicking it travels back to now.

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 8 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/components/time-machine-panel/index.tsx">

<violation number="1" location="packages/react-grab/src/components/time-machine-panel/index.tsx:269">
P2: The Live button is visually styled as non-interactive when already live (`cursor-default`, no hover effects) and its click handler does nothing, yet it remains a focusable `<button>` without `disabled` or `aria-disabled`. Screen-reader and keyboard users will hear "Live, button" and can activate a control that has no effect, creating an accessibility/UX mismatch between visual and semantic state.

Consider adding `aria-disabled={isLive() ? "true" : undefined}` (and preventing activation in the keyboard path) so assistive technology correctly communicates when the control is inert, or render a `<span>` when live since it serves only as a status indicator in that state.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

{clockLabel()}
</span>
<button
type="button"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The Live button is visually styled as non-interactive when already live (cursor-default, no hover effects) and its click handler does nothing, yet it remains a focusable <button> without disabled or aria-disabled. Screen-reader and keyboard users will hear "Live, button" and can activate a control that has no effect, creating an accessibility/UX mismatch between visual and semantic state.

Consider adding aria-disabled={isLive() ? "true" : undefined} (and preventing activation in the keyboard path) so assistive technology correctly communicates when the control is inert, or render a <span> when live since it serves only as a status indicator in that state.

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/time-machine-panel/index.tsx, line 269:

<comment>The Live button is visually styled as non-interactive when already live (`cursor-default`, no hover effects) and its click handler does nothing, yet it remains a focusable `<button>` without `disabled` or `aria-disabled`. Screen-reader and keyboard users will hear "Live, button" and can activate a control that has no effect, creating an accessibility/UX mismatch between visual and semantic state.

Consider adding `aria-disabled={isLive() ? "true" : undefined}` (and preventing activation in the keyboard path) so assistive technology correctly communicates when the control is inert, or render a `<span>` when live since it serves only as a status indicator in that state.</comment>

<file context>
@@ -240,12 +248,51 @@ const TimeMachinePanelBody: Component<TimeMachinePanelBodyProps> = (props) => {
+                {clockLabel()}
+              </span>
+              <button
+                type="button"
+                data-react-grab-time-machine-live={isLive() ? "true" : "false"}
+                aria-label={isLive() ? "Live" : "Return to live"}
</file context>

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